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
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ src/DeviceBatteryInfo/
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)
(one diffed publish per poll, RemoveAsync for a deleted entry),
ExternalBatteryDevices (other providers' devices for the widgets)
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 @@ -98,6 +99,8 @@ tests/DeviceBatteryInfo.Tests/
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
ExternalBatteryDevicesTests.cs other providers' devices in the picker and as widget rows, our own
filtered out, a live widget session, the reverse kind and state 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 @@ -288,6 +291,16 @@ Design knowledge that is not obvious from the code alone:
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.
- **The widgets can also show other providers' devices** from the same catalog
(`IIntegrationContext.Batteries`, read through `ExternalBatteryDevices`). They are widget-only: no
`battery_<id>_*` variables, no events, no trend. A widget stores one by its qualified catalog id
(`<provider>:<id>`), whose colon keeps it apart from every slot id, and an empty `sourceIds` still
means this plugin's devices only, so existing widgets do not grow new rows. Our own devices come back
through the catalog too and are filtered out by the local id we published. The row id is a hash of the
catalog id (`RowKey`), because a `UiRepeat` key takes no colon. The host drops a `Changed` handler
subscribed through the context on every re-initialization, and widget sessions outlive that, so the
integration re-subscribes in `InitializeAsync` and re-raises its own `BatteryCatalogChanged` for the
sessions. A withdrawn device (its plugin stopped) drops out of the widget until it is published again.
- **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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ plugin's variables, which stay unchanged. A device you are not reaching right no
last level and shows as disconnected; deleting the device removes it. Macro Deck lets every installed
plugin read these devices (name, kind and level).

**Devices from other plugins** work the other way round: the widgets' device list also offers every
device another plugin or Macro Deck itself reports to Macro Deck (for example the phones of the
built-in ADB integration), shown as "device - plugin". They appear in the widgets only, with their
level, charging state and connection; they get no variables or events from this plugin and no battery
trend. A widget with no devices picked still shows only this plugin's devices.

## Supported devices

### Generic sources
Expand Down Expand Up @@ -212,7 +218,8 @@ src/DeviceBatteryInfo/
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
HostBatteries/ configured devices -> Macro Deck's battery catalog, published after each poll,
and other providers' devices from that catalog for the widgets
ConfigFlow/ the device config flow (add/edit steps, discovery, the "Other devices" catalog)
Core/ IBatterySource, BatteryReading, BatteryRegistry, BatteryPollingService,
BatteryPluginOptions, DeviceCatalog, DeviceAccessProblems
Expand Down
26 changes: 26 additions & 0 deletions src/DeviceBatteryInfo/BatteryIntegration.Batteries.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
using DeviceBatteryInfo.HostBatteries;
using MacroDeck.Sdk.Batteries;

namespace DeviceBatteryInfo;

public sealed partial class BatteryIntegration : IBatteryProvider
{
private IBatteryApi _batteries = UnsupportedBatteryApi.Instance;

// Re-raised from the catalog's own event, which the host drops on every re-initialization while
// widget sessions stay open across it.
private event EventHandler? BatteryCatalogChanged;

// The plugin's own name, a proper noun like the manifest's.
public string ProviderName => "Device Battery Info";

Expand All @@ -25,4 +32,23 @@ Task IBatteryProvider.ShutdownAsync(CancellationToken cancellationToken)

private void OnPolled(object? sender, EventArgs e) =>
_ = _hostBatteries.SyncAsync(CancellationToken.None);

private void AttachBatteryCatalog(IBatteryApi batteries)
{
_batteries.Changed -= OnBatteryCatalogChanged;
_batteries = batteries;
_batteries.Changed += OnBatteryCatalogChanged;
}

private void DetachBatteryCatalog()
{
_batteries.Changed -= OnBatteryCatalogChanged;
_batteries = UnsupportedBatteryApi.Instance;
}

private void OnBatteryCatalogChanged(object? sender, BatteryDevicesChangedEventArgs e) =>
BatteryCatalogChanged?.Invoke(this, EventArgs.Empty);

private IReadOnlyList<BatteryDeviceSnapshot> ExternalDevices() =>
ExternalBatteryDevices.List(_batteries, _catalog.Devices);
}
120 changes: 81 additions & 39 deletions src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using System.Text.Json;
using DeviceBatteryInfo.Core;
using DeviceBatteryInfo.HostBatteries;
using DeviceBatteryInfo.Ui;
using MacroDeck.Sdk.Batteries;
using MacroDeck.Sdk.Ui;
using MacroDeck.Sdk.Widgets;
using MacroDeck.Ui.Config;
using MacroDeck.Ui.Config.Options;
using MacroDeck.Ui.Model.Surfaces;
using MacroDeck.Ui.Runtime;

Expand Down Expand Up @@ -120,6 +123,7 @@ private static string Preview(JsonElement value)
void OnCatalogChanged(object? sender, EventArgs e) => Refresh();
_registry.Changed += OnRegistryChanged;
_catalog.Changed += OnCatalogChanged;
BatteryCatalogChanged += OnCatalogChanged;

_polling.RequestRefresh();

Expand All @@ -129,6 +133,7 @@ private static string Preview(JsonElement value)
{
_registry.Changed -= OnRegistryChanged;
_catalog.Changed -= OnCatalogChanged;
BatteryCatalogChanged -= OnCatalogChanged;
}
);
}
Expand All @@ -153,60 +158,97 @@ private static string Preview(JsonElement value)
var options = ParseOptions(data);
var view = new UiView(
surface,
BatteryWidgetConfigView.Build(localId, options, CurrentSlots(), data ?? default)
BatteryWidgetConfigView.Build(localId, options, DeviceChoices(), data ?? default)
);
return new UiViewSession(view);
}

