Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ jobs:
with:
version: ${{ needs.prepare.outputs.tag }}
source: src/DeviceBatteryInfo
cli-version: 3.0.0-beta.16
cli-version: 3.0.0-beta.17
changelog: ${{ needs.prepare.outputs.changelog }}
upload-artifact: true
artifact-name: plugin-package
Expand Down
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ src/DeviceBatteryInfo/
BatteryIntegration.Widgets.cs the same partial class: IWidgetTypeProvider + IUiProvider
BatteryIntegration.Issues.cs the same partial class: IIntegrationIssueProvider (the Linux HID
permission issue)
BatteryIntegration.Batteries.cs the same partial class: IBatteryProvider (SDK beta.17), feeding
Macro Deck's own battery catalog and built-in Battery widget
HostBatteries/ HostBatteryDevices (slot + snapshot -> BatteryDevice), HostBatteryPublisher
(one diffed publish per poll, RemoveAsync for a deleted entry)
ConfigFlow/ DeviceConfigFlow (host-rendered steps per device: basics picks a name and
a category, "Other devices" adds a brand/model step; a backend that still
needs an address or a device name (adb, Bluetooth) then gets a details
Expand Down Expand Up @@ -92,6 +96,8 @@ tests/DeviceBatteryInfo.Tests/
MacOsSourceTests.cs pmset and system_profiler parsers, the Bluetooth snapshot, the system
source
LinuxSourceTests.cs power_supply and BlueZ parsers, sysfs HID interface and unit keys
HostBatteryPublisherTests.cs the host battery catalog: one publish per change, renames, deletes,
out-of-reach devices, the kind mapping
HidTransportPlatformTests.cs macOS entry ids and descriptors (a captured G Pro X Wireless one), Windows
interface and unit keys, the bounded exchange on a fake channel,
vendor-collection matching
Expand Down Expand Up @@ -268,6 +274,20 @@ Design knowledge that is not obvious from the code alone:
released widgets already store `showTrend: true`, which would shrink every existing ring. Both the `trend`
and `trend-rate` variable suffixes are public API like every other field suffix in
`BatteryVariableCatalog`.
- **Every configured device is also published to Macro Deck's own battery catalog** (`IBatteryProvider`,
SDK beta.17), which drives the host's built-in Battery widget and its own `battery_<name>_<hash>_*`
variables. That naming lives in the host only, so this plugin's `battery_<id>_*` variables are kept
as they are and never renamed to match. A host device's id is the config entry's GUID
(`HostBatteryDevices.IdOf`), not the slot id: the slot id follows the name, so a rename would leave a
dead device behind. Publishing counts toward the shared callback throttle, so `HostBatteryPublisher`
runs once per poll (`BatteryPollingService.Polled`), sends the whole set in one `PublishAsync` only
when it differs from the last one sent, and on a failure keeps its last state so the next poll
retries. A deleted entry is `RemoveAsync`ed (the host deletes its variables); a device that is stale,
unreadable or no longer discovered is published with `IsConnected = false` and its last level, as
the host expects for "out of reach". The host withdraws every device when it re-initializes the
provider, so `AttachAsync` forgets what it sent. Our kinds map to the host's closest one (headset and
earbuds to headphones, the system battery to laptop, pen and VR headset to other); our own widget
keeps its icons.
- **Charging is inferred where a source cannot tell.** Bluetooth only ever reports a level, so
`BluetoothBatterySource` fills in `Discharging` with `BatteryReading.StatusIsAssumed`; an `Unknown`
status counts the same. `ChargingInference` (a DI singleton like `BatteryTrendTracker`, applied by
Expand Down
12 changes: 6 additions & 6 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="MacroDeck.Localization" Version="3.0.0-beta.16" />
<PackageVersion Include="MacroDeck.Plugin.Analyzers" Version="3.0.0-beta.16" />
<PackageVersion Include="MacroDeck.Plugin.Hosting" Version="3.0.0-beta.16" />
<PackageVersion Include="MacroDeck.Plugin.Serilog" Version="3.0.0-beta.16" />
<PackageVersion Include="MacroDeck.Plugin.Testing" Version="3.0.0-beta.16" />
<PackageVersion Include="MacroDeck.Sdk" Version="3.0.0-beta.16" />
<PackageVersion Include="MacroDeck.Localization" Version="3.0.0-beta.17" />
<PackageVersion Include="MacroDeck.Plugin.Analyzers" Version="3.0.0-beta.17" />
<PackageVersion Include="MacroDeck.Plugin.Hosting" Version="3.0.0-beta.17" />
<PackageVersion Include="MacroDeck.Plugin.Serilog" Version="3.0.0-beta.17" />
<PackageVersion Include="MacroDeck.Plugin.Testing" Version="3.0.0-beta.17" />
<PackageVersion Include="MacroDeck.Sdk" Version="3.0.0-beta.17" />
</ItemGroup>
<ItemGroup>
<!-- Cross-platform HID access for feature-report battery reads (e.g. Razer wireless mice). -->
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ minutes of history and starts over whenever the plugin restarts.

**A "Refresh battery levels" action** that forces an immediate poll.

**Macro Deck's own Battery widget** (Macro Deck 3.0.0-beta.17 and later) lists every device you add
here too, and Macro Deck creates its own battery variables for each of them. Those sit beside this
plugin's variables, which stay unchanged. A device you are not reaching right now stays listed with its
last level and shows as disconnected; deleting the device removes it. Macro Deck lets every installed
plugin read these devices (name, kind and level).

## Supported devices

### Generic sources
Expand Down Expand Up @@ -205,6 +211,8 @@ src/DeviceBatteryInfo/
BatteryIntegration.cs IPluginIntegration + IEventProvider + IConfigFlowProvider
BatteryIntegration.Widgets.cs the same partial class: IWidgetTypeProvider + IUiProvider
BatteryIntegration.Issues.cs the same partial class: IIntegrationIssueProvider (Linux HID access)
BatteryIntegration.Batteries.cs the same partial class: IBatteryProvider (Macro Deck's battery catalog)
HostBatteries/ configured devices -> Macro Deck's battery catalog, published after each poll
ConfigFlow/ the device config flow (add/edit steps, discovery, the "Other devices" catalog)
Core/ IBatterySource, BatteryReading, BatteryRegistry, BatteryPollingService,
BatteryPluginOptions, DeviceCatalog, DeviceAccessProblems
Expand Down
28 changes: 28 additions & 0 deletions src/DeviceBatteryInfo/BatteryIntegration.Batteries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using MacroDeck.Sdk.Batteries;

namespace DeviceBatteryInfo;

public sealed partial class BatteryIntegration : IBatteryProvider
{
// The plugin's own name, a proper noun like the manifest's.
public string ProviderName => "Device Battery Info";

async Task IBatteryProvider.InitializeAsync(
IBatteryProviderContext context,
CancellationToken cancellationToken
)
{
_polling.Polled -= OnPolled;
_polling.Polled += OnPolled;
await _hostBatteries.AttachAsync(context, cancellationToken);
}

Task IBatteryProvider.ShutdownAsync(CancellationToken cancellationToken)
{
_polling.Polled -= OnPolled;
return _hostBatteries.DetachAsync(cancellationToken);
}

private void OnPolled(object? sender, EventArgs e) =>
_ = _hostBatteries.SyncAsync(CancellationToken.None);
}
3 changes: 3 additions & 0 deletions src/DeviceBatteryInfo/BatteryIntegration.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using DeviceBatteryInfo.Actions;
using DeviceBatteryInfo.ConfigFlow;
using DeviceBatteryInfo.Core;
using DeviceBatteryInfo.HostBatteries;
using DeviceBatteryInfo.Variables;
using MacroDeck.Localization;
using MacroDeck.Plugin.Hosting.Integrations.HostApis;
Expand All @@ -21,6 +22,7 @@ public sealed partial class BatteryIntegration(
DeviceModelCatalog models,
IDeviceDiscovery discovery,
DeviceAccessProblems accessProblems,
HostBatteryPublisher hostBatteries,
ILogger logger
) : IPluginIntegration, IVariableProvider, IEventProvider, IConfigFlowProvider
{
Expand All @@ -37,6 +39,7 @@ ILogger logger
private readonly DeviceModelCatalog _models = models;
private readonly IDeviceDiscovery _discovery = discovery;
private readonly DeviceAccessProblems _accessProblems = accessProblems;
private readonly HostBatteryPublisher _hostBatteries = hostBatteries;
private readonly ILogger _logger = logger.ForContext<BatteryIntegration>();

private IIntegrationContext? _context;
Expand Down
74 changes: 37 additions & 37 deletions src/DeviceBatteryInfo/ConfigFlow/DeviceEntryReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ CancellationToken cancellationToken
async Task<string?> Read(string key)
{
await Task.Delay(BetweenReads, cancellationToken).ConfigureAwait(false);
return await HostCallRetry.RunAsync(
return await HostCallRetry
.RunAsync(
() => config.GetStringAsync(entry.Id, key, cancellationToken),
cancellationToken
)
Expand All @@ -45,43 +46,42 @@ CancellationToken cancellationToken
var type = model is null ? DeviceConfigKeys.ParseType(typeValue) : DeviceType.Catalog;
var id = BatterySlots.Reserve(BatterySlots.Slug(name), used);

slots.Add(
type switch
{
DeviceType.System => new BatterySlot(
id,
name,
BatterySourceKind.System,
DeviceType.System
),
DeviceType.Bluetooth => new BatterySlot(
id,
name,
DeviceConfigKeys.ParseKind(
await Read(DeviceConfigKeys.Kind)
?? await Read(DeviceConfigKeys.LegacyBluetoothKind)
),
DeviceType.Bluetooth,
BluetoothFriendlyName: await Read(DeviceConfigKeys.BluetoothName)
),
DeviceType.Catalog => new BatterySlot(
id,
name,
model!.Kind,
DeviceType.Catalog,
CatalogDeviceId: model.Id
),
_ => new BatterySlot(
id,
name,
await Read(DeviceConfigKeys.Kind) is { } kind
? DeviceConfigKeys.ParseKind(kind)
: BatterySourceKind.Phone,
DeviceType.AdbPhone,
AdbAddress: (await Read(DeviceConfigKeys.AdbAddress))?.Trim()
var slot = type switch
{
DeviceType.System => new BatterySlot(
id,
name,
BatterySourceKind.System,
DeviceType.System
),
DeviceType.Bluetooth => new BatterySlot(
id,
name,
DeviceConfigKeys.ParseKind(
await Read(DeviceConfigKeys.Kind)
?? await Read(DeviceConfigKeys.LegacyBluetoothKind)
),
}
);
DeviceType.Bluetooth,
BluetoothFriendlyName: await Read(DeviceConfigKeys.BluetoothName)
),
DeviceType.Catalog => new BatterySlot(
id,
name,
model!.Kind,
DeviceType.Catalog,
CatalogDeviceId: model.Id
),
_ => new BatterySlot(
id,
name,
await Read(DeviceConfigKeys.Kind) is { } kind
? DeviceConfigKeys.ParseKind(kind)
: BatterySourceKind.Phone,
DeviceType.AdbPhone,
AdbAddress: (await Read(DeviceConfigKeys.AdbAddress))?.Trim()
),
};
slots.Add(slot with { EntryId = entry.Id });
}

return slots;
Expand Down
5 changes: 5 additions & 0 deletions src/DeviceBatteryInfo/Core/BatteryPollingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ ILogger logger
private readonly SemaphoreSlim _wake = new(0, 1);
private readonly Dictionary<string, bool> _lastFailed = new(StringComparer.Ordinal);

// Raised once after every poll cycle, so a listener can batch everything the cycle changed.
public event EventHandler? Polled;

public void RequestRefresh()
{
try
Expand Down Expand Up @@ -49,6 +52,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
_logger.Error(exception, "Battery poll cycle failed.");
}

Polled?.Invoke(this, EventArgs.Empty);

var interval = TimeSpan.FromSeconds(
Math.Max(10, _options.CurrentValue.PollIntervalSeconds)
);
Expand Down
3 changes: 2 additions & 1 deletion src/DeviceBatteryInfo/Core/BatterySlots.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public sealed record BatterySlot(
DeviceType Type,
string? BluetoothFriendlyName = null,
string? AdbAddress = null,
string? CatalogDeviceId = null
string? CatalogDeviceId = null,
Guid? EntryId = null
);

public static class BatterySlots
Expand Down
103 changes: 103 additions & 0 deletions src/DeviceBatteryInfo/HostBatteries/HostBatteryDevices.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using DeviceBatteryInfo.Core;
using MacroDeck.Sdk.Batteries;

namespace DeviceBatteryInfo.HostBatteries;

internal static class HostBatteryDevices
{
// The config entry id, not the slot id: the slot id follows the device name, so a rename would
// leave the old device behind in the host's catalog.
public static string IdOf(Guid entryId) => entryId.ToString("N");

public static IReadOnlyList<BatteryDevice> Build(
IReadOnlyList<BatterySlot> slots,
BatteryRegistry registry,
IReadOnlyDictionary<string, BatteryDevice> previous
)
{
var devices = new List<BatteryDevice>(slots.Count);
foreach (var slot in slots)
{
if (slot.EntryId is not { } entryId)
{
continue;
}

var id = IdOf(entryId);
var snapshot = registry.TryGet(slot.Id, out var found) ? found : null;
devices.Add(Describe(id, slot, snapshot, previous.GetValueOrDefault(id)));
if (devices.Count == BatteryLimits.MaxDevicesPerProvider)
{
break;
}
}

return devices;
}

private static BatteryDevice Describe(
string id,
BatterySlot slot,
BatterySnapshot? snapshot,
BatteryDevice? previous
)
{
var name = Truncate(slot.DisplayName, BatteryLimits.MaxNameLength);
var kind = KindOf(slot.Kind);

if (snapshot is not { Reading.HasValue: true })
{
// Out of reach: the host keeps showing the last level we sent, so keep sending it.
return (previous ?? new BatteryDevice { Id = id, Name = name }) with
{
Name = name,
Kind = kind,
IsConnected = false,
TimeRemaining = null,
TimeToFull = null,
};
}

var reading = snapshot.Reading;
return new BatteryDevice
{
Id = id,
Name = name,
Kind = kind,
LevelPercent = reading.Percent,
ChargeState = ChargeStateOf(reading.Status),
IsConnected = !snapshot.IsStale,
TimeRemaining = Positive(reading.TimeToEmpty),
TimeToFull = Positive(reading.TimeToFull),
};
}

public static string KindOf(BatterySourceKind kind) =>
kind switch
{
BatterySourceKind.System => BatteryDeviceKinds.Laptop,
BatterySourceKind.Mouse => BatteryDeviceKinds.Mouse,
BatterySourceKind.Keyboard => BatteryDeviceKinds.Keyboard,
BatterySourceKind.Headset or BatterySourceKind.Earbuds => BatteryDeviceKinds.Headphones,
BatterySourceKind.Phone => BatteryDeviceKinds.Phone,
BatterySourceKind.Tablet => BatteryDeviceKinds.Tablet,
BatterySourceKind.Controller => BatteryDeviceKinds.Controller,
BatterySourceKind.Speaker => BatteryDeviceKinds.Speaker,
_ => BatteryDeviceKinds.Other,
};

private static BatteryChargeState ChargeStateOf(BatteryStatus status) =>
status switch
{
BatteryStatus.Discharging => BatteryChargeState.Discharging,
BatteryStatus.Charging => BatteryChargeState.Charging,
BatteryStatus.Full => BatteryChargeState.Full,
_ => BatteryChargeState.Unknown,
};

private static TimeSpan? Positive(TimeSpan? value) =>
value is { } span && span > TimeSpan.Zero ? span : null;

private static string Truncate(string value, int length) =>
value.Length <= length ? value : value[..length];
}
Loading
Loading