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
34 changes: 29 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,21 @@ src/DeviceBatteryInfo/
scanning. Hid/ has the shared HID transport/interop, HidProtocol
(what a brand implements) and HidFamily; Razer/RazerProtocol.cs lists the
supported mice
Variables/BatteryVariableCatalog.cs slot x field -> VariableDefinition, and the reverse resolve
Variables/BatteryVariableCatalog.cs slot -> its folder (`devices/<slot id>`), slot x field ->
VariableDefinition (in that folder), the search match, and the reverse
resolve
Localization/Strings.resx default-culture strings; Strings.<culture>.resx per language
Assets/icon.svg
Properties/launchSettings.json the single real-host debug profile
tests/DeviceBatteryInfo.Tests/
BatteryIntegrationTests.cs builds, initializes, the variables catalogue + a read work
BatteryIntegrationTests.cs builds, initializes, the variables catalogue (folders, search, folder
ids, pinned variable ids and names) + a read work
BatterySourceParsingTests.cs Razer report / PnP / Win32 power-status parsers, the Android picker
BatteryTrendTrackerTests.cs trend windows, segments per charging state, the trend text
BatteryWidgetViewTests.cs every widget tree built through a real UiView, the config form, glyph
paths, responsive variants, the previews
CatalogNotificationTests.cs the integration never takes a catalog notifier, and re-initializes cleanly
CatalogNotificationTests.cs the integration never takes a catalog notifier, re-initializes cleanly,
and reloads the variable catalog only when the devices change
DeviceConfigFlowTests.cs the config flow end to end, the way the host drives it
DeviceEntryReaderTests.cs config entries -> device slots, including the legacy keys
DeviceFamilyTests.cs model ids, the catalog, DeviceFamilyProvider
Expand Down Expand Up @@ -127,6 +131,25 @@ Design knowledge that is not obvious from the code alone:
empty and let the host reach each `battery_<id>_<field>` id through `DiscoverAsync`/`ResolveAsync`
instead. `SupportsPush` + `OnAttachedAsync` still push live updates to an attached session; `ReadAsync`
answers a direct `get`.
**The catalog is a tree: one folder per device.** The host draws folders only for a provider with
`SupportsSearch => true`; without it, it walks every container and lists the leaves flat. With it,
`DiscoverAsync` answers three shapes: no parent and no search gives the folders (`IsContainer`, not
bindable, id `devices/<slot id>`), a folder id as `ParentId` gives that device's variables (each with
that `ParentId`), and no parent with a search gives the matching variables of every device, never a
folder: every word typed (split on space, `_` and `.`, the host strips a leading `vars.`) must appear
in the variable's `Name` or the device's display name, ignoring case. A slug never has a `/`, so a
folder id can never be a variable id; `TryResolve` rejects it, so a read or subscribe of a folder is
unavailable, and `ResolveAsync` returns the folder. The host stores a link as the variable's `Id` plus a
copy of its name (`dynamic-variable-bindings.json`), so the ids and names stay exactly
`<slot>-<suffix>` and `battery_<slot>_<suffix>`; the folder is only a `ParentId`. The host keeps the tree
it browsed last, even across a plugin restart, until `IVariableSink.InvalidateCatalogAsync`: the
integration calls it in `OnAttachedAsync` and whenever the device ids or display names change
(`DeviceCatalog.Changed`, or a registry source outside the config appearing, going or renamed),
compared against the set it last announced, so readings never trigger one. It only signals the host's
browser to re-discover, which never changes the device set, so it cannot loop the way
`CatalogChanged` does. `CatalogEntryCount` counts the bindable variables, not the folders. A device
name is passed to the variable strings as a plain argument; a localized string as the argument would
render as `{placeholder}` in the host.
- **`DeviceCatalog` is the single source of device identity at runtime.** Providers, the variable
catalogue and the widgets all read `DeviceCatalog.Devices`. It starts empty and is populated only by
`BatteryIntegration.InitializeAsync` from the config-flow entries (`DeviceEntryReader`) - there is no
Expand Down Expand Up @@ -854,8 +877,9 @@ integration, which re-enters `InitializeAsync`, which notifies again - an 8-deep
every provided variable failing to register as `AlreadyExists` (net: zero variables, and the
localization + widget-type registration churned so strings render as `[[plugin:<id>:Key]]` and the
widgets vanish). `BatteryIntegration` therefore takes no `IPluginCatalogNotifier` at all
(`CatalogNotificationTests` asserts that): a device-set change only re-polls, and the on-demand variable
catalogue is resolved live. Integration issues need no notification either, because the host lists them
(`CatalogNotificationTests` asserts that): a device-set change only re-polls and reloads the variable
browser through `IVariableSink.InvalidateCatalogAsync` (a different, loop-free signal, see the catalog
note under Orientation), and the on-demand variable catalogue is resolved live. Integration issues need no notification either, because the host lists them
with a live call on its own schedule (confirmed: the Linux HID issue appears without one).