private BatteryWidgetModel BuildModel(BatteryWidgetOptions options)
internal BatteryWidgetModel BuildModel(BatteryWidgetOptions options)
{
var rows = ResolveDevices(options.SourceIds)
.Select(slot =>
{
if (_registry.TryGet(slot.Id, out var snapshot))
{
var reading = snapshot.Reading;
return new BatteryWidgetRow(
slot.Id,
slot.DisplayName,
reading.Percent,
reading.Status,
reading.IsCharging,
snapshot.IsStale,
FormatTimeToFull(reading.TimeToFull),
BatteryTrendFormatter.FormatText(_trend.GetTrend(snapshot)),
slot.Kind
);
}

return new BatteryWidgetRow(
slot.Id,
slot.DisplayName,
null,
BatteryStatus.Unknown,
false,
true,
null,
Kind: slot.Kind
);
})
.ToArray();
var own = CurrentSlots();
IReadOnlyList<BatteryWidgetRow> rows;
if (options.SourceIds.Count == 0)
{
rows = own.Select(OwnRow).ToArray();
}
else
{
var ownById = own.ToDictionary(slot => slot.Id, StringComparer.Ordinal);
var external = ExternalDevices().ToDictionary(d => d.Id, StringComparer.Ordinal);
rows = options
.SourceIds.Select(id =>
ownById.TryGetValue(id, out var slot) ? OwnRow(slot)
: external.TryGetValue(id, out var device) ? ExternalRow(device)
: null
)
.OfType<BatteryWidgetRow>()
.ToArray();
}

return new BatteryWidgetModel(options.Order(rows), options);
}

private IReadOnlyList<BatterySlot> ResolveDevices(IReadOnlyList<string> sourceIds)
private BatteryWidgetRow OwnRow(BatterySlot slot)
{
var all = CurrentSlots();
if (sourceIds.Count == 0)
if (_registry.TryGet(slot.Id, out var snapshot))
{
return all;
var reading = snapshot.Reading;
return new BatteryWidgetRow(
slot.Id,
slot.DisplayName,
reading.Percent,
reading.Status,
reading.IsCharging,
snapshot.IsStale,
FormatTimeToFull(reading.TimeToFull),
BatteryTrendFormatter.FormatText(_trend.GetTrend(snapshot)),
slot.Kind
);
}

var byId = all.ToDictionary(slot => slot.Id, StringComparer.Ordinal);
return sourceIds.Where(byId.ContainsKey).Select(id => byId[id]).ToArray();
return new BatteryWidgetRow(
slot.Id,
slot.DisplayName,
null,
BatteryStatus.Unknown,
false,
true,
null,
Kind: slot.Kind
);
}

private static BatteryWidgetRow ExternalRow(BatteryDeviceSnapshot device)
{
var status = ExternalBatteryDevices.StatusOf(device.ChargeState, device.LevelPercent);
return new BatteryWidgetRow(
ExternalBatteryDevices.RowKey(device.Id),
device.Name,
device.LevelPercent,
status,
status is BatteryStatus.Charging,
!device.IsConnected,
FormatTimeToFull(device.TimeToFull),
Kind: ExternalBatteryDevices.KindOf(device.Kind)
);
}

internal UiOption[] DeviceChoices() =>
[
.. CurrentSlots()
.Select(slot => UiOption.Of(slot.Id) with { Label = slot.DisplayName }),
.. ExternalDevices()
.Select(device =>
UiOption.Of(device.Id) with
{
Label = Strings.Widgets.Config.Devices.External(
device.Name,
device.ProviderName
),
}
),
];

