feat: enhance device.status with system health sections (#249)
Some checks are pending
Build and Test / release (push) Blocked by required conditions
Build and Test / test (push) Waiting to run
Build and Test / build (win-arm64) (push) Blocked by required conditions
Build and Test / build (win-x64) (push) Blocked by required conditions
Build and Test / build-msix (ARM64, win-arm64) (push) Blocked by required conditions
Build and Test / build-msix (x64, win-x64) (push) Blocked by required conditions
Build and Test / build-extension (arm64) (push) Blocked by required conditions
Build and Test / build-extension (x64) (push) Blocked by required conditions
Some checks are pending
Build and Test / release (push) Blocked by required conditions
Build and Test / test (push) Waiting to run
Build and Test / build (win-arm64) (push) Blocked by required conditions
Build and Test / build (win-x64) (push) Blocked by required conditions
Build and Test / build-msix (ARM64, win-arm64) (push) Blocked by required conditions
Build and Test / build-msix (x64, win-x64) (push) Blocked by required conditions
Build and Test / build-extension (arm64) (push) Blocked by required conditions
Build and Test / build-extension (x64) (push) Blocked by required conditions
Adds richer `device.status` sections while preserving the existing command surface and legacy status fields. - adds an injected `IDeviceStatusProvider` abstraction for platform-specific status collection - adds Windows OS, CPU, memory, disk, and battery status collection - supports `sections[]` filtering with unknown-section validation - preserves legacy battery, thermal, storage, network, and uptime fields for compatibility - adds tests for filtering, failure isolation, legacy compatibility, and provider disposal behavior Closes #240. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
e0c40985a7
commit
24dfd6aebe
@ -11,7 +11,9 @@ using System.Threading.Tasks;
|
||||
namespace OpenClaw.Shared.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Device metadata and lightweight health/status capability.
|
||||
/// Device metadata and system health/status capability.
|
||||
/// device.info - static device metadata (no provider needed).
|
||||
/// device.status - rich system health data via injected IDeviceStatusProvider.
|
||||
/// </summary>
|
||||
public class DeviceCapability : NodeCapabilityBase
|
||||
{
|
||||
@ -23,20 +25,28 @@ public class DeviceCapability : NodeCapabilityBase
|
||||
"device.status"
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> _validSections = new(
|
||||
["os", "cpu", "memory", "disk", "battery"],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly IDeviceStatusProvider? _provider;
|
||||
|
||||
public override IReadOnlyList<string> Commands => _commands;
|
||||
|
||||
public DeviceCapability(IOpenClawLogger logger) : base(logger)
|
||||
public DeviceCapability(IOpenClawLogger logger, IDeviceStatusProvider provider)
|
||||
: base(logger)
|
||||
{
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
public override Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest request)
|
||||
public override async Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest request)
|
||||
{
|
||||
return Task.FromResult(request.Command switch
|
||||
return request.Command switch
|
||||
{
|
||||
"device.info" => HandleInfo(),
|
||||
"device.status" => HandleStatus(),
|
||||
"device.status" => await HandleStatusAsync(request),
|
||||
_ => Error($"Unknown command: {request.Command}")
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private NodeInvokeResponse HandleInfo()
|
||||
@ -60,29 +70,133 @@ public class DeviceCapability : NodeCapabilityBase
|
||||
});
|
||||
}
|
||||
|
||||
private NodeInvokeResponse HandleStatus()
|
||||
private async Task<NodeInvokeResponse> HandleStatusAsync(NodeInvokeRequest request)
|
||||
{
|
||||
Logger.Info("device.status");
|
||||
if (_provider == null)
|
||||
return Error("Device status provider not available");
|
||||
|
||||
var storage = GetStorageStatus(Logger);
|
||||
var network = GetNetworkStatus(Logger);
|
||||
var sections = GetStringArrayArg(request.Args, "sections");
|
||||
|
||||
return Success(new
|
||||
// Reject unknown section names
|
||||
var invalid = sections.Where(s => !_validSections.Contains(s)).ToArray();
|
||||
if (invalid.Length > 0)
|
||||
{
|
||||
battery = new
|
||||
return Error($"Unknown sections: {string.Join(", ", invalid)}. "
|
||||
+ $"Valid: {string.Join(", ", _validSections)}");
|
||||
}
|
||||
|
||||
bool all = sections.Length == 0;
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
["collectedAt"] = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
|
||||
if (all || sections.Contains("os", StringComparer.OrdinalIgnoreCase))
|
||||
result["os"] = SafeCollect("os", () => _provider.GetOsInfo());
|
||||
|
||||
if (all || sections.Contains("cpu", StringComparer.OrdinalIgnoreCase))
|
||||
result["cpu"] = await SafeCollectAsync("cpu", () => _provider.GetCpuInfoAsync());
|
||||
|
||||
if (all || sections.Contains("memory", StringComparer.OrdinalIgnoreCase))
|
||||
result["memory"] = SafeCollect("memory", () => _provider.GetMemoryInfo());
|
||||
|
||||
if (all || sections.Contains("disk", StringComparer.OrdinalIgnoreCase))
|
||||
result["disk"] = SafeCollect("disk", () => _provider.GetDiskInfo());
|
||||
|
||||
if (all || sections.Contains("battery", StringComparer.OrdinalIgnoreCase))
|
||||
result["battery"] = SafeCollect("battery", () => WrapBatteryWithLegacyFields(_provider.GetBatteryInfo()));
|
||||
|
||||
// Always ensure legacy battery fields exist for backward compatibility.
|
||||
// Old contract: { level: null, state: "unknown", lowPowerModeEnabled: false }
|
||||
// Covers: battery not requested (filtered out), provider threw (SafeCollect
|
||||
// returned { error }), or battery is null.
|
||||
{
|
||||
var hasBattery = result.TryGetValue("battery", out var batteryVal) && batteryVal != null;
|
||||
var isError = hasBattery && batteryVal!.GetType().GetProperty("error") != null;
|
||||
|
||||
if (!hasBattery || isError)
|
||||
{
|
||||
level = (double?)null,
|
||||
state = "unknown",
|
||||
lowPowerModeEnabled = false
|
||||
},
|
||||
thermal = new
|
||||
{
|
||||
state = "nominal"
|
||||
},
|
||||
storage,
|
||||
network,
|
||||
uptimeSeconds = Environment.TickCount64 / 1000.0
|
||||
});
|
||||
string? errorMsg = null;
|
||||
if (isError)
|
||||
{
|
||||
var errProp = batteryVal!.GetType().GetProperty("error")!.GetValue(batteryVal);
|
||||
errorMsg = errProp?.ToString();
|
||||
}
|
||||
|
||||
result["battery"] = new
|
||||
{
|
||||
level = (double?)null,
|
||||
state = "unknown",
|
||||
lowPowerModeEnabled = false,
|
||||
error = errorMsg
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fields preserved for backward compatibility with existing consumers.
|
||||
result["thermal"] = new { state = "nominal" };
|
||||
result["storage"] = SafeCollect("storage", () => GetStorageStatus());
|
||||
result["network"] = SafeCollect("network", () => GetNetworkStatus());
|
||||
result["uptimeSeconds"] = Environment.TickCount64 / 1000.0;
|
||||
|
||||
return Success(result);
|
||||
}
|
||||
|
||||
/// <summary>Per-section fault tolerance: one section failing doesn't kill the whole response.</summary>
|
||||
private object? SafeCollect(string section, Func<object> collector)
|
||||
{
|
||||
try { return collector(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"device.status: {section} collection failed: {ex.Message}");
|
||||
return new { error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object?> SafeCollectAsync(string section, Func<Task<object>> collector)
|
||||
{
|
||||
try { return await collector(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"device.status: {section} collection failed: {ex.Message}");
|
||||
return new { error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the provider's battery result with legacy fields (level, state, lowPowerModeEnabled)
|
||||
/// so old consumers that read battery.level / battery.state continue to work.
|
||||
/// </summary>
|
||||
private static object WrapBatteryWithLegacyFields(object providerResult)
|
||||
{
|
||||
// Serialize the provider result to a dictionary so we can merge legacy fields.
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(providerResult);
|
||||
var dict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(json)
|
||||
?? new Dictionary<string, System.Text.Json.JsonElement>();
|
||||
|
||||
// Map new fields to legacy equivalents.
|
||||
double? level = null;
|
||||
if (dict.TryGetValue("chargePercent", out var cp) && cp.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
level = cp.GetDouble();
|
||||
|
||||
var isCharging = dict.TryGetValue("isCharging", out var ic)
|
||||
&& ic.ValueKind == System.Text.Json.JsonValueKind.True;
|
||||
|
||||
var state = isCharging ? "charging" : (level.HasValue ? "discharging" : "unknown");
|
||||
|
||||
var result = new Dictionary<string, object?>
|
||||
{
|
||||
// Legacy fields
|
||||
["level"] = level,
|
||||
["state"] = state,
|
||||
["lowPowerModeEnabled"] = false,
|
||||
};
|
||||
|
||||
// Merge all new fields from provider
|
||||
foreach (var kv in dict)
|
||||
result[kv.Key] = kv.Value;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetModelIdentifier()
|
||||
@ -96,67 +210,59 @@ public class DeviceCapability : NodeCapabilityBase
|
||||
return $"{RuntimeInformation.OSArchitecture}".ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static object GetStorageStatus(IOpenClawLogger logger)
|
||||
#region Legacy helpers (backward compat)
|
||||
|
||||
private static object GetStorageStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
||||
?? Path.GetPathRoot(AppContext.BaseDirectory)
|
||||
?? string.Empty;
|
||||
var drive = !string.IsNullOrWhiteSpace(root)
|
||||
? new DriveInfo(root)
|
||||
: DriveInfo.GetDrives().FirstOrDefault(d => d.IsReady);
|
||||
var root = Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
||||
?? Path.GetPathRoot(AppContext.BaseDirectory)
|
||||
?? string.Empty;
|
||||
var drive = !string.IsNullOrWhiteSpace(root)
|
||||
? new DriveInfo(root)
|
||||
: DriveInfo.GetDrives().FirstOrDefault(d => d.IsReady);
|
||||
|
||||
if (drive is { IsReady: true })
|
||||
if (drive is { IsReady: true })
|
||||
{
|
||||
var totalBytes = drive.TotalSize;
|
||||
var freeBytes = drive.AvailableFreeSpace;
|
||||
return new
|
||||
{
|
||||
var totalBytes = drive.TotalSize;
|
||||
var freeBytes = drive.AvailableFreeSpace;
|
||||
return new
|
||||
{
|
||||
totalBytes,
|
||||
freeBytes,
|
||||
usedBytes = Math.Max(0, totalBytes - freeBytes)
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warn($"device.status: storage status unavailable: {ex.Message}");
|
||||
totalBytes,
|
||||
freeBytes,
|
||||
usedBytes = Math.Max(0, totalBytes - freeBytes)
|
||||
};
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
totalBytes = 0L,
|
||||
freeBytes = 0L,
|
||||
usedBytes = 0L
|
||||
};
|
||||
return new { totalBytes = 0L, freeBytes = 0L, usedBytes = 0L };
|
||||
}
|
||||
|
||||
private static object GetNetworkStatus(IOpenClawLogger logger)
|
||||
private static object GetNetworkStatus()
|
||||
{
|
||||
var interfaces = Array.Empty<string>();
|
||||
string[] interfaces;
|
||||
try
|
||||
{
|
||||
interfaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(nic => nic.OperationalStatus == OperationalStatus.Up)
|
||||
.Select(MapInterfaceType)
|
||||
.Select(nic => nic.NetworkInterfaceType switch
|
||||
{
|
||||
NetworkInterfaceType.Wireless80211 => "wifi",
|
||||
NetworkInterfaceType.Ethernet
|
||||
or NetworkInterfaceType.GigabitEthernet
|
||||
or NetworkInterfaceType.FastEthernetFx
|
||||
or NetworkInterfaceType.FastEthernetT => "wired",
|
||||
NetworkInterfaceType.Ppp
|
||||
or NetworkInterfaceType.Wwanpp
|
||||
or NetworkInterfaceType.Wwanpp2 => "cellular",
|
||||
_ => "other"
|
||||
})
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warn($"device.status: network interfaces unavailable: {ex.Message}");
|
||||
}
|
||||
catch { interfaces = []; }
|
||||
|
||||
var isAvailable = false;
|
||||
try
|
||||
{
|
||||
isAvailable = NetworkInterface.GetIsNetworkAvailable();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warn($"device.status: network availability unavailable: {ex.Message}");
|
||||
}
|
||||
bool isAvailable;
|
||||
try { isAvailable = NetworkInterface.GetIsNetworkAvailable(); }
|
||||
catch { isAvailable = false; }
|
||||
|
||||
return new
|
||||
{
|
||||
@ -167,19 +273,5 @@ public class DeviceCapability : NodeCapabilityBase
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapInterfaceType(NetworkInterface nic)
|
||||
{
|
||||
return nic.NetworkInterfaceType switch
|
||||
{
|
||||
NetworkInterfaceType.Wireless80211 => "wifi",
|
||||
NetworkInterfaceType.Ethernet
|
||||
or NetworkInterfaceType.GigabitEthernet
|
||||
or NetworkInterfaceType.FastEthernetFx
|
||||
or NetworkInterfaceType.FastEthernetT => "wired",
|
||||
NetworkInterfaceType.Ppp
|
||||
or NetworkInterfaceType.Wwanpp
|
||||
or NetworkInterfaceType.Wwanpp2 => "cellular",
|
||||
_ => "other"
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
27
src/OpenClaw.Shared/IDeviceStatusProvider.cs
Normal file
27
src/OpenClaw.Shared/IDeviceStatusProvider.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OpenClaw.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Provider interface for platform-specific device status data collection.
|
||||
/// Each method returns an object that will be serialized to JSON.
|
||||
/// Implementations should handle their own error cases gracefully.
|
||||
/// </summary>
|
||||
public interface IDeviceStatusProvider : IDisposable
|
||||
{
|
||||
/// <summary>OS version, architecture, machine name, uptime.</summary>
|
||||
object GetOsInfo();
|
||||
|
||||
/// <summary>CPU name, logical processor count, usage percent (may be null during warm-up).</summary>
|
||||
Task<object> GetCpuInfoAsync();
|
||||
|
||||
/// <summary>Total/available memory in bytes and usage percent.</summary>
|
||||
object GetMemoryInfo();
|
||||
|
||||
/// <summary>Fixed drive info: name, label, total/free bytes, usage percent, format.</summary>
|
||||
object GetDiskInfo();
|
||||
|
||||
/// <summary>Battery presence, charge level, charging state, estimated time remaining.</summary>
|
||||
object GetBatteryInfo();
|
||||
}
|
||||
@ -56,6 +56,7 @@
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4654" />
|
||||
<PackageReference Include="WinUIEx" Version="2.9.0" />
|
||||
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.7" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||
<PackageReference Include="Updatum" Version="1.3.4" />
|
||||
|
||||
250
src/OpenClaw.Tray.WinUI/Services/DeviceStatusProvider.cs
Normal file
250
src/OpenClaw.Tray.WinUI/Services/DeviceStatusProvider.cs
Normal file
@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenClaw.Shared;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace OpenClawTray.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Windows-specific implementation of IDeviceStatusProvider.
|
||||
/// Uses WinRT for battery, P/Invoke for memory, registry for CPU name,
|
||||
/// and a cached background sampler for CPU usage.
|
||||
/// </summary>
|
||||
public class DeviceStatusProvider : IDeviceStatusProvider
|
||||
{
|
||||
private readonly IOpenClawLogger _logger;
|
||||
private double? _lastCpuUsage;
|
||||
private Timer? _cpuSampler;
|
||||
private PerformanceCounter? _cpuCounter;
|
||||
private bool _disposed;
|
||||
|
||||
public DeviceStatusProvider(IOpenClawLogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background CPU usage sampler. Call once after construction.
|
||||
/// The first valid reading appears after ~2 seconds.
|
||||
/// </summary>
|
||||
public void StartCpuSampling()
|
||||
{
|
||||
try
|
||||
{
|
||||
_cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
|
||||
_cpuCounter.NextValue(); // Prime the counter (first read is always 0)
|
||||
_cpuSampler = new Timer(_ =>
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
var counter = _cpuCounter;
|
||||
if (counter != null)
|
||||
_lastCpuUsage = Math.Round(counter.NextValue(), 1);
|
||||
}
|
||||
catch { /* counter unavailable or disposed */ }
|
||||
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn($"CPU sampling unavailable: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public object GetOsInfo()
|
||||
{
|
||||
return new
|
||||
{
|
||||
version = Environment.OSVersion.Version.ToString(),
|
||||
architecture = RuntimeInformation.OSArchitecture.ToString(),
|
||||
machineName = Environment.MachineName,
|
||||
uptimeSeconds = Environment.TickCount64 / 1000
|
||||
};
|
||||
}
|
||||
|
||||
public Task<object> GetCpuInfoAsync()
|
||||
{
|
||||
string? cpuName = null;
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(
|
||||
@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
|
||||
cpuName = key?.GetValue("ProcessorNameString") as string;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Registry access may be restricted
|
||||
}
|
||||
|
||||
object result = new
|
||||
{
|
||||
name = cpuName?.Trim(),
|
||||
logicalProcessors = Environment.ProcessorCount,
|
||||
usagePercent = _lastCpuUsage
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public object GetMemoryInfo()
|
||||
{
|
||||
var status = new MEMORYSTATUSEX { dwLength = (uint)Marshal.SizeOf<MEMORYSTATUSEX>() };
|
||||
if (!GlobalMemoryStatusEx(ref status))
|
||||
throw new InvalidOperationException("GlobalMemoryStatusEx failed");
|
||||
|
||||
var totalBytes = (long)status.ullTotalPhys;
|
||||
var availableBytes = (long)status.ullAvailPhys;
|
||||
var usagePercent = totalBytes > 0
|
||||
? Math.Round((1.0 - (double)availableBytes / totalBytes) * 100, 1)
|
||||
: 0.0;
|
||||
|
||||
return new
|
||||
{
|
||||
totalBytes,
|
||||
availableBytes,
|
||||
usagePercent
|
||||
};
|
||||
}
|
||||
|
||||
public object GetDiskInfo()
|
||||
{
|
||||
var drives = DriveInfo.GetDrives()
|
||||
.Where(d => d.DriveType == DriveType.Fixed && d.IsReady)
|
||||
.Select(d =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var totalBytes = d.TotalSize;
|
||||
var freeBytes = d.AvailableFreeSpace;
|
||||
return (object?)new
|
||||
{
|
||||
name = d.Name,
|
||||
label = d.VolumeLabel,
|
||||
totalBytes,
|
||||
freeBytes,
|
||||
usagePercent = totalBytes > 0
|
||||
? Math.Round((1.0 - (double)freeBytes / totalBytes) * 100, 1)
|
||||
: 0.0,
|
||||
format = d.DriveFormat
|
||||
};
|
||||
}
|
||||
catch { return null; }
|
||||
})
|
||||
.Where(d => d != null)
|
||||
.ToArray();
|
||||
|
||||
return new { drives };
|
||||
}
|
||||
|
||||
public object GetBatteryInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
var battery = global::Windows.Devices.Power.Battery.AggregateBattery;
|
||||
var report = battery.GetReport();
|
||||
|
||||
if (report.Status == global::Windows.System.Power.BatteryStatus.NotPresent)
|
||||
{
|
||||
return new
|
||||
{
|
||||
present = false,
|
||||
chargePercent = (int?)null,
|
||||
isCharging = false,
|
||||
estimatedMinutesRemaining = (int?)null
|
||||
};
|
||||
}
|
||||
|
||||
int? chargePercent = null;
|
||||
if (report.FullChargeCapacityInMilliwattHours.HasValue &&
|
||||
report.RemainingCapacityInMilliwattHours.HasValue &&
|
||||
report.FullChargeCapacityInMilliwattHours.Value > 0)
|
||||
{
|
||||
chargePercent = (int)Math.Round(
|
||||
(double)report.RemainingCapacityInMilliwattHours.Value /
|
||||
report.FullChargeCapacityInMilliwattHours.Value * 100);
|
||||
}
|
||||
|
||||
bool isCharging = report.Status == global::Windows.System.Power.BatteryStatus.Charging;
|
||||
|
||||
// Estimate minutes remaining when discharging
|
||||
int? estimatedMinutesRemaining = null;
|
||||
if (report.Status == global::Windows.System.Power.BatteryStatus.Discharging &&
|
||||
report.ChargeRateInMilliwatts.HasValue &&
|
||||
report.ChargeRateInMilliwatts.Value < 0 &&
|
||||
report.RemainingCapacityInMilliwattHours.HasValue)
|
||||
{
|
||||
var rateWatts = Math.Abs(report.ChargeRateInMilliwatts.Value);
|
||||
if (rateWatts > 0)
|
||||
{
|
||||
estimatedMinutesRemaining = (int)(
|
||||
(double)report.RemainingCapacityInMilliwattHours.Value / rateWatts * 60);
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
present = true,
|
||||
chargePercent,
|
||||
isCharging,
|
||||
estimatedMinutesRemaining
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn($"Battery info unavailable: {ex.Message}");
|
||||
return new
|
||||
{
|
||||
present = false,
|
||||
chargePercent = (int?)null,
|
||||
isCharging = false,
|
||||
estimatedMinutesRemaining = (int?)null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Drain the timer: wait for any in-flight callback to complete
|
||||
// before disposing the counter it reads.
|
||||
using var timerDrained = new ManualResetEvent(false);
|
||||
if (_cpuSampler != null)
|
||||
{
|
||||
_cpuSampler.Dispose(timerDrained);
|
||||
timerDrained.WaitOne(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
_cpuSampler = null;
|
||||
|
||||
_cpuCounter?.Dispose();
|
||||
_cpuCounter = null;
|
||||
}
|
||||
|
||||
#region P/Invoke
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MEMORYSTATUSEX
|
||||
{
|
||||
public uint dwLength;
|
||||
public uint dwMemoryLoad;
|
||||
public ulong ullTotalPhys;
|
||||
public ulong ullAvailPhys;
|
||||
public ulong ullTotalPageFile;
|
||||
public ulong ullAvailPageFile;
|
||||
public ulong ullTotalVirtual;
|
||||
public ulong ullAvailVirtual;
|
||||
public ulong ullAvailExtendedVirtual;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
|
||||
|
||||
#endregion
|
||||
}
|
||||
@ -68,6 +68,7 @@ public sealed class NodeService : IDisposable
|
||||
private CameraCapability? _cameraCapability;
|
||||
private LocationCapability? _locationCapability;
|
||||
private DeviceCapability? _deviceCapability;
|
||||
private DeviceStatusProvider? _deviceStatusProvider;
|
||||
private BrowserProxyCapability? _browserProxyCapability;
|
||||
private TtsCapability? _ttsCapability;
|
||||
private TextToSpeechService? _textToSpeechService;
|
||||
@ -292,8 +293,11 @@ public sealed class NodeService : IDisposable
|
||||
Register(_ttsCapability);
|
||||
}
|
||||
|
||||
// Device metadata/status capability
|
||||
_deviceCapability = new DeviceCapability(_logger);
|
||||
// Device metadata/status capability - dispose previous provider on re-registration
|
||||
_deviceStatusProvider?.Dispose();
|
||||
_deviceStatusProvider = new DeviceStatusProvider(_logger);
|
||||
_deviceStatusProvider.StartCpuSampling();
|
||||
_deviceCapability = new DeviceCapability(_logger, _deviceStatusProvider);
|
||||
Register(_deviceCapability);
|
||||
|
||||
// BrowserProxy needs a live gateway connection — only register when gateway is up.
|
||||
@ -1310,6 +1314,8 @@ public sealed class NodeService : IDisposable
|
||||
|
||||
try { _navigationPromptGate.Dispose(); } catch { /* ignore */ }
|
||||
|
||||
try { _deviceStatusProvider?.Dispose(); } catch { /* ignore */ }
|
||||
|
||||
if (_canvasWindow != null && !_canvasWindow.IsClosed)
|
||||
{
|
||||
var window = _canvasWindow;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
@ -1474,71 +1475,301 @@ public class DeviceCapabilityTests
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_DeviceCommands()
|
||||
private class FakeDeviceStatusProvider : IDeviceStatusProvider
|
||||
{
|
||||
var cap = new DeviceCapability(NullLogger.Instance);
|
||||
public bool ThrowOnBattery { get; set; }
|
||||
|
||||
Assert.True(cap.CanHandle("device.info"));
|
||||
public object GetOsInfo() => new
|
||||
{
|
||||
version = "10.0.99999",
|
||||
architecture = "X64",
|
||||
machineName = "TEST-PC",
|
||||
uptimeSeconds = 3600L
|
||||
};
|
||||
|
||||
public Task<object> GetCpuInfoAsync() => Task.FromResult<object>(new
|
||||
{
|
||||
name = "Test CPU",
|
||||
logicalProcessors = 8,
|
||||
usagePercent = (double?)42.0
|
||||
});
|
||||
|
||||
public object GetMemoryInfo() => new
|
||||
{
|
||||
totalBytes = 16L * 1024 * 1024 * 1024,
|
||||
availableBytes = 8L * 1024 * 1024 * 1024,
|
||||
usagePercent = 50.0
|
||||
};
|
||||
|
||||
public object GetDiskInfo() => new
|
||||
{
|
||||
drives = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "C:\\",
|
||||
label = "OS",
|
||||
totalBytes = 500L * 1024 * 1024 * 1024,
|
||||
freeBytes = 250L * 1024 * 1024 * 1024,
|
||||
usagePercent = 50.0,
|
||||
format = "NTFS"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public object GetBatteryInfo() => ThrowOnBattery
|
||||
? throw new Exception("No battery hardware")
|
||||
: new
|
||||
{
|
||||
present = true,
|
||||
chargePercent = (int?)85,
|
||||
isCharging = false,
|
||||
estimatedMinutesRemaining = (int?)120
|
||||
};
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private static DeviceCapability CreateCapability(FakeDeviceStatusProvider? provider = null)
|
||||
{
|
||||
return new DeviceCapability(NullLogger.Instance, provider ?? new FakeDeviceStatusProvider());
|
||||
}
|
||||
|
||||
private static JsonElement GetPayload(NodeInvokeResponse res)
|
||||
{
|
||||
return JsonSerializer.Deserialize<JsonElement>(JsonSerializer.Serialize(res.Payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_DeviceStatus()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
Assert.True(cap.CanHandle("device.status"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_UnknownCommand()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
Assert.False(cap.CanHandle("device.unknown"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_IsDevice()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
Assert.Equal("device", cap.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceInfo_ReturnsMacCompatiblePayloadShape()
|
||||
public async Task Status_ReturnsAllSections_WhenNoFilter()
|
||||
{
|
||||
var cap = new DeviceCapability(NullLogger.Instance);
|
||||
var req = new NodeInvokeRequest { Id = "d1", Command = "device.info", Args = Parse("""{}""") };
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest { Id = "t1", Command = "device.status", Args = Parse("""{}""") };
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var payload = JsonSerializer.Deserialize<JsonElement>(JsonSerializer.Serialize(res.Payload));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("deviceName").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("modelIdentifier").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("systemName").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("systemVersion").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("appVersion").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("appBuild").GetString()));
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("locale").ValueKind);
|
||||
var payload = GetPayload(res);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("collectedAt").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("os").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("cpu").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("memory").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("disk").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("battery").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceStatus_ReturnsMacCompatiblePayloadShape()
|
||||
public async Task Status_ReturnsOnlyRequested_WhenFiltered()
|
||||
{
|
||||
var cap = new DeviceCapability(NullLogger.Instance);
|
||||
var req = new NodeInvokeRequest { Id = "d2", Command = "device.status", Args = Parse("""{}""") };
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest
|
||||
{
|
||||
Id = "t2",
|
||||
Command = "device.status",
|
||||
Args = Parse("""{"sections":["os","disk"]}""")
|
||||
};
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var payload = JsonSerializer.Deserialize<JsonElement>(JsonSerializer.Serialize(res.Payload));
|
||||
var payload = GetPayload(res);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("collectedAt").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("os").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("disk").ValueKind);
|
||||
// cpu, memory should NOT be present
|
||||
Assert.False(payload.TryGetProperty("cpu", out _));
|
||||
Assert.False(payload.TryGetProperty("memory", out _));
|
||||
// battery always present for legacy compat (fallback stub when not requested)
|
||||
var battery = payload.GetProperty("battery");
|
||||
Assert.Equal("unknown", battery.GetProperty("state").GetString());
|
||||
Assert.False(battery.GetProperty("lowPowerModeEnabled").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, battery.GetProperty("level").ValueKind);
|
||||
}
|
||||
|
||||
Assert.Equal("nominal", payload.GetProperty("thermal").GetProperty("state").GetString());
|
||||
[Fact]
|
||||
public async Task Status_RejectsUnknownSections()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest
|
||||
{
|
||||
Id = "t3",
|
||||
Command = "device.status",
|
||||
Args = Parse("""{"sections":["os","bogus"]}""")
|
||||
};
|
||||
|
||||
var storage = payload.GetProperty("storage");
|
||||
Assert.True(storage.GetProperty("totalBytes").GetInt64() >= 0);
|
||||
Assert.True(storage.GetProperty("freeBytes").GetInt64() >= 0);
|
||||
Assert.True(storage.GetProperty("usedBytes").GetInt64() >= 0);
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
var network = payload.GetProperty("network");
|
||||
Assert.Contains(network.GetProperty("status").GetString(), new[] { "satisfied", "unsatisfied" });
|
||||
Assert.False(network.GetProperty("isExpensive").GetBoolean());
|
||||
Assert.False(network.GetProperty("isConstrained").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Array, network.GetProperty("interfaces").ValueKind);
|
||||
Assert.False(res.Ok);
|
||||
Assert.Contains("bogus", res.Error);
|
||||
Assert.Contains("Valid:", res.Error);
|
||||
}
|
||||
|
||||
Assert.True(payload.GetProperty("uptimeSeconds").GetDouble() >= 0);
|
||||
[Fact]
|
||||
public async Task Status_OsSection_HasExpectedFields()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest
|
||||
{
|
||||
Id = "t4",
|
||||
Command = "device.status",
|
||||
Args = Parse("""{"sections":["os"]}""")
|
||||
};
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var os = GetPayload(res).GetProperty("os");
|
||||
Assert.Equal("10.0.99999", os.GetProperty("version").GetString());
|
||||
Assert.Equal("X64", os.GetProperty("architecture").GetString());
|
||||
Assert.Equal("TEST-PC", os.GetProperty("machineName").GetString());
|
||||
Assert.Equal(3600, os.GetProperty("uptimeSeconds").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_DiskSection_HasDrives()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest
|
||||
{
|
||||
Id = "t5",
|
||||
Command = "device.status",
|
||||
Args = Parse("""{"sections":["disk"]}""")
|
||||
};
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var disk = GetPayload(res).GetProperty("disk");
|
||||
var drives = disk.GetProperty("drives");
|
||||
Assert.Equal(JsonValueKind.Array, drives.ValueKind);
|
||||
Assert.True(drives.GetArrayLength() > 0);
|
||||
var firstDrive = drives[0];
|
||||
Assert.True(firstDrive.GetProperty("totalBytes").GetInt64() > 0);
|
||||
Assert.Equal("NTFS", firstDrive.GetProperty("format").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_MemorySection_HasUsage()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest
|
||||
{
|
||||
Id = "t6",
|
||||
Command = "device.status",
|
||||
Args = Parse("""{"sections":["memory"]}""")
|
||||
};
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var mem = GetPayload(res).GetProperty("memory");
|
||||
Assert.True(mem.GetProperty("totalBytes").GetInt64() > 0);
|
||||
var usage = mem.GetProperty("usagePercent").GetDouble();
|
||||
Assert.InRange(usage, 0, 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_BatterySection_GracefulOnFailure()
|
||||
{
|
||||
var provider = new FakeDeviceStatusProvider { ThrowOnBattery = true };
|
||||
var cap = CreateCapability(provider);
|
||||
var req = new NodeInvokeRequest
|
||||
{
|
||||
Id = "t7",
|
||||
Command = "device.status",
|
||||
Args = Parse("""{"sections":["battery"]}""")
|
||||
};
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
// Command should succeed - battery section has error + legacy fields
|
||||
Assert.True(res.Ok);
|
||||
var battery = GetPayload(res).GetProperty("battery");
|
||||
Assert.NotEqual(JsonValueKind.Undefined, battery.GetProperty("error").ValueKind);
|
||||
Assert.Contains("No battery hardware", battery.GetProperty("error").GetString());
|
||||
// Legacy fields must still be present for backward compat
|
||||
Assert.True(battery.TryGetProperty("level", out _), "battery.level missing on failure path");
|
||||
Assert.Equal("unknown", battery.GetProperty("state").GetString());
|
||||
Assert.True(battery.TryGetProperty("lowPowerModeEnabled", out _), "battery.lowPowerModeEnabled missing on failure path");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_EmptySectionsArray_ReturnsAll()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest
|
||||
{
|
||||
Id = "t8",
|
||||
Command = "device.status",
|
||||
Args = Parse("""{"sections":[]}""")
|
||||
};
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var payload = GetPayload(res);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("os").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("cpu").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("memory").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("disk").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("battery").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_HasCollectedAtTimestamp()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest { Id = "t9", Command = "device.status", Args = Parse("""{}""") };
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var collectedAt = GetPayload(res).GetProperty("collectedAt").GetString();
|
||||
Assert.NotNull(collectedAt);
|
||||
// Verify it's valid ISO 8601
|
||||
Assert.True(DateTimeOffset.TryParse(collectedAt, out var dto));
|
||||
// Should be recent (within last 10 seconds)
|
||||
Assert.InRange(dto, DateTimeOffset.UtcNow.AddSeconds(-10), DateTimeOffset.UtcNow.AddSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceInfo_StillWorks()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest { Id = "di", Command = "device.info", Args = Parse("""{}""") };
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var payload = GetPayload(res);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("deviceName").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("systemName").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("appVersion").GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceUnknownCommand_ReturnsError()
|
||||
{
|
||||
var cap = new DeviceCapability(NullLogger.Instance);
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest { Id = "d3", Command = "device.unknown", Args = Parse("""{}""") };
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
@ -1546,6 +1777,137 @@ public class DeviceCapabilityTests
|
||||
Assert.False(res.Ok);
|
||||
Assert.Contains("Unknown command", res.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_Default_ContainsLegacyFields()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest { Id = "leg1", Command = "device.status", Args = Parse("""{}""") };
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var payload = GetPayload(res);
|
||||
|
||||
// Legacy battery fields — must be present even when provider returns new shape
|
||||
var battery = payload.GetProperty("battery");
|
||||
Assert.NotEqual(JsonValueKind.Undefined, battery.ValueKind);
|
||||
// Old contract fields: level, state, lowPowerModeEnabled
|
||||
Assert.True(battery.TryGetProperty("level", out _), "battery.level missing");
|
||||
Assert.True(battery.TryGetProperty("state", out _), "battery.state missing");
|
||||
Assert.True(battery.TryGetProperty("lowPowerModeEnabled", out _), "battery.lowPowerModeEnabled missing");
|
||||
// New fields also present
|
||||
Assert.True(battery.TryGetProperty("present", out _), "battery.present missing");
|
||||
Assert.True(battery.TryGetProperty("chargePercent", out _), "battery.chargePercent missing");
|
||||
|
||||
// Legacy thermal
|
||||
var thermal = payload.GetProperty("thermal");
|
||||
Assert.Equal("nominal", thermal.GetProperty("state").GetString());
|
||||
|
||||
// Legacy storage
|
||||
var storage = payload.GetProperty("storage");
|
||||
Assert.NotEqual(JsonValueKind.Undefined, storage.ValueKind);
|
||||
|
||||
// Legacy network
|
||||
var network = payload.GetProperty("network");
|
||||
Assert.NotEqual(JsonValueKind.Undefined, network.ValueKind);
|
||||
|
||||
// Legacy top-level uptimeSeconds
|
||||
var uptime = payload.GetProperty("uptimeSeconds").GetDouble();
|
||||
Assert.True(uptime >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_Filtered_StillContainsLegacyFields()
|
||||
{
|
||||
var cap = CreateCapability();
|
||||
var req = new NodeInvokeRequest
|
||||
{
|
||||
Id = "leg2",
|
||||
Command = "device.status",
|
||||
Args = Parse("""{"sections":["os","disk"]}""")
|
||||
};
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var payload = GetPayload(res);
|
||||
|
||||
// Requested sections present
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("os").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("disk").ValueKind);
|
||||
|
||||
// Legacy fields always present regardless of sections filter
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("battery").ValueKind);
|
||||
Assert.Equal("nominal", payload.GetProperty("thermal").GetProperty("state").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("storage").ValueKind);
|
||||
Assert.NotEqual(JsonValueKind.Undefined, payload.GetProperty("network").ValueKind);
|
||||
Assert.True(payload.GetProperty("uptimeSeconds").GetDouble() >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_LazyDiskProvider_DoesNotCrashCommand()
|
||||
{
|
||||
// A provider whose GetDiskInfo returns a lazy enumerable that throws
|
||||
// during enumeration. SafeCollect catches synchronous throws; the lazy
|
||||
// throw happens during serialization. This test verifies the command
|
||||
// still completes (Success wraps the result; serialization is the
|
||||
// caller's concern). The real DeviceStatusProvider materializes with
|
||||
// .ToArray() to prevent this scenario.
|
||||
var provider = new LazyThrowingDiskProvider();
|
||||
var cap = new DeviceCapability(NullLogger.Instance, provider);
|
||||
var req = new NodeInvokeRequest
|
||||
{
|
||||
Id = "lazy1",
|
||||
Command = "device.status",
|
||||
Args = Parse("""{"sections":["disk"]}""")
|
||||
};
|
||||
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
// Command itself succeeds — the lazy enumerable hasn't been iterated yet.
|
||||
// This confirms SafeCollect doesn't mask the issue; materialization in
|
||||
// the provider (.ToArray()) is what actually prevents serialization failures.
|
||||
Assert.True(res.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_ProviderDisposal_GetCpuInfoStillSafe()
|
||||
{
|
||||
var provider = new FakeDeviceStatusProvider();
|
||||
var cap = new DeviceCapability(NullLogger.Instance, provider);
|
||||
|
||||
// Dispose the provider before calling
|
||||
provider.Dispose();
|
||||
|
||||
// GetCpuInfoAsync should still return data (fake doesn't depend on timer)
|
||||
var req = new NodeInvokeRequest { Id = "disp1", Command = "device.status", Args = Parse("""{"sections":["cpu"]}""") };
|
||||
var res = await cap.ExecuteAsync(req);
|
||||
|
||||
Assert.True(res.Ok);
|
||||
var cpu = GetPayload(res).GetProperty("cpu");
|
||||
Assert.Equal(8, cpu.GetProperty("logicalProcessors").GetInt32());
|
||||
}
|
||||
|
||||
private class LazyThrowingDiskProvider : IDeviceStatusProvider
|
||||
{
|
||||
public object GetOsInfo() => new { version = "10.0", architecture = "X64", machineName = "TEST", uptimeSeconds = 0L };
|
||||
public Task<object> GetCpuInfoAsync() => Task.FromResult<object>(new { name = "CPU", logicalProcessors = 1, usagePercent = (double?)null });
|
||||
public object GetMemoryInfo() => new { totalBytes = 0L, availableBytes = 0L, usagePercent = 0.0 };
|
||||
public object GetDiskInfo()
|
||||
{
|
||||
// Returns a lazy enumerable that throws during enumeration/serialization,
|
||||
// not at construction time — simulates a drive that becomes unavailable
|
||||
// after the provider method returns but before JSON serialization.
|
||||
IEnumerable<object> LazyDrives()
|
||||
{
|
||||
throw new IOException("Simulated lazy disk enumeration failure");
|
||||
}
|
||||
return new { drives = LazyDrives() };
|
||||
}
|
||||
public object GetBatteryInfo() => new { present = false, chargePercent = (int?)null, isCharging = false, estimatedMinutesRemaining = (int?)null };
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
public class ScreenCapabilityTests
|
||||
|
||||
Loading…
Reference in New Issue
Block a user