### Configuration and secrets
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ Widgets update live between polls. The battery trend is shown as a signed change
covers, for example `-13%/1h` while discharging or `+28%/30m` while charging. It needs a couple of
minutes of history and starts over whenever the plugin restarts.

**Variables** for every device you add: `battery_<device>_percent`, `_status`, `_charging`, `_online`,
`_time_to_full`, `_trend` and `_trend_rate`. Macro Deck's variable list shows them in one folder per
device, and its search box finds them across devices by name and device name (`phone percent`, say).

**Events** you can use in flows: `charging-started`, `charging-stopped` and `battery-low`.

**A "Refresh battery levels" action** that forces an immediate poll.
Expand Down
83 changes: 67 additions & 16 deletions src/DeviceBatteryInfo/BatteryIntegration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ ILogger logger

private IIntegrationContext? _context;
private IVariableSink? _sink;
private string? _catalogDevices;

public IReadOnlyList<IActionDefinition> Actions { get; } = [new RefreshBatteryAction(polling)];

Expand Down Expand Up @@ -94,15 +95,13 @@ public Task ShutdownAsync()
DetachBatteryCatalog();
_context = null;
_sink = null;
_catalogDevices = null;
return Task.CompletedTask;
}

// Empty on purpose: every variable is served through the catalog below (SupportsCatalog), not this list.
public IReadOnlyList<VariableDefinition> Variables => Array.Empty<VariableDefinition>();

private IReadOnlyList<VariableDefinition> CatalogDefinitions() =>
BatteryVariableCatalog.Build(CurrentSlots());

// Configured devices plus any the registry has seen, so a contributed provider needs no config flow.
private List<BatterySlot> CurrentSlots()
{
Expand Down Expand Up @@ -131,40 +130,82 @@ private void OnDeviceCatalogChanged(object? sender, EventArgs e)
// Just re-poll. Never call IPluginCatalogNotifier.CatalogChanged from here: it re-enters
// InitializeAsync, which would call it again: an infinite re-registration loop.
_polling.RequestRefresh();
InvalidateCatalogIfDevicesChanged();
}

// The host keeps the catalog it browsed last, even across a plugin restart, until told to reload it.
private void InvalidateCatalogIfDevicesChanged()
{
var sink = _sink;
if (sink is null)
{
return;
}

var devices = DeviceSignature();
if (Interlocked.Exchange(ref _catalogDevices, devices) != devices)
{
_ = InvalidateCatalogAsync(sink, CancellationToken.None);
}
}

private string DeviceSignature() =>
string.Join('\n', CurrentSlots().Select(slot => $"{slot.Id}\t{slot.DisplayName}"));

private async Task InvalidateCatalogAsync(IVariableSink sink, CancellationToken cancellationToken)
{
try
{
await sink.InvalidateCatalogAsync(cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.Debug(exception, "Invalidating the variable catalog failed.");
}
}

public bool SupportsCatalog => true;

public bool SupportsPush => true;

// Without search the host flattens the catalog and lists no folders.
public bool SupportsSearch => true;

public string CatalogName => "Battery";

public int? CatalogEntryCount => CurrentSlots().Count * BatteryVariableCatalog.FieldsPerDevice;

public Task OnAttachedAsync(IVariableSink sink, CancellationToken cancellationToken = default)
{
_sink = sink;
return Task.CompletedTask;
Volatile.Write(ref _catalogDevices, DeviceSignature());
return InvalidateCatalogAsync(sink, cancellationToken);
}

public ValueTask<VariableCatalogPage> DiscoverAsync(
VariableCatalogQuery query,
CancellationToken cancellationToken = default
)
{
IEnumerable<VariableDefinition> defs = CatalogDefinitions();
if (!string.IsNullOrWhiteSpace(query.Search))
var slots = CurrentSlots();
VariableDefinition[] ordered = query switch
{
defs = defs.Where(d =>
(
d.Name is { } name
&& name.Contains(query.Search, StringComparison.OrdinalIgnoreCase)
)
|| (d.Id is { } id && id.Contains(query.Search, StringComparison.OrdinalIgnoreCase))
);
}
{ ParentId: null, Search: { } search } when !string.IsNullOrWhiteSpace(search) =>
[
.. slots.SelectMany(slot =>
BatteryVariableCatalog
.ForDevice(slot)
.Where(variable => BatteryVariableCatalog.Matches(variable, slot, search))
),
],
{ ParentId: null } => [.. slots.Select(BatteryVariableCatalog.Folder)],
_ => slots.Find(slot =>
BatteryVariableCatalog.FolderId(slot.Id) == query.ParentId
) is { } parent
? [.. BatteryVariableCatalog.ForDevice(parent)]
: [],
};

var ordered = defs.ToArray();
var start = int.TryParse(query.ContinuationToken, out var parsed)
? Math.Clamp(parsed, 0, ordered.Length)
: 0;
Expand All @@ -189,7 +230,12 @@ d.Name is { } name
CancellationToken cancellationToken = default
) =>
ValueTask.FromResult(
CatalogDefinitions()
CurrentSlots()
.SelectMany(slot =>
BatteryVariableCatalog
.ForDevice(slot)
.Prepend(BatteryVariableCatalog.Folder(slot))
)
.FirstOrDefault(d => string.Equals(d.ResolvedId, localId, StringComparison.Ordinal))
);

