Batteries
Macro Deck keeps one catalog of battery-powered devices: phones, headphones, controllers, mice, keyboards and the like. A battery provider reports the devices it knows and their charge; Macro Deck shows them in the Battery widget, gives each one variables, and lets every plugin read the whole catalog.
You publish, Macro Deck never asks. The provider polls or listens however its devices need and pushes what it finds; there is no host-side polling to answer.
Quick start
Section titled “Quick start”An integration implements IBatteryProvider next to its other interfaces. The one below reports the
battery of a wireless headset:
using MacroDeck.Sdk;using MacroDeck.Sdk.Batteries;
public sealed class HeadsetIntegration(IHeadsetWatcher watcher) : IPluginIntegration, IBatteryProvider{ private IBatteryProviderContext? _batteries;
public string ProviderName => "Example Headsets";
public async Task InitializeAsync(IBatteryProviderContext context, CancellationToken cancellationToken = default) { _batteries = context; await context.PublishAsync([.. watcher.Headsets.Select(Describe)], cancellationToken); watcher.Changed += OnHeadsetChanged; }
public Task ShutdownAsync(CancellationToken cancellationToken = default) { watcher.Changed -= OnHeadsetChanged; _batteries = null; return Task.CompletedTask; }
private void OnHeadsetChanged(object? sender, Headset headset) => _ = _batteries?.PublishAsync(Describe(headset));
private static BatteryDevice Describe(Headset headset) => new() { Id = headset.PairingId, Name = headset.Name, Kind = BatteryDeviceKinds.Headphones, LevelPercent = headset.Level, ChargeState = headset.IsCharging ? BatteryChargeState.Charging : BatteryChargeState.Discharging, IsConnected = headset.IsInRange };
// IPluginIntegration members omitted.}The headset now appears in the Battery widget’s device list, and Macro Deck creates its
battery_<name>_<hash>_level, _charging, _connected, _state, _time_remaining_minutes,
_time_to_full_minutes and _health variables under your plugin (see Variables).
Things to know:
- Order is fixed. Macro Deck calls
IBatteryProvider.InitializeAsyncafter the integration’s ownInitializeAsync, andIBatteryProvider.ShutdownAsyncbefore the integration stops. - The context is yours until
ShutdownAsyncreturns. Keep it and publish whenever something changes. - Devices are always published under your plugin or integration. Nothing in a
BatteryDevicenames its owner, so you cannot publish or remove another provider’s devices. ProviderNameis optional. Left empty, Macro Deck shows your plugin’s or integration’s name.
Publishing devices
Section titled “Publishing devices”IBatteryProviderContext member |
What it does |
|---|---|
PublishAsync(device) |
Adds a device, or replaces the one published under the same Id. |
PublishAsync(devices) |
Adds or replaces several devices in one call. All or nothing: when one is refused, none is applied. An empty list does nothing. |
RemoveAsync(deviceId) |
Removes a device for good. Removing an unknown id does nothing. |
BatteryDevice is a record with init-only properties. Every property is checked when it is set, so an
instance that exists is always publishable:
| Property | Meaning |
|---|---|
Id |
Required. Your own stable id for the device - see Stable ids. |
Name |
Required. The name Macro Deck shows. |
Kind |
One of BatteryDeviceKinds, other by default - see Kinds. |
LevelPercent |
0 to 100, or null when the device does not report one. Outside the range throws ArgumentOutOfRangeException. |
ChargeState |
Unknown, Discharging, Charging, Full, or NotCharging (on power but not charging). |
IsConnected |
Whether the device is reachable right now. true by default. |
TimeRemaining, TimeToFull |
Optional estimates. Negative throws ArgumentOutOfRangeException. |
Health |
Unknown, Good, Overheat, Dead, OverVoltage, Cold or Failure. |
A list that contains null or the same id twice throws ArgumentException; a publish that would leave you
with more than 64 devices throws InvalidOperationException - see Limits. The exact bounds are
in
BatteryLimits.cs.
Publish a change when it happens and nothing in between: an unchanged device costs a round trip for nothing.
phone, tablet, laptop, headphones, watch, controller, mouse, keyboard, speaker, other,
as constants on BatteryDeviceKinds. The kind picks the icon a reader draws. A reader shows a kind it does
not know as other, and a charge state or health it does not know as unknown, so a newer provider keeps
working with an older Macro Deck.
Connected, withdrawn and removed
Section titled “Connected, withdrawn and removed”A device leaves the catalog in two different ways, and readers treat them differently:
| What happened | Who does it | What readers see |
|---|---|---|
| Out of reach: unplugged, out of range, switched off | You publish it with IsConnected = false |
The device stays, shown as not connected, with its name and last level. Its _state reads disconnected and both time variables read 0. |
| Withdrawn: your provider stopped or was initialized again, the user turned your plugin or integration off, your plugin’s session ended, Macro Deck shut down | Macro Deck | The device leaves the list but may come back under the same id. Its variables stay: _connected and _charging are false, _state reads disconnected, _time_remaining_minutes and _time_to_full_minutes read 0, and _level and _health keep the last reported values; a widget showing it shows its stored name as not available. |
| Removed for good: unpaired, forgotten, deleted | You call RemoveAsync |
The device leaves the list and its id is in RemovedIds. Its variables are deleted. |
So keep a device you merely lost contact with published as not connected, and remove it only once it is
gone for good. You never have to clean up on the way out: when your provider stops, Macro Deck withdraws
its devices itself, and the next InitializeAsync publishes them again.
Re-initializing starts from an empty set. When Macro Deck initializes your provider again while your
plugin stays connected, for example after the user changed its configuration, the devices you published
before are withdrawn first, and the new InitializeAsync publishes what the new configuration covers. A
device you no longer report is therefore not left behind, but it is only withdrawn: its variables stay, with
_connected false and _state reading disconnected. Call RemoveAsync before the provider stops for a device that is gone for good, such as
one the user removed from your configuration.
Variables
Section titled “Variables”You write no variable code for your devices. Macro Deck writes the seven variables of every device itself and files them under the plugin or integration that published it, so they are listed with your other variables and an Android phone’s are listed under the ADB integration. What each one reads is described in Battery variables.
- Do not create these names yourself. Macro Deck never takes over a variable you created: if yours
already has a device variable’s name, that one variable is not written for the device.
IVariableApi.GetAllAsynclists them among your variables; leave them alone rather than deleting them. - They never go stale. Macro Deck pushes them, so they stay available however long a level stays the same, unlike a variable you declare for polling.
- They follow your devices. A withdrawn device keeps its variables as not connected, a device you remove for good loses them, and they are deleted with the rest of your variables when the user uninstalls your plugin.
Stable ids
Section titled “Stable ids”Id is the device’s identity, and readers store it: a Battery widget configured for one device keeps
that device’s id, and the device’s variable names carry a hash of it. Publishing the same id again
updates the device; a new id is a new device.
- Derive it from something that stays the same across restarts and reconnects, such as a pairing id, never from a connection handle or a list position.
- Do not publish a hardware serial or anything else the user would not share. Every plugin can read the id. Hash it with a secret of your own if the stable source is sensitive, the way the ADB provider does.
- Readers see a qualified id. Macro Deck prefixes your id with the provider that published it,
<plugin or integration id>:<your id>, so two providers never collide.
Reading devices
Section titled “Reading devices”Every integration reads the whole catalog, whoever published it, through IIntegrationContext.Batteries:
IBatteryApi member |
What it does |
|---|---|
Devices |
Every device currently published, as BatteryDeviceSnapshot. Empty until Macro Deck has sent the first list. |
Find(id) |
The device with that qualified id, or null. |
Changed |
Raised after Devices changed. RemovedIds lists the qualified ids removed for good in that change. |
A BatteryDeviceSnapshot carries the published fields plus Id (qualified), LocalId, ProviderId,
ProviderName and UpdatedAt, when Macro Deck last received a report for it.
- A device absent from
Devicesand not inRemovedIdswas withdrawn and may come back under the same id. Keep what you stored about it. RemovedIdsis best effort. A plugin that was disconnected while a device was removed does not see the removal;Devicesis always complete.- Handlers run on a background thread. One that throws is logged and does not affect the others.
Handlers subscribed through
IIntegrationContext.Batteriesare released when Macro Deck shuts your integration down or initializes it again. In a plugin,IBatteryApican also be taken from dependency injection; handlers subscribed there are yours to remove.
A widget that shows the device with the lowest battery, live:
using MacroDeck.Sdk;using MacroDeck.Sdk.Batteries;using MacroDeck.Sdk.Ui;using MacroDeck.Ui.Components;using MacroDeck.Ui.Dsl;using MacroDeck.Ui.Model.Surfaces;using MacroDeck.Ui.Runtime;
public sealed class LowestBatteryIntegration : IPluginIntegration, IUiProvider{ private IBatteryApi _batteries = UnsupportedBatteryApi.Instance;
public Task InitializeAsync(IIntegrationContext context) { _batteries = context.Batteries; return Task.CompletedTask; }
public IReadOnlyList<UiSurfaceDeclaration> Surfaces { get; } = [new() { Kind = UiSurfaceKinds.Widget, SessionMode = UiSessionModes.Shared }];
public Task<IUiSession?> CreateSessionAsync(UiSessionRequest request, CancellationToken cancellationToken) { if (request.Surface.Kind != UiSurfaceKinds.Widget) { return Task.FromResult<IUiSession?>(null); }
var batteries = _batteries; var lowest = new UiState<BatteryDeviceSnapshot?>(Lowest(batteries.Devices)); void OnChanged(object? sender, BatteryDevicesChangedEventArgs e) => lowest.Value = Lowest(batteries.Devices); batteries.Changed += OnChanged;
var root = new UiStack { Key = "lowest", Children = [ new UiTextRun { Key = "name", Text = UiText.From(() => lowest.Value?.Name ?? string.Empty) }, new UiTextRun { Key = "level", Text = UiText.From(() => lowest.Value?.LevelPercent is { } level ? $"{level} %" : "--"), Size = 0.4 } ] };
return Task.FromResult<IUiSession?>( new ViewSession(new UiView(request.Surface, root), onDispose: () => batteries.Changed -= OnChanged)); }
private static BatteryDeviceSnapshot? Lowest(IReadOnlyList<BatteryDeviceSnapshot> devices) => devices.Where(device => device is { IsConnected: true, LevelPercent: not null }) .MinBy(device => device.LevelPercent);
// Other IPluginIntegration members omitted.}ViewSession is the adapter from Serving a view with one addition: it runs
onDispose in DisposeAsync, so a closed session stops listening. Writing a UiState from the
Changed handler is safe on any thread, and only the text that reads it is patched.
Limits
Section titled “Limits”- At most 64 devices per provider at a time (
BatteryLimits.MaxDevicesPerProvider). A publish that would exceed it is refused as a whole; publishing 64 devices in one list is one call. - Ids and names are at most 128 characters, a kind at most 32. Empty or whitespace is refused.
- A list is all or nothing, in the SDK and on the host: one invalid device refuses the whole call and nothing is applied.
- Calls count toward your plugin’s callback throttle. The SDK retries a call refused with
RATE_LIMITEDonce, after a second; a burst beyond that fails. Publish changes, not a timer’s every tick.
In a plugin
Section titled “In a plugin”MacroDeck.Plugin.Hosting declares the battery-provider capability for you when an integration
implements IBatteryProvider, starts the provider once the plugin is connected and its integration has
initialized, and stops it on shutdown. No permission is needed, to publish or to read.
- Reconnects are handled. The SDK remembers the set you published. When the connection drops and the session resumes, it resends the whole set in one call, so you publish nothing again. When a new session starts instead, Macro Deck initializes your provider again and you publish from scratch.
- Your devices go with your session. When your plugin stops or its session ends, Macro Deck withdraws the devices that session published. Uninstalling the plugin does the same.
- A configuration change withdraws too. Before the SDK initializes your provider again within the same session, it asks Macro Deck to withdraw what you published.
- While the user has turned your plugin off, what you publish is kept but not shown, and appears again when it is turned back on, without a new publish.
Built-in integrations
Section titled “Built-in integrations”A built-in integration implements the same IBatteryProvider and reads the same
IIntegrationContext.Batteries; nothing crosses a wire. Turning such an integration off stops its
provider and withdraws its devices; turning it on again initializes the provider, which publishes them
again.
The ADB provider
Section titled “The ADB provider”Macro Deck’s own ADB integration is the reference provider. It reports every Android phone and tablet
that has authorized the computer, while ADB is enabled and the ADB integration is turned on. It uses only
the public SDK: the devices, their connection events and their battery readings come from
IIntegrationContext.AndroidDevices, the same IAndroidDeviceManager a plugin uses (see
Android devices), so it would keep working if it were moved into a plugin.
See
AdbBatteryProvider.cs.
It shows the rules on this page in practice:
- A stable id that reveals nothing. The id is the device’s
StableId, a keyed hash of its hardware serial with a key that belongs to this install of Macro Deck, so the same phone keeps its id over USB and Wi-Fi and across restarts, while the serial itself is never published or stored. A widget imported from another install therefore shows the device’s stored name as not available. - Out of reach is not removed. A phone that disconnects stays published with
IsConnected = false; it is connected again as soon as one of its USB or wireless connections returns. The provider never callsRemoveAsyncon a disconnect. - Push on change, poll slowly. It publishes when a device connects or is authorized and refreshes the levels every 15 seconds, publishing only devices that changed.
Older versions of Macro Deck
Section titled “Older versions of Macro Deck”- A plugin on an older host. A Macro Deck that predates battery providers answers a publish with
CAPABILITY_UNSUPPORTED. The SDK logs this once and turns every later publish into a no-op, so your provider keeps running without errors.IIntegrationContext.Batteriesstays empty and never raisesChanged. Set a minimum host version incompatibility.macroDeckif batteries are the point of your plugin. - An older plugin on a newer host. A plugin built before battery providers declares no
battery-providercapability and ignores thebatteriesstate the host sends to every plugin. - An
IIntegrationContextof your own, such as a test double, keeps compiling:Batteriesis a default interface member that returnsUnsupportedBatteryApi.Instance.
Security
Section titled “Security”Every plugin can read every battery device, including the ones of other plugins and Android phones reported over ADB, whether or not plugins may use ADB. That is deliberate: a battery level, a name and a kind are what a deck shows anyway, and gating them would leave widgets of other plugins blank. So publish nothing in a name or id that you would not show every plugin: no serial numbers, no account names you would not put on a deck. See the security model.
Testing
Section titled “Testing”MacroDeck.Plugin.Testing has a fake for each side:
using MacroDeck.Plugin.Testing.Fakes;using MacroDeck.Sdk.Batteries;
var batteries = new FakeBatteryProviderContext();await new HeadsetIntegration(new FakeHeadsetWatcher("pad-1", level: 80)).InitializeAsync(batteries);
Assert.That(batteries.Devices["pad-1"].LevelPercent, Is.EqualTo(80));Assert.That(batteries.Publishes, Has.Count.EqualTo(1));FakeBatteryProviderContextkeeps the host’s rules: publishing a known id replaces the device, a list is all or nothing, and more than 64 devices is refused. Assert onDevices,PublishesandRemovedIds.FakeBatteryApiis the reader’s side.Set(devices, removedIds)replaces the catalog and raisesChanged;SubscriberCounttells you whether your code still listens.- In a
PluginTestHarness,harness.Context.Batteriesis theFakeBatteryApibehindIIntegrationContext.Batteries, andharness.Context.BatteryProvidertheFakeBatteryProviderContextyour provider publishes into.InitializeIntegrationsAsyncstarts battery providers against it.
See Testing.
Over the plugin protocol
Section titled “Over the plugin protocol”| Direction | What | Operations |
|---|---|---|
| Plugin to host | batteries host API |
publish (add or replace up to 64 devices), remove (remove ids for good), sync (replace the plugin’s whole set, used after a resumed reconnect), withdraw (withdraw the session’s devices, used before a provider is initialized again) |
| Host to plugin | battery-provider capability kind, version 1 |
describe (the provider name) |
| Host to every plugin | host.state for batteries |
The whole catalog with a revision and the ids removed since the previous one |
A call without the declared battery-provider capability is CAPABILITY_UNAVAILABLE; an invalid device or
a set above the limit is INVALID_PAYLOAD and nothing is applied. See
batteries in the WebSocket reference.
See also
Section titled “See also”- Android devices - Macro Deck’s ADB connection for your own operations.
- Variables - how integration variables work.
- Widget types - offering a widget of your own.
- Testing - the fakes and the test harness.