feat(device): add BatteryStatusRequested event to DeviceCapability

DeviceCapability.HandleStatus() always returned battery as null/unknown.
Issue #240 identified that real battery data requires WinRT APIs
(Windows.Devices.Power.Battery) that are unavailable in the cross-platform
Shared project (net10.0 TFM).

Follows the same event-delegation pattern used by CameraCapability and
ScreenCapability: the capability exposes a Func<Task<DeviceBatteryStatus?>>
event; the Tray.WinUI project subscribes a WinRT-backed provider.

Changes:
- DeviceCapability: add BatteryStatusRequested event + DeviceBatteryStatus class
- HandleStatus -> HandleStatusAsync: awaits provider if wired; falls back
  gracefully to unknown if provider throws or is null
- battery response now includes a 'present' field (false when no provider)
- NodeService.cs: TODO comment marking where WinRT provider should be wired
- CapabilityTests: 4 new tests covering charging, unplugged, not-present,
  and provider-throws-graceful-fallback scenarios

Test Status:
- Shared.Tests: 1045 passed (+4), 5 pre-existing McpHttpServer failures, 20 skipped
- Tray.Tests: 245 passed, 0 failed

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
github-actions[bot] 2026-04-30 13:12:38 +00:00 committed by GitHub
parent 29510a16eb
commit 240fcde019
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 156 additions and 11 deletions

View File

@ -25,18 +25,27 @@ public class DeviceCapability : NodeCapabilityBase
public override IReadOnlyList<string> Commands => _commands;
/// <summary>
/// Optional platform-specific battery provider.
/// When set, <c>device.status</c> calls this to populate the <c>battery</c> section.
/// When null (default), battery is reported as <c>state=unknown, level=null</c>.
/// Follows the same event-delegation pattern used by <see cref="CameraCapability"/>
/// and <see cref="ScreenCapability"/> to keep WinRT out of the Shared project.
/// </summary>
public event Func<Task<DeviceBatteryStatus?>>? BatteryStatusRequested;
public DeviceCapability(IOpenClawLogger logger) : base(logger)
{
}
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(),
_ => Error($"Unknown command: {request.Command}")
});
};
}
private NodeInvokeResponse HandleInfo()
@ -60,21 +69,51 @@ public class DeviceCapability : NodeCapabilityBase
});
}
private NodeInvokeResponse HandleStatus()
private async Task<NodeInvokeResponse> 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
};
}
}
/// <summary>
/// Battery status returned by the platform-specific <see cref="DeviceCapability.BatteryStatusRequested"/> provider.
/// </summary>
public sealed class DeviceBatteryStatus
{
/// <summary>Whether a battery is physically present.</summary>
public bool Present { get; init; }
/// <summary>Battery charge level 0100, or null if unavailable.</summary>
public int? ChargePercent { get; init; }
/// <summary>Whether the device is currently charging.</summary>
public bool IsCharging { get; init; }
/// <summary>Estimated minutes of battery life remaining, or null if unknown or charging.</summary>
public int? EstimatedMinutesRemaining { get; init; }
}

View File

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

View File

@ -1510,9 +1510,11 @@ public class DeviceCapabilityTests
Assert.True(res.Ok);
var payload = JsonSerializer.Deserialize<JsonElement>(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<DeviceBatteryStatus?>(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<JsonElement>(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<DeviceBatteryStatus?>(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<JsonElement>(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<DeviceBatteryStatus?>(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<JsonElement>(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<JsonElement>(JsonSerializer.Serialize(res.Payload));
Assert.Equal("unknown", payload.GetProperty("battery").GetProperty("state").GetString());
}
[Fact]
public async Task DeviceUnknownCommand_ReturnsError()
{