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

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:
Régis Brid 2026-05-01 11:54:51 -07:00 committed by GitHub
parent e0c40985a7
commit 24dfd6aebe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 857 additions and 119 deletions

View File

@ -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
}

View 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();
}

View File

@ -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" />

View 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
}

View File

@ -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;

View File

@ -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