From 24dfd6aebefe63ffef8995b8315cfe7e64af6d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Brid?= <36547063+RBrid@users.noreply.github.com> Date: Fri, 1 May 2026 11:54:51 -0700 Subject: [PATCH] feat: enhance device.status with system health sections (#249) 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> --- .../Capabilities/DeviceCapability.cs | 260 +++++++---- src/OpenClaw.Shared/IDeviceStatusProvider.cs | 27 ++ .../OpenClaw.Tray.WinUI.csproj | 1 + .../Services/DeviceStatusProvider.cs | 250 ++++++++++ .../Services/NodeService.cs | 10 +- .../OpenClaw.Shared.Tests/CapabilityTests.cs | 428 ++++++++++++++++-- 6 files changed, 857 insertions(+), 119 deletions(-) create mode 100644 src/OpenClaw.Shared/IDeviceStatusProvider.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/DeviceStatusProvider.cs diff --git a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs index 4a01ec8..e975f6c 100644 --- a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs @@ -11,7 +11,9 @@ using System.Threading.Tasks; namespace OpenClaw.Shared.Capabilities; /// -/// 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. /// public class DeviceCapability : NodeCapabilityBase { @@ -23,20 +25,28 @@ public class DeviceCapability : NodeCapabilityBase "device.status" ]; + private static readonly HashSet _validSections = new( + ["os", "cpu", "memory", "disk", "battery"], + StringComparer.OrdinalIgnoreCase); + + private readonly IDeviceStatusProvider? _provider; + public override IReadOnlyList Commands => _commands; - public DeviceCapability(IOpenClawLogger logger) : base(logger) + public DeviceCapability(IOpenClawLogger logger, IDeviceStatusProvider provider) + : base(logger) { + _provider = provider; } - public override Task ExecuteAsync(NodeInvokeRequest request) + public override async Task 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 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 + { + ["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); + } + + /// Per-section fault tolerance: one section failing doesn't kill the whole response. + private object? SafeCollect(string section, Func collector) + { + try { return collector(); } + catch (Exception ex) + { + Logger.Warn($"device.status: {section} collection failed: {ex.Message}"); + return new { error = ex.Message }; + } + } + + private async Task SafeCollectAsync(string section, Func> collector) + { + try { return await collector(); } + catch (Exception ex) + { + Logger.Warn($"device.status: {section} collection failed: {ex.Message}"); + return new { error = ex.Message }; + } + } + + /// + /// Wraps the provider's battery result with legacy fields (level, state, lowPowerModeEnabled) + /// so old consumers that read battery.level / battery.state continue to work. + /// + 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>(json) + ?? new Dictionary(); + + // 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 + { + // 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[] 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 } diff --git a/src/OpenClaw.Shared/IDeviceStatusProvider.cs b/src/OpenClaw.Shared/IDeviceStatusProvider.cs new file mode 100644 index 0000000..e4ab256 --- /dev/null +++ b/src/OpenClaw.Shared/IDeviceStatusProvider.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading.Tasks; + +namespace OpenClaw.Shared; + +/// +/// 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. +/// +public interface IDeviceStatusProvider : IDisposable +{ + /// OS version, architecture, machine name, uptime. + object GetOsInfo(); + + /// CPU name, logical processor count, usage percent (may be null during warm-up). + Task GetCpuInfoAsync(); + + /// Total/available memory in bytes and usage percent. + object GetMemoryInfo(); + + /// Fixed drive info: name, label, total/free bytes, usage percent, format. + object GetDiskInfo(); + + /// Battery presence, charge level, charging state, estimated time remaining. + object GetBatteryInfo(); +} diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 96dc8ff..c174bd7 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -56,6 +56,7 @@ + diff --git a/src/OpenClaw.Tray.WinUI/Services/DeviceStatusProvider.cs b/src/OpenClaw.Tray.WinUI/Services/DeviceStatusProvider.cs new file mode 100644 index 0000000..f494682 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DeviceStatusProvider.cs @@ -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; + +/// +/// 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. +/// +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; + } + + /// + /// Starts the background CPU usage sampler. Call once after construction. + /// The first valid reading appears after ~2 seconds. + /// + 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 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() }; + 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 +} diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index f6abf77..7c5478c 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -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; diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 161a3f7..68de009 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -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 GetCpuInfoAsync() => Task.FromResult(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(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(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(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 GetCpuInfoAsync() => Task.FromResult(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 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