Expand Down Expand Up @@ -300,6 +346,11 @@ private VariableReading ReadField(BatteryVariableCatalog.Field field, BatterySna

private void OnSnapshotChanged(object? sender, BatterySnapshotChangedEventArgs e)
{
if (e.Previous?.DisplayName != e.Current?.DisplayName)
{
InvalidateCatalogIfDevicesChanged();
}

if (e.Current is not { } current)
{
return;
Expand Down
46 changes: 34 additions & 12 deletions src/DeviceBatteryInfo/Variables/BatteryVariableCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,48 @@ private static readonly (Field Field, string Suffix)[] SuffixesByLength =
.. Fields.Select(f => (f.Field, f.Suffix)).OrderByDescending(f => f.Suffix.Length),
];

public static IReadOnlyList<VariableDefinition> Build(IReadOnlyList<BatterySlot> slots)
{
var definitions = new List<VariableDefinition>(slots.Count * Fields.Length);
foreach (var slot in slots)
// A slug never contains '/', so a folder id can never be a variable id.
private const string FolderIdPrefix = "devices/";

private static readonly char[] SearchSeparators = [' ', '_', '.'];

public static IReadOnlyList<VariableDefinition> Build(IReadOnlyList<BatterySlot> slots) =>
[.. slots.SelectMany(ForDevice)];

public static IReadOnlyList<VariableDefinition> ForDevice(BatterySlot slot) =>
[.. Fields.Select(f => Define(slot, f.Field, f.Suffix, f.Type))];

public static string FolderId(string slotId) => FolderIdPrefix + slotId;

public static VariableDefinition Folder(BatterySlot slot) =>
VariableDefinition.OnDemand(FolderId(slot.Id), VariableType.Text) with
{
foreach (var (field, suffix, type) in Fields)
{
definitions.Add(Define(slot, field, suffix, type));
}
}
DisplayName = slot.DisplayName,
IsContainer = true,
IsBindable = false,
};

return definitions;
// Every word typed must appear in the variable's name or its device's name ("phone percent").
public static bool Matches(VariableDefinition variable, BatterySlot slot, string search)
{
var text = $"{variable.Name} {slot.DisplayName}";
return search
.Split(SearchSeparators, StringSplitOptions.RemoveEmptyEntries)
.All(word => text.Contains(word, StringComparison.OrdinalIgnoreCase));
}

public static IEnumerable<(string LocalId, Field Field)> FieldsFor(string slotId) =>
Fields.Select(f => ($"{slotId}-{f.Suffix}", f.Field));

public static bool TryResolve(string localId, out string slotId, out Field field)
{
slotId = string.Empty;
field = default;
if (localId.StartsWith(FolderIdPrefix, StringComparison.Ordinal))
{
return false;
}

foreach (var (candidate, suffix) in SuffixesByLength)
{
if (
Expand All @@ -68,8 +91,6 @@ public static bool TryResolve(string localId, out string slotId, out Field field
}
}

slotId = string.Empty;
field = default;
return false;
}

Expand Down Expand Up @@ -120,6 +141,7 @@ VariableType type
Name = $"battery_{slot.Id.Replace('-', '_')}_{suffix.Replace('-', '_')}",
Type = type,
Materialization = VariableMaterialization.OnDemand,
ParentId = FolderId(slot.Id),
DisplayName = displayName,
Description = description,
DecimalPlaces = field switch
Expand Down
Loading
Loading