diff --git a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs index 4a01ec8..213a32a 100644 --- a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs @@ -25,18 +25,27 @@ public class DeviceCapability : NodeCapabilityBase public override IReadOnlyList Commands => _commands; + /// + /// Optional platform-specific battery provider. + /// When set, device.status calls this to populate the battery section. + /// When null (default), battery is reported as state=unknown, level=null. + /// Follows the same event-delegation pattern used by + /// and to keep WinRT out of the Shared project. + /// + public event Func>? BatteryStatusRequested; + public DeviceCapability(IOpenClawLogger logger) : base(logger) { } - 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(), _ => Error($"Unknown command: {request.Command}") - }); + }; } private NodeInvokeResponse HandleInfo() @@ -60,21 +69,51 @@ public class DeviceCapability : NodeCapabilityBase }); } - private NodeInvokeResponse HandleStatus() + private async Task HandleStatusAsync() { Logger.Info("device.status"); + DeviceBatteryStatus? batteryData = null; + if (BatteryStatusRequested != null) + { + try + { + batteryData = await BatteryStatusRequested(); + } + catch (Exception ex) + { + Logger.Warn($"device.status: battery provider threw: {ex.Message}"); + } + } + + object battery; + if (batteryData != null) + { + battery = new + { + level = batteryData.ChargePercent.HasValue ? batteryData.ChargePercent.Value / 100.0 : (double?)null, + state = batteryData.IsCharging ? "charging" : (batteryData.Present ? "unplugged" : "unknown"), + lowPowerModeEnabled = false, + present = batteryData.Present + }; + } + else + { + battery = new + { + level = (double?)null, + state = "unknown", + lowPowerModeEnabled = false, + present = false + }; + } + var storage = GetStorageStatus(Logger); var network = GetNetworkStatus(Logger); return Success(new { - battery = new - { - level = (double?)null, - state = "unknown", - lowPowerModeEnabled = false - }, + battery, thermal = new { state = "nominal" @@ -183,3 +222,22 @@ public class DeviceCapability : NodeCapabilityBase }; } } + +/// +/// Battery status returned by the platform-specific provider. +/// +public sealed class DeviceBatteryStatus +{ + /// Whether a battery is physically present. + public bool Present { get; init; } + + /// Battery charge level 0–100, or null if unavailable. + public int? ChargePercent { get; init; } + + /// Whether the device is currently charging. + public bool IsCharging { get; init; } + + /// Estimated minutes of battery life remaining, or null if unknown or charging. + public int? EstimatedMinutesRemaining { get; init; } +} + diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 2d9a6ac..5c7a993 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -284,6 +284,9 @@ public sealed class NodeService : IDisposable // Device metadata/status capability _deviceCapability = new DeviceCapability(_logger); + // TODO: wire _deviceCapability.BatteryStatusRequested to a WinRT BatteryStatusProvider + // once the provider is implemented (Windows.Devices.Power.Battery.AggregateBattery). + // The event API is ready; the WinRT implementation is tracked as future work. Register(_deviceCapability); // BrowserProxy needs a live gateway connection — only register when gateway is up. diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 57dacb4..5bb1920 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -1510,9 +1510,11 @@ public class DeviceCapabilityTests Assert.True(res.Ok); var payload = JsonSerializer.Deserialize(JsonSerializer.Serialize(res.Payload)); var battery = payload.GetProperty("battery"); + // No provider wired — battery falls back to "unknown" stub Assert.Equal("unknown", battery.GetProperty("state").GetString()); Assert.False(battery.GetProperty("lowPowerModeEnabled").GetBoolean()); Assert.Equal(JsonValueKind.Null, battery.GetProperty("level").ValueKind); + Assert.False(battery.GetProperty("present").GetBoolean()); Assert.Equal("nominal", payload.GetProperty("thermal").GetProperty("state").GetString()); @@ -1530,6 +1532,88 @@ public class DeviceCapabilityTests Assert.True(payload.GetProperty("uptimeSeconds").GetDouble() >= 0); } + [Fact] + public async Task DeviceStatus_BatteryProvider_Charging_ReturnsChargingState() + { + var cap = new DeviceCapability(NullLogger.Instance); + cap.BatteryStatusRequested += () => Task.FromResult(new DeviceBatteryStatus + { + Present = true, + ChargePercent = 72, + IsCharging = true, + EstimatedMinutesRemaining = null + }); + + var req = new NodeInvokeRequest { Id = "d4", Command = "device.status", Args = Parse("""{}""") }; + var res = await cap.ExecuteAsync(req); + + Assert.True(res.Ok); + var payload = JsonSerializer.Deserialize(JsonSerializer.Serialize(res.Payload)); + var battery = payload.GetProperty("battery"); + Assert.Equal("charging", battery.GetProperty("state").GetString()); + Assert.InRange(battery.GetProperty("level").GetDouble(), 0.71, 0.73); + Assert.True(battery.GetProperty("present").GetBoolean()); + } + + [Fact] + public async Task DeviceStatus_BatteryProvider_Unplugged_ReturnsUnpluggedState() + { + var cap = new DeviceCapability(NullLogger.Instance); + cap.BatteryStatusRequested += () => Task.FromResult(new DeviceBatteryStatus + { + Present = true, + ChargePercent = 45, + IsCharging = false, + EstimatedMinutesRemaining = 180 + }); + + var req = new NodeInvokeRequest { Id = "d5", Command = "device.status", Args = Parse("""{}""") }; + var res = await cap.ExecuteAsync(req); + + Assert.True(res.Ok); + var payload = JsonSerializer.Deserialize(JsonSerializer.Serialize(res.Payload)); + var battery = payload.GetProperty("battery"); + Assert.Equal("unplugged", battery.GetProperty("state").GetString()); + Assert.InRange(battery.GetProperty("level").GetDouble(), 0.44, 0.46); + Assert.True(battery.GetProperty("present").GetBoolean()); + } + + [Fact] + public async Task DeviceStatus_BatteryProvider_NotPresent_ReturnsUnknown() + { + var cap = new DeviceCapability(NullLogger.Instance); + cap.BatteryStatusRequested += () => Task.FromResult(new DeviceBatteryStatus + { + Present = false, + ChargePercent = null, + IsCharging = false + }); + + var req = new NodeInvokeRequest { Id = "d6", Command = "device.status", Args = Parse("""{}""") }; + var res = await cap.ExecuteAsync(req); + + Assert.True(res.Ok); + var payload = JsonSerializer.Deserialize(JsonSerializer.Serialize(res.Payload)); + var battery = payload.GetProperty("battery"); + Assert.Equal("unknown", battery.GetProperty("state").GetString()); + Assert.False(battery.GetProperty("present").GetBoolean()); + } + + [Fact] + public async Task DeviceStatus_BatteryProvider_Throws_FallsBackToUnknown() + { + var cap = new DeviceCapability(NullLogger.Instance); + cap.BatteryStatusRequested += () => throw new InvalidOperationException("WinRT not available"); + + var req = new NodeInvokeRequest { Id = "d7", Command = "device.status", Args = Parse("""{}""") }; + var res = await cap.ExecuteAsync(req); + + // Should succeed despite provider exception — graceful fallback + Assert.True(res.Ok); + var payload = JsonSerializer.Deserialize(JsonSerializer.Serialize(res.Payload)); + Assert.Equal("unknown", payload.GetProperty("battery").GetProperty("state").GetString()); + } + [Fact] public async Task DeviceUnknownCommand_ReturnsError() {