private string? LocalWidgetId(string widgetTypeAttribute)
{
foreach (var (localId, qualifiedId) in _widgetTypeIds)
Expand Down
2 changes: 2 additions & 0 deletions src/DeviceBatteryInfo/BatteryIntegration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public IConfigFlow CreateConfigFlow() =>
public async Task InitializeAsync(IIntegrationContext context)
{
_context = context;
AttachBatteryCatalog(context.Batteries);

_registry.Changed -= OnSnapshotChanged;
_registry.Changed += OnSnapshotChanged;
Expand Down Expand Up @@ -90,6 +91,7 @@ public Task ShutdownAsync()
{
_registry.Changed -= OnSnapshotChanged;
_catalog.Changed -= OnDeviceCatalogChanged;
DetachBatteryCatalog();
_context = null;
_sink = null;
return Task.CompletedTask;
Expand Down
59 changes: 59 additions & 0 deletions src/DeviceBatteryInfo/HostBatteries/ExternalBatteryDevices.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Security.Cryptography;
using System.Text;
using DeviceBatteryInfo.Core;
using MacroDeck.Sdk.Batteries;

namespace DeviceBatteryInfo.HostBatteries;

// Devices other providers published to Macro Deck's battery catalog. A widget stores one by its
// qualified catalog id, which contains a colon and so never collides with a slot id.
internal static class ExternalBatteryDevices
{
public static IReadOnlyList<BatteryDeviceSnapshot> List(
IBatteryApi batteries,
IReadOnlyList<BatterySlot> ownSlots
)
{
// Our own devices come back through the catalog too; they are matched by the id we sent.
var own = ownSlots
.Where(s => s.EntryId is not null)
.Select(s => HostBatteryDevices.IdOf(s.EntryId!.Value))
.ToHashSet(StringComparer.Ordinal);

return batteries
.Devices.Where(d => !own.Contains(d.LocalId))
.OrderBy(d => d.ProviderName, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
}

// A catalog id may hold characters a UiElement key rejects (a colon at least), so a widget row
// is keyed by a hash of it instead.
public static string RowKey(string catalogId) =>
"ext-" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(catalogId)))[..16];

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

// NotCharging (a charge limit) reads like the macOS and Linux sources map it: Full at 100.
public static BatteryStatus StatusOf(BatteryChargeState state, int? level) =>
state switch
{
BatteryChargeState.Discharging => BatteryStatus.Discharging,
BatteryChargeState.Charging => BatteryStatus.Charging,
BatteryChargeState.Full => BatteryStatus.Full,
BatteryChargeState.NotCharging when level >= 100 => BatteryStatus.Full,
_ => BatteryStatus.Unknown,
};
}
4 changes: 4 additions & 0 deletions src/DeviceBatteryInfo/Localization/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@
<data name="Widgets.Config.Devices.Label" xml:space="preserve">
<value>Devices</value>
</data>
<data name="Widgets.Config.Devices.External" xml:space="preserve">
<value>{device} - {provider}</value>
<comment>A device another plugin reports to Macro Deck, followed by that plugin's name.</comment>
</data>
<data name="Widgets.Config.Title.Label" xml:space="preserve">
<value>Heading</value>
</data>
Expand Down
6 changes: 2 additions & 4 deletions src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal static class BatteryWidgetConfigView
public static UiElement Build(
string widgetLocalId,
BatteryWidgetOptions current,
IReadOnlyList<BatterySlot> devices,
IReadOnlyList<UiOption> devices,
JsonElement data = default
)
{
Expand Down Expand Up @@ -80,9 +80,7 @@ UiValue<UiVisibleWhen> OnlyFor(string layoutValue) =>
{
Key = "sourceIds",
Label = Strings.Widgets.Config.Devices.Label(),
Options = UiValue.Of<IReadOnlyList<UiOption>>(
devices.Select(d => UiOption.Of(d.Id) with { Label = d.DisplayName }).ToArray()
),
Options = UiValue.Of(devices),
Binding = Bind.To(sourceIds),
Reorderable = UiValue.Of(true),
}
Expand Down
2 changes: 1 addition & 1 deletion src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public static JsonElement StoredFlows(JsonElement? data) =>
"sourceIds": {
"type": "array",
"items": { "type": "string" },
"description": "Battery source ids to show, in display order. Empty means every device the plugin sees."
"description": "Devices to show, in display order: this plugin's device ids or Macro Deck battery catalog ids (provider:id) of other plugins' devices. Empty means every device this plugin reads."
},
"showBar": { "type": "boolean", "default": true },
"showPercent": { "type": "boolean", "default": true },
Expand Down
Loading
Loading