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:
parent
29510a16eb
commit
240fcde019
@ -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 0–100, 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; }
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user