From 63eae782ca732be06fe9ad2c161878e915259b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:37:47 +0200 Subject: [PATCH 01/12] refactor: share the Bluetooth snapshot between the platform readers --- .../Sources/Bluetooth/BluetoothSnapshot.cs | 47 +++++++++++++++++++ .../Bluetooth/MacBluetoothBatteryReader.cs | 47 ++----------------- 2 files changed, 52 insertions(+), 42 deletions(-) create mode 100644 src/DeviceBatteryInfo/Sources/Bluetooth/BluetoothSnapshot.cs diff --git a/src/DeviceBatteryInfo/Sources/Bluetooth/BluetoothSnapshot.cs b/src/DeviceBatteryInfo/Sources/Bluetooth/BluetoothSnapshot.cs new file mode 100644 index 0000000..3dbacc6 --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/Bluetooth/BluetoothSnapshot.cs @@ -0,0 +1,47 @@ +namespace DeviceBatteryInfo.Sources.Bluetooth; + +// Several Bluetooth sources of one poll share a single fetch. A failed or cancelled fetch is never cached, +// so each waiter then fetches for itself. +internal sealed class BluetoothSnapshot( + Func>> fetch, + TimeProvider? timeProvider = null +) : IDisposable +{ + private static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(5); + + private readonly TimeProvider _time = timeProvider ?? TimeProvider.System; + private readonly SemaphoreSlim _gate = new(1, 1); + private IReadOnlyList<(string Name, string? RawBattery)>? _devices; + private long _timestamp; + + public async Task ReadRawAsync(string friendlyName, CancellationToken cancellationToken) + { + var devices = await GetAsync(cancellationToken); + return devices.FirstOrDefault(d => d.Name == friendlyName).RawBattery; + } + + private async Task> GetAsync( + CancellationToken cancellationToken + ) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (_devices is not null && _time.GetElapsedTime(_timestamp) < Lifetime) + { + return _devices; + } + + var fresh = await fetch(cancellationToken); + _devices = fresh; + _timestamp = _time.GetTimestamp(); + return fresh; + } + finally + { + _gate.Release(); + } + } + + public void Dispose() => _gate.Dispose(); +} diff --git a/src/DeviceBatteryInfo/Sources/Bluetooth/MacBluetoothBatteryReader.cs b/src/DeviceBatteryInfo/Sources/Bluetooth/MacBluetoothBatteryReader.cs index 9b11184..f43b0cf 100644 --- a/src/DeviceBatteryInfo/Sources/Bluetooth/MacBluetoothBatteryReader.cs +++ b/src/DeviceBatteryInfo/Sources/Bluetooth/MacBluetoothBatteryReader.cs @@ -4,16 +4,11 @@ namespace DeviceBatteryInfo.Sources.Bluetooth; internal sealed class MacBluetoothBatteryReader : IBluetoothBatteryReader, IDisposable { - private static readonly TimeSpan SnapshotLifetime = TimeSpan.FromSeconds(5); - private readonly ILogger _logger; private readonly Func> _runSystemProfiler; private readonly Func> _runPmsetAccessories; - private readonly TimeProvider _time; - private readonly SemaphoreSlim _gate = new(1, 1); + private readonly BluetoothSnapshot _snapshot; private int _accessoriesFailed; - private IReadOnlyList<(string Name, string? RawBattery)>? _snapshot; - private long _snapshotTimestamp; public MacBluetoothBatteryReader(ILogger logger) : this(logger, RunSystemProfilerAsync, RunPmsetAccessoriesAsync) { } @@ -28,48 +23,16 @@ internal MacBluetoothBatteryReader( _logger = logger; _runSystemProfiler = runSystemProfiler; _runPmsetAccessories = runPmsetAccessories; - _time = timeProvider ?? TimeProvider.System; + _snapshot = new BluetoothSnapshot(FetchAsync, timeProvider); } - public async Task ReadRawAsync( - string friendlyName, - CancellationToken cancellationToken - ) - { - var devices = await GetSnapshotAsync(cancellationToken); - return devices.FirstOrDefault(d => d.Name == friendlyName).RawBattery; - } + public Task ReadRawAsync(string friendlyName, CancellationToken cancellationToken) => + _snapshot.ReadRawAsync(friendlyName, cancellationToken); public Task> ListDevicesAsync( CancellationToken cancellationToken ) => FetchAsync(cancellationToken); - private async Task> GetSnapshotAsync( - CancellationToken cancellationToken - ) - { - await _gate.WaitAsync(cancellationToken); - try - { - if ( - _snapshot is not null - && _time.GetElapsedTime(_snapshotTimestamp) < SnapshotLifetime - ) - { - return _snapshot; - } - - var fresh = await FetchAsync(cancellationToken); - _snapshot = fresh; - _snapshotTimestamp = _time.GetTimestamp(); - return fresh; - } - finally - { - _gate.Release(); - } - } - private async Task> FetchAsync( CancellationToken cancellationToken ) @@ -109,7 +72,7 @@ CancellationToken cancellationToken } } - public void Dispose() => _gate.Dispose(); + public void Dispose() => _snapshot.Dispose(); private static Task RunSystemProfilerAsync(CancellationToken cancellationToken) => ExternalProcess.RunAsync( From a3916e699a3193f5fc24138af0e8a74f2a305b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:38:04 +0200 Subject: [PATCH 02/12] feat: read the system battery and Bluetooth devices on Linux --- .../Sources/BatterySourceRegistration.cs | 5 + .../Sources/Bluetooth/BlueZBatteryReader.cs | 53 +++++ .../Sources/Bluetooth/BlueZDeviceParser.cs | 62 +++++ .../SystemBattery/LinuxSystemPowerReader.cs | 31 +++ .../SystemBattery/PowerSupplyBatteryParser.cs | 120 ++++++++++ .../DeviceBatteryInfo.Tests/HardwareTests.cs | 12 +- .../LinuxSourceTests.cs | 225 ++++++++++++++++++ 7 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 src/DeviceBatteryInfo/Sources/Bluetooth/BlueZBatteryReader.cs create mode 100644 src/DeviceBatteryInfo/Sources/Bluetooth/BlueZDeviceParser.cs create mode 100644 src/DeviceBatteryInfo/Sources/SystemBattery/LinuxSystemPowerReader.cs create mode 100644 src/DeviceBatteryInfo/Sources/SystemBattery/PowerSupplyBatteryParser.cs create mode 100644 tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs diff --git a/src/DeviceBatteryInfo/Sources/BatterySourceRegistration.cs b/src/DeviceBatteryInfo/Sources/BatterySourceRegistration.cs index 90bcf78..9d58c44 100644 --- a/src/DeviceBatteryInfo/Sources/BatterySourceRegistration.cs +++ b/src/DeviceBatteryInfo/Sources/BatterySourceRegistration.cs @@ -20,6 +20,11 @@ public static IServiceCollection AddBatterySources(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); } + else if (OperatingSystem.IsLinux()) + { + services.AddSingleton(); + services.AddSingleton(); + } else { services.AddSingleton(); diff --git a/src/DeviceBatteryInfo/Sources/Bluetooth/BlueZBatteryReader.cs b/src/DeviceBatteryInfo/Sources/Bluetooth/BlueZBatteryReader.cs new file mode 100644 index 0000000..cf24d99 --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/Bluetooth/BlueZBatteryReader.cs @@ -0,0 +1,53 @@ +namespace DeviceBatteryInfo.Sources.Bluetooth; + +internal sealed class BlueZBatteryReader : IBluetoothBatteryReader, IDisposable +{ + private readonly Func> _runBusctl; + private readonly BluetoothSnapshot _snapshot; + + public BlueZBatteryReader() + : this(RunBusctlAsync) { } + + internal BlueZBatteryReader( + Func> runBusctl, + TimeProvider? timeProvider = null + ) + { + _runBusctl = runBusctl; + _snapshot = new BluetoothSnapshot(FetchAsync, timeProvider); + } + + public Task ReadRawAsync(string friendlyName, CancellationToken cancellationToken) => + _snapshot.ReadRawAsync(friendlyName, cancellationToken); + + public Task> ListDevicesAsync( + CancellationToken cancellationToken + ) => FetchAsync(cancellationToken); + + private async Task> FetchAsync( + CancellationToken cancellationToken + ) => + [ + .. BlueZDeviceParser + .ParseConnected(await _runBusctl(cancellationToken)) + .DistinctBy(d => d.Name, StringComparer.Ordinal), + ]; + + public void Dispose() => _snapshot.Dispose(); + + private static Task RunBusctlAsync(CancellationToken cancellationToken) => + ExternalProcess.RunAsync( + "/usr/bin/busctl", + "busctl", + [ + "--system", + "--json=short", + "call", + "org.bluez", + "/", + "org.freedesktop.DBus.ObjectManager", + "GetManagedObjects", + ], + cancellationToken + ); +} diff --git a/src/DeviceBatteryInfo/Sources/Bluetooth/BlueZDeviceParser.cs b/src/DeviceBatteryInfo/Sources/Bluetooth/BlueZDeviceParser.cs new file mode 100644 index 0000000..c2adb14 --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/Bluetooth/BlueZDeviceParser.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using System.Text.Json; + +namespace DeviceBatteryInfo.Sources.Bluetooth; + +// The busctl --json rendering of BlueZ's GetManagedObjects. A device gains org.bluez.Battery1 next to its +// org.bluez.Device1 once BlueZ or a battery provider (PipeWire for headsets) knows a level. +internal static class BlueZDeviceParser +{ + private const string DeviceInterface = "org.bluez.Device1"; + private const string BatteryInterface = "org.bluez.Battery1"; + + // Alias is the name the desktop shows and the user can rename; BlueZ fills it from Name otherwise. + public static IReadOnlyList<(string Name, string? RawBattery)> ParseConnected(string json) + { + using var document = JsonDocument.Parse(json); + var devices = new List<(string Name, string? RawBattery)>(); + + if ( + !document.RootElement.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Array + ) + { + return devices; + } + + foreach (var objects in data.EnumerateArray().Where(o => o.ValueKind == JsonValueKind.Object)) + { + foreach (var entry in objects.EnumerateObject()) + { + if ( + !entry.Value.TryGetProperty(DeviceInterface, out var device) + || Value(device, "Connected")?.ValueKind != JsonValueKind.True + || (String(device, "Alias") ?? String(device, "Name")) is not { Length: > 0 } name + ) + { + continue; + } + + devices.Add((name, Percentage(entry.Value))); + } + } + + return devices; + } + + private static string? Percentage(JsonElement deviceObject) => + deviceObject.TryGetProperty(BatteryInterface, out var battery) + && Value(battery, "Percentage") is { ValueKind: JsonValueKind.Number } percentage + && percentage.TryGetInt32(out var value) + ? value.ToString(CultureInfo.InvariantCulture) + : null; + + private static string? String(JsonElement properties, string name) => + Value(properties, name) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null; + + // busctl wraps every variant as { "type": ..., "data": ... }. + private static JsonElement? Value(JsonElement properties, string name) => + properties.TryGetProperty(name, out var variant) && variant.TryGetProperty("data", out var value) + ? value + : null; +} diff --git a/src/DeviceBatteryInfo/Sources/SystemBattery/LinuxSystemPowerReader.cs b/src/DeviceBatteryInfo/Sources/SystemBattery/LinuxSystemPowerReader.cs new file mode 100644 index 0000000..a2251ca --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/SystemBattery/LinuxSystemPowerReader.cs @@ -0,0 +1,31 @@ +using DeviceBatteryInfo.Core; + +namespace DeviceBatteryInfo.Sources.SystemBattery; + +internal sealed class LinuxSystemPowerReader : ISystemPowerReader +{ + private const string PowerSupplyDirectory = "/sys/class/power_supply"; + + public async ValueTask ReadAsync(CancellationToken cancellationToken) + { + if (!Directory.Exists(PowerSupplyDirectory)) + { + return null; + } + + var uevents = new List(); + foreach (var supply in Directory.EnumerateFileSystemEntries(PowerSupplyDirectory)) + { + try + { + uevents.Add(await File.ReadAllTextAsync(Path.Combine(supply, "uevent"), cancellationToken)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A supply can vanish between the listing and the read (a peripheral that disconnected). + } + } + + return PowerSupplyBatteryParser.Parse(uevents); + } +} diff --git a/src/DeviceBatteryInfo/Sources/SystemBattery/PowerSupplyBatteryParser.cs b/src/DeviceBatteryInfo/Sources/SystemBattery/PowerSupplyBatteryParser.cs new file mode 100644 index 0000000..b2497b2 --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/SystemBattery/PowerSupplyBatteryParser.cs @@ -0,0 +1,120 @@ +using System.Globalization; +using DeviceBatteryInfo.Core; + +namespace DeviceBatteryInfo.Sources.SystemBattery; + +internal static class PowerSupplyBatteryParser +{ + private sealed record Battery(int? Percent, BatteryStatus Status, long? Now, long? Full, long? Rate, bool InEnergy); + + // Each text is one /sys/class/power_supply//uevent. A laptop can have more than one battery. + public static BatteryReading? Parse(IEnumerable uevents) + { + var batteries = uevents.Select(ParseUevent).Select(ToBattery).OfType().ToArray(); + if (batteries.Length == 0) + { + return null; + } + + var status = CombinedStatus(batteries); + long? now = null; + long? full = null; + long? rate = null; + + // Energy (uWh, uW) and charge (uAh, uA) cannot be summed together, so mixed batteries skip the totals. + if (batteries.All(b => b.Now is not null && b.Full is > 0 && b.InEnergy == batteries[0].InEnergy)) + { + now = batteries.Sum(b => b.Now!.Value); + full = batteries.Sum(b => b.Full!.Value); + rate = batteries.All(b => b.Rate is not null) ? batteries.Sum(b => b.Rate!.Value) : null; + } + + int? percent = batteries.Length == 1 && batteries[0].Percent is { } single + ? single + : now is not null && full is not null + ? (int)Math.Round(100.0 * now.Value / full.Value) + : Average(batteries.Select(b => b.Percent)); + percent = percent is { } value ? Math.Clamp(value, 0, 100) : null; + + return new BatteryReading + { + Percent = percent, + Status = status == BatteryStatus.Unknown && percent >= 100 ? BatteryStatus.Full : status, + TimeToEmpty = status == BatteryStatus.Discharging ? Hours(now, rate) : null, + TimeToFull = status == BatteryStatus.Charging ? Hours(full - now, rate) : null, + }; + } + + internal static Dictionary ParseUevent(string text) + { + var values = new Dictionary(StringComparer.Ordinal); + foreach (var line in text.Split('\n')) + { + var separator = line.IndexOf('=', StringComparison.Ordinal); + if (separator > 0) + { + values[line[..separator].Trim()] = line[(separator + 1)..].Trim(); + } + } + + return values; + } + + // SCOPE=Device is a peripheral (a mouse, a controller) that the kernel drives, not this computer's battery. + private static Battery? ToBattery(Dictionary values) + { + if ( + values.GetValueOrDefault("POWER_SUPPLY_TYPE") != "Battery" + || values.GetValueOrDefault("POWER_SUPPLY_SCOPE") == "Device" + || values.GetValueOrDefault("POWER_SUPPLY_PRESENT") == "0" + ) + { + return null; + } + + var inEnergy = values.ContainsKey("POWER_SUPPLY_ENERGY_NOW"); + var prefix = inEnergy ? "POWER_SUPPLY_ENERGY_" : "POWER_SUPPLY_CHARGE_"; + var now = Number(values, prefix + "NOW"); + var full = Number(values, prefix + "FULL"); + + // Some drivers report the discharge current or power as a negative number. + var rate = Number(values, inEnergy ? "POWER_SUPPLY_POWER_NOW" : "POWER_SUPPLY_CURRENT_NOW") is { } raw + ? Math.Abs(raw) + : (long?)null; + + var percent = (int?)Number(values, "POWER_SUPPLY_CAPACITY") + ?? (now is not null && full is > 0 ? (int)Math.Round(100.0 * now.Value / full.Value) : null); + + var status = values.GetValueOrDefault("POWER_SUPPLY_STATUS") switch + { + "Charging" => BatteryStatus.Charging, + "Discharging" => BatteryStatus.Discharging, + "Full" => BatteryStatus.Full, + // A charge threshold or an idle dock holds the battery on AC without charging it. + _ => BatteryStatus.Unknown, + }; + + return new Battery(percent, status, now, full, rate, inEnergy); + } + + private static BatteryStatus CombinedStatus(Battery[] batteries) => + batteries.Any(b => b.Status == BatteryStatus.Charging) ? BatteryStatus.Charging + : batteries.Any(b => b.Status == BatteryStatus.Discharging) ? BatteryStatus.Discharging + : batteries.All(b => b.Status == BatteryStatus.Full) ? BatteryStatus.Full + : BatteryStatus.Unknown; + + private static TimeSpan? Hours(long? amount, long? rate) => + amount is > 0 && rate is > 0 ? TimeSpan.FromHours((double)amount.Value / rate.Value) : null; + + private static int? Average(IEnumerable percents) + { + var known = percents.OfType().ToArray(); + return known.Length == 0 ? null : (int)Math.Round(known.Average()); + } + + private static long? Number(Dictionary values, string key) => + values.TryGetValue(key, out var raw) + && long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : null; +} diff --git a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs index ed1132a..f062b3e 100644 --- a/tests/DeviceBatteryInfo.Tests/HardwareTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HardwareTests.cs @@ -33,18 +33,18 @@ public sealed class HardwareTests [SetUp] public void RequireSupportedPlatform() => - Assume.That(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()); + Assume.That(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()); private static IBluetoothBatteryReader PlatformBluetoothReader() => - OperatingSystem.IsMacOS() - ? new MacBluetoothBatteryReader(Serilog.Core.Logger.None) - : new PowerShellPnpBatteryReader(); + OperatingSystem.IsMacOS() ? new MacBluetoothBatteryReader(Serilog.Core.Logger.None) + : OperatingSystem.IsLinux() ? new BlueZBatteryReader() + : new PowerShellPnpBatteryReader(); [Test] public async Task Reads_the_system_battery() { - ISystemPowerReader reader = OperatingSystem.IsMacOS() - ? new MacSystemPowerReader() + ISystemPowerReader reader = OperatingSystem.IsMacOS() ? new MacSystemPowerReader() + : OperatingSystem.IsLinux() ? new LinuxSystemPowerReader() : new WindowsSystemPowerReader(); var reading = await reader.ReadAsync(CancellationToken.None); diff --git a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs new file mode 100644 index 0000000..d06fc4f --- /dev/null +++ b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs @@ -0,0 +1,225 @@ +using DeviceBatteryInfo.Core; +using DeviceBatteryInfo.Sources.Bluetooth; +using DeviceBatteryInfo.Sources.SystemBattery; +using NUnit.Framework; + +namespace DeviceBatteryInfo.Tests; + +[TestFixture] +public sealed class PowerSupplyBatteryParserTests +{ + // Captured from a laptop whose battery reports charge (uAh) and current (uA) instead of energy. + private const string ChargeBattery = """ + DEVTYPE=power_supply + POWER_SUPPLY_NAME=BAT1 + POWER_SUPPLY_TYPE=Battery + POWER_SUPPLY_STATUS=Discharging + POWER_SUPPLY_PRESENT=1 + POWER_SUPPLY_TECHNOLOGY=Li-ion + POWER_SUPPLY_CYCLE_COUNT=34 + POWER_SUPPLY_VOLTAGE_MIN_DESIGN=10800000 + POWER_SUPPLY_VOLTAGE_NOW=10826000 + POWER_SUPPLY_CURRENT_NOW=973000 + POWER_SUPPLY_CHARGE_FULL_DESIGN=4500000 + POWER_SUPPLY_CHARGE_FULL=2511000 + POWER_SUPPLY_CHARGE_NOW=1997000 + POWER_SUPPLY_CAPACITY=80 + POWER_SUPPLY_CAPACITY_LEVEL=Normal + POWER_SUPPLY_TYPE=Battery + POWER_SUPPLY_MODEL_NAME=CP700280-03 + POWER_SUPPLY_MANUFACTURER=PAC + POWER_SUPPLY_SERIAL_NUMBER=01B-Z171221004149Z + """; + + private const string Mains = """ + DEVTYPE=power_supply + POWER_SUPPLY_NAME=ACAD + POWER_SUPPLY_TYPE=Mains + POWER_SUPPLY_ONLINE=0 + POWER_SUPPLY_TYPE=Mains + """; + + private static string EnergyBattery(string status, int capacity, long now, long full, long power) => + $""" + POWER_SUPPLY_NAME=BAT0 + POWER_SUPPLY_TYPE=Battery + POWER_SUPPLY_STATUS={status} + POWER_SUPPLY_PRESENT=1 + POWER_SUPPLY_POWER_NOW={power} + POWER_SUPPLY_ENERGY_FULL={full} + POWER_SUPPLY_ENERGY_NOW={now} + POWER_SUPPLY_CAPACITY={capacity} + """; + + [Test] + public void Reads_a_discharging_charge_based_battery_with_time_to_empty() + { + var reading = PowerSupplyBatteryParser.Parse([Mains, ChargeBattery]); + + Assert.That(reading, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(reading.Percent, Is.EqualTo(80)); + Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Discharging)); + Assert.That(reading.TimeToEmpty, Is.EqualTo(TimeSpan.FromHours(1997000.0 / 973000))); + Assert.That(reading.TimeToFull, Is.Null); + } + } + + [Test] + public void Reads_a_charging_energy_based_battery_with_time_to_full() + { + var reading = PowerSupplyBatteryParser.Parse( + [EnergyBattery("Charging", 50, 25_000_000, 50_000_000, 12_500_000)] + ); + + Assert.That(reading, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(reading.Percent, Is.EqualTo(50)); + Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Charging)); + Assert.That(reading.TimeToFull, Is.EqualTo(TimeSpan.FromHours(2))); + Assert.That(reading.TimeToEmpty, Is.Null); + } + } + + [Test] + public void A_zero_rate_leaves_the_time_empty() + { + var reading = PowerSupplyBatteryParser.Parse([EnergyBattery("Discharging", 50, 25, 50, 0)]); + + Assert.That(reading?.TimeToEmpty, Is.Null); + } + + [Test] + public void A_negative_discharge_current_is_read_as_its_magnitude() + { + var reading = PowerSupplyBatteryParser.Parse( + [ChargeBattery.Replace("CURRENT_NOW=973000", "CURRENT_NOW=-973000", StringComparison.Ordinal)] + ); + + Assert.That(reading?.TimeToEmpty, Is.EqualTo(TimeSpan.FromHours(1997000.0 / 973000))); + } + + [TestCase("Full", 100, BatteryStatus.Full)] + [TestCase("Not charging", 100, BatteryStatus.Full)] + [TestCase("Not charging", 80, BatteryStatus.Unknown)] + [TestCase("Unknown", 64, BatteryStatus.Unknown)] + public void Maps_the_kernel_status(string status, int capacity, BatteryStatus expected) + { + var reading = PowerSupplyBatteryParser.Parse([EnergyBattery(status, capacity, capacity, 100, 0)]); + + Assert.That(reading?.Status, Is.EqualTo(expected)); + } + + [Test] + public void Two_batteries_combine_into_one_reading_weighted_by_capacity() + { + var reading = PowerSupplyBatteryParser.Parse( + [ + EnergyBattery("Discharging", 100, 20_000_000, 20_000_000, 5_000_000), + EnergyBattery("Unknown", 25, 15_000_000, 60_000_000, 5_000_000), + ] + ); + + Assert.That(reading, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(reading.Percent, Is.EqualTo(44)); + Assert.That(reading.Status, Is.EqualTo(BatteryStatus.Discharging)); + Assert.That(reading.TimeToEmpty, Is.EqualTo(TimeSpan.FromHours(3.5))); + } + } + + [Test] + public void Without_a_capacity_the_level_comes_from_the_charge() + { + var reading = PowerSupplyBatteryParser.Parse( + [ChargeBattery.Replace("POWER_SUPPLY_CAPACITY=80\n", "", StringComparison.Ordinal)] + ); + + Assert.That(reading?.Percent, Is.EqualTo(80)); + } + + [Test] + public void A_desktop_with_only_mains_has_no_battery() + { + Assert.That(PowerSupplyBatteryParser.Parse([Mains]), Is.Null); + } + + [Test] + public void An_absent_battery_or_a_peripheral_is_not_the_computer_battery() + { + var absent = ChargeBattery.Replace("PRESENT=1", "PRESENT=0", StringComparison.Ordinal); + var mouse = """ + POWER_SUPPLY_NAME=hidpp_battery_0 + POWER_SUPPLY_TYPE=Battery + POWER_SUPPLY_SCOPE=Device + POWER_SUPPLY_STATUS=Discharging + POWER_SUPPLY_PRESENT=1 + POWER_SUPPLY_CAPACITY=55 + """; + + Assert.That(PowerSupplyBatteryParser.Parse([absent, mouse]), Is.Null); + } +} + +[TestFixture] +public sealed class BlueZDeviceParserTests +{ + // The busctl --json=short shape of GetManagedObjects. A connected device with Battery1 is constructed from + // that shape until one is captured from real hardware. + private const string Objects = """ + {"type":"a{oa{sa{sv}}}","data":[{ + "/org/bluez":{"org.bluez.AgentManager1":{}}, + "/org/bluez/hci0":{"org.bluez.Adapter1":{"Name":{"type":"s","data":"laptop"},"Powered":{"type":"b","data":true}}}, + "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_01":{ + "org.bluez.Device1":{"Name":{"type":"s","data":"WH-1000XM4"},"Alias":{"type":"s","data":"My Headphones"},"Connected":{"type":"b","data":true}}, + "org.bluez.Battery1":{"Percentage":{"type":"y","data":70},"Source":{"type":"s","data":"HFP"}}}, + "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_02":{ + "org.bluez.Device1":{"Name":{"type":"s","data":"Speaker"},"Connected":{"type":"b","data":true}}}, + "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_03":{ + "org.bluez.Device1":{"Alias":{"type":"s","data":"Old Mouse"},"Connected":{"type":"b","data":false}}, + "org.bluez.Battery1":{"Percentage":{"type":"y","data":12}}} + }]} + """; + + [Test] + public void Lists_connected_devices_by_alias_with_their_battery() + { + var devices = BlueZDeviceParser.ParseConnected(Objects); + + Assert.That( + devices, + Is.EqualTo(new (string, string?)[] { ("My Headphones", "70"), ("Speaker", null) }) + ); + } + + [Test] + public void An_empty_object_tree_lists_nothing() + { + Assert.That(BlueZDeviceParser.ParseConnected("""{"type":"a{oa{sa{sv}}}","data":[{}]}"""), Is.Empty); + } + + [Test] + public async Task Concurrent_reads_share_one_busctl_call() + { + var calls = 0; + using var reader = new BlueZBatteryReader(async ct => + { + Interlocked.Increment(ref calls); + await Task.Delay(50, ct); + return Objects; + }); + + var results = await Task.WhenAll( + Enumerable.Range(0, 4).Select(_ => reader.ReadRawAsync("My Headphones", CancellationToken.None)) + ); + + using (Assert.EnterMultipleScope()) + { + Assert.That(calls, Is.EqualTo(1)); + Assert.That(results, Is.All.EqualTo("70")); + } + } +} From 5cfc6b62bebf800f12e147511f32803ddb923dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:38:20 +0200 Subject: [PATCH 03/12] feat: read HID devices on Linux through hidraw ioctls --- .../Sources/Hid/FeatureChannel.cs | 10 +++ .../Sources/Hid/HidFamily.cs | 6 ++ .../Sources/Hid/HidTransport.cs | 17 ++++- .../Sources/Hid/LinuxHidraw.cs | 54 ++++++++++++++ .../LinuxSourceTests.cs | 70 +++++++++++++++++++ 5 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs diff --git a/src/DeviceBatteryInfo/Sources/Hid/FeatureChannel.cs b/src/DeviceBatteryInfo/Sources/Hid/FeatureChannel.cs index 0e9f28f..3d108b2 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/FeatureChannel.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/FeatureChannel.cs @@ -22,6 +22,16 @@ internal sealed class NativeFeatureChannel(SafeFileHandle handle) : IFeatureChan public void Dispose() => handle.Dispose(); } +[SupportedOSPlatform("linux")] +internal sealed class LinuxFeatureChannel(SafeFileHandle handle) : IFeatureChannel +{ + public void Set(byte[] report) => LinuxHidraw.SetFeature(handle, report); + + public void Get(byte[] report) => LinuxHidraw.GetFeature(handle, report); + + public void Dispose() => handle.Dispose(); +} + internal sealed class HidSharpFeatureChannel : IFeatureChannel { private readonly HidStream _stream; diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs index 5681a06..4f72255 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs @@ -126,6 +126,12 @@ internal static string PhysicalUnitKey(HidCandidate candidate) return "m:" + candidate.Path[..interfaceNode].ToLowerInvariant(); } + var linux = HidSharpTransport.LinuxUsbInterfacePattern().Match(candidate.Path); + if (linux.Success) + { + return "l:" + linux.Groups["unit"].Value; + } + var parts = candidate.Path.Split('#'); if (parts.Length >= 4) { diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs index 63d8235..df93db1 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs @@ -93,9 +93,9 @@ bool boundBlockingOpen } private static IFeatureChannel OpenPlatformChannel(string devicePath) => - OperatingSystem.IsWindows() - ? new NativeFeatureChannel(NativeHid.Open(devicePath)) - : new HidSharpFeatureChannel(devicePath); + OperatingSystem.IsWindows() ? new NativeFeatureChannel(NativeHid.Open(devicePath)) + : OperatingSystem.IsLinux() ? new LinuxFeatureChannel(LinuxHidraw.Open(devicePath)) + : new HidSharpFeatureChannel(devicePath); [GeneratedRegex( @"mi_(?[0-9a-fA-F]{1,2})|IOUSBHostInterface@(?[0-9a-fA-F]+)", @@ -103,6 +103,11 @@ private static IFeatureChannel OpenPlatformChannel(string devicePath) => )] private static partial Regex InterfacePattern(); + // Linux paths are sysfs paths through the USB interface node, ":." in decimal, + // e.g. /sys/devices/.../usb1/1-5/1-5.3/1-5.3:1.1/0003:1532:00B7.0002/hidraw/hidraw1. + [GeneratedRegex(@"^(?/sys/devices/.*)/\d+-[\d.]+:\d+\.(?\d+)/")] + internal static partial Regex LinuxUsbInterfacePattern(); + public IReadOnlyList FindCandidates( int vendorId, int productId, @@ -392,6 +397,12 @@ int fallback internal static int? ParseInterfaceNumber(string devicePath) { + var linux = LinuxUsbInterfacePattern().Match(devicePath); + if (linux.Success) + { + return int.Parse(linux.Groups["n"].Value, System.Globalization.CultureInfo.InvariantCulture); + } + var match = InterfacePattern().Match(devicePath); return match.Success ? int.Parse(match.Groups["n"].Value, System.Globalization.NumberStyles.HexNumber, null) diff --git a/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs b/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs new file mode 100644 index 0000000..2fcf7eb --- /dev/null +++ b/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs @@ -0,0 +1,54 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32.SafeHandles; + +namespace DeviceBatteryInfo.Sources.Hid; + +// The hidraw feature-report ioctls directly. HidSharp's SetFeature/GetFeature on Linux return a zeroed +// buffer without an error (measured on a DeathAdder V3 Pro dongle), while the same ioctls answer correctly. +internal static class LinuxHidraw +{ + private const uint IocWrite = 1; + private const uint IocRead = 2; + private const uint HidrawType = 'H'; + private const uint SetFeatureNumber = 0x06; + private const uint GetFeatureNumber = 0x07; + + // Classic DllImport, matching NativeHid. ioctl is variadic, which x64 and arm64 Linux pass like fixed args. + [SupportedOSPlatform("linux")] + [DllImport("libc", EntryPoint = "ioctl", SetLastError = true)] + private static extern int Ioctl(SafeFileHandle fd, nuint request, byte[] buffer); + + // HidSharp's DevicePath on Linux is the sysfs path ending in the hidraw node's name. + internal static string DeviceNode(string devicePath) + { + var name = Path.GetFileName(devicePath); + return name.StartsWith("hidraw", StringComparison.Ordinal) + ? "/dev/" + name + : throw new InvalidOperationException($"{devicePath} is not a hidraw device."); + } + + [SupportedOSPlatform("linux")] + internal static SafeFileHandle Open(string devicePath) => + File.OpenHandle(DeviceNode(devicePath), FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + + [SupportedOSPlatform("linux")] + internal static void SetFeature(SafeFileHandle handle, byte[] report) => + Call(handle, SetFeatureNumber, report, "HIDIOCSFEATURE"); + + [SupportedOSPlatform("linux")] + internal static void GetFeature(SafeFileHandle handle, byte[] report) => + Call(handle, GetFeatureNumber, report, "HIDIOCGFEATURE"); + + internal static nuint Request(uint number, int length) => + ((IocRead | IocWrite) << 30) | ((uint)length << 16) | (HidrawType << 8) | number; + + [SupportedOSPlatform("linux")] + private static void Call(SafeFileHandle handle, uint number, byte[] report, string name) + { + if (Ioctl(handle, Request(number, report.Length), report) < 0) + { + throw new IOException($"{name} failed (errno {Marshal.GetLastPInvokeError()})."); + } + } +} diff --git a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs index d06fc4f..90bec68 100644 --- a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs @@ -1,5 +1,6 @@ using DeviceBatteryInfo.Core; using DeviceBatteryInfo.Sources.Bluetooth; +using DeviceBatteryInfo.Sources.Hid; using DeviceBatteryInfo.Sources.SystemBattery; using NUnit.Framework; @@ -223,3 +224,72 @@ public async Task Concurrent_reads_share_one_busctl_call() } } } + +[TestFixture] +public sealed class LinuxHidPathTests +{ + private const string Path = + "/sys/devices/pci0000:00/0000:00:14.0/usb1/1-5/1-5.3/1-5.3:1.{n}/0003:1532:00B7.0002/hidraw/hidraw1"; + + private static HidCandidate Candidate(string path, string? serial) => + new(path, 0x1532, 0x00B7, null, "Mouse", 91, serial); + + [Test] + public void Reads_the_interface_number_from_a_sysfs_path() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(HidSharpTransport.ParseInterfaceNumber(Path.Replace("{n}", "0")), Is.EqualTo(0)); + Assert.That(HidSharpTransport.ParseInterfaceNumber(Path.Replace("{n}", "2")), Is.EqualTo(2)); + Assert.That(HidSharpTransport.ParseInterfaceNumber(Path.Replace("{n}", "10")), Is.EqualTo(10)); + } + } + + [Test] + public void The_feature_ioctls_carry_the_report_length() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(LinuxHidraw.Request(0x06, 91), Is.EqualTo((nuint)0xC05B4806)); + Assert.That(LinuxHidraw.Request(0x07, 91), Is.EqualTo((nuint)0xC05B4807)); + } + } + + [Test] + public void The_hidraw_node_comes_from_the_end_of_the_sysfs_path() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(LinuxHidraw.DeviceNode(Path.Replace("{n}", "0")), Is.EqualTo("/dev/hidraw1")); + Assert.Throws(() => LinuxHidraw.DeviceNode("/sys/devices/x/input0")); + } + } + + [Test] + public void A_bluetooth_hid_device_has_no_interface_number() + { + Assert.That( + HidSharpTransport.ParseInterfaceNumber( + "/sys/devices/virtual/misc/uhid/0005:046D:B023.0007/hidraw/hidraw6" + ), + Is.Null + ); + } + + [Test] + public void Interfaces_of_one_unit_share_a_key_and_a_second_port_does_not() + { + var unitA0 = HidFamily.PhysicalUnitKey(Candidate(Path.Replace("{n}", "0"), "000000000000")); + var unitA2 = HidFamily.PhysicalUnitKey(Candidate(Path.Replace("{n}", "2"), "000000000000")); + var unitB0 = HidFamily.PhysicalUnitKey( + Candidate(Path.Replace("1-5.3", "1-5.4").Replace("{n}", "0"), "000000000000") + ); + + using (Assert.EnterMultipleScope()) + { + Assert.That(unitA0, Is.EqualTo(unitA2)); + Assert.That(unitA0, Is.EqualTo("l:/sys/devices/pci0000:00/0000:00:14.0/usb1/1-5/1-5.3")); + Assert.That(unitA0, Is.Not.EqualTo(unitB0)); + } + } +} From 61c2fa6e56c6831cebd7712e01b07cd79dade887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:38:37 +0200 Subject: [PATCH 04/12] feat: ship a linux-x64 build and run CI on Ubuntu --- .github/workflows/ci.yml | 6 +++--- src/DeviceBatteryInfo/macrodeck-build.json | 19 ++++++++++++++++++- src/DeviceBatteryInfo/manifest.json | 9 ++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef01381..a6f6056 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,12 @@ on: jobs: build-and-test: - # The Windows and macOS sources differ (Win32 power status and PowerShell PnP versus pmset and - # system_profiler), so both platforms build and run the tests. + # The Windows, macOS and Linux sources differ (Win32 power status and PowerShell PnP, pmset and + # system_profiler, sysfs and BlueZ), so every platform builds and runs the tests. strategy: fail-fast: false matrix: - os: [windows-latest, macos-latest] + os: [windows-latest, macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} steps: diff --git a/src/DeviceBatteryInfo/macrodeck-build.json b/src/DeviceBatteryInfo/macrodeck-build.json index f51536c..fb850d6 100644 --- a/src/DeviceBatteryInfo/macrodeck-build.json +++ b/src/DeviceBatteryInfo/macrodeck-build.json @@ -34,6 +34,23 @@ "bin/publish/osx-arm64" ], "output": "bin/publish/osx-arm64" + }, + "linux-x64": { + "executable": "dotnet", + "arguments": [ + "publish", + "DeviceBatteryInfo.csproj", + "-c", + "Release", + "-r", + "linux-x64", + "--self-contained", + "false", + "-p:UseAppHost=false", + "-o", + "bin/publish/linux-x64" + ], + "output": "bin/publish/linux-x64" } } -} +} \ No newline at end of file diff --git a/src/DeviceBatteryInfo/manifest.json b/src/DeviceBatteryInfo/manifest.json index 8a26e9e..9673ff8 100644 --- a/src/DeviceBatteryInfo/manifest.json +++ b/src/DeviceBatteryInfo/manifest.json @@ -3,7 +3,7 @@ "manifestVersion": 1, "id": "com.pyflat.device-battery-info", "name": "Device Battery Info", - "version": "1.4.1", + "version": "1.5.0", "description": "Battery levels for your PC, phone, mouse and Bluetooth devices, with a custom deck widget.", "icon": "Assets/icon.svg", "entrypoints": { @@ -20,6 +20,13 @@ "kind": "FrameworkDependent", "dotnetVersion": "10.0" } + }, + "linux-x64": { + "executable": "runtimes/linux-x64/DeviceBatteryInfo.dll", + "runtime": { + "kind": "FrameworkDependent", + "dotnetVersion": "10.0" + } } }, "publisher": { From dd4030074e122ceda268f7df8313777f93ce45ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:38:54 +0200 Subject: [PATCH 05/12] feat: add a udev rule that grants HID access on Linux --- packaging/linux/70-device-battery-info.rules | 20 ++++++++++ .../LinuxSourceTests.cs | 37 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 packaging/linux/70-device-battery-info.rules diff --git a/packaging/linux/70-device-battery-info.rules b/packaging/linux/70-device-battery-info.rules new file mode 100644 index 0000000..07144a5 --- /dev/null +++ b/packaging/linux/70-device-battery-info.rules @@ -0,0 +1,20 @@ +# Lets the user logged in at the seat open the hidraw nodes of the USB devices Device Battery Info reads. +# The file name must sort before 73-seat-late.rules, which is where the uaccess tag is applied. +# +# Install: +# sudo cp 70-device-battery-info.rules /etc/udev/rules.d/ +# sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=hidraw +# then replug the device (or its receiver). + +# Razer +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="1532", TAG+="uaccess" +# Logitech +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="046d", TAG+="uaccess" +# Corsair +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="1b1c", TAG+="uaccess" +# Rapoo +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="24ae", TAG+="uaccess" +# AULA +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="3554", TAG+="uaccess" +# Sony +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="054c", TAG+="uaccess" diff --git a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs index 90bec68..40bf466 100644 --- a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs @@ -293,3 +293,40 @@ public void Interfaces_of_one_unit_share_a_key_and_a_second_port_does_not() } } } + +[TestFixture] +public sealed class LinuxUdevRuleTests +{ + private static string RuleFile() + { + for (var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); directory is not null; directory = directory.Parent) + { + var rule = System.IO.Path.Combine(directory.FullName, "packaging", "linux", "70-device-battery-info.rules"); + if (File.Exists(rule)) + { + return File.ReadAllText(rule); + } + } + + throw new FileNotFoundException("packaging/linux/70-device-battery-info.rules not found above the test directory."); + } + + // Without its line a brand's devices read as not connected on Linux. + [Test] + public void Every_hid_vendor_has_a_udev_line() + { + var rule = RuleFile(); + var vendors = typeof(HidProtocol) + .Assembly.GetTypes() + .Where(t => t is { IsAbstract: false } && t.IsAssignableTo(typeof(HidProtocol))) + .Select(t => ((HidProtocol)Activator.CreateInstance(t, nonPublic: true)!).VendorId) + .Distinct() + .ToArray(); + + Assert.That(vendors, Is.Not.Empty); + Assert.That( + vendors.Where(v => !rule.Contains($"ATTRS{{idVendor}}==\"{v:x4}\"", StringComparison.Ordinal)), + Is.Empty + ); + } +} From 59267de3ec6f037f3f4dfcd3649b621a73fa8d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:39:12 +0200 Subject: [PATCH 06/12] feat: report HID devices Linux refuses to open as an integration issue --- docs/linux-setup.md | 61 +++++++++++++++++ .../BatteryIntegration.Issues.cs | 68 +++++++++++++++++++ src/DeviceBatteryInfo/BatteryIntegration.cs | 2 + .../Core/DeviceAccessProblems.cs | 16 +++++ .../Localization/Strings.resx | 18 +++++ .../Sources/BatterySourceRegistration.cs | 1 + .../Sources/Hid/HidFamily.cs | 34 +++++++++- .../Sources/Hid/HidTransport.cs | 6 +- .../BatteryIntegrationTests.cs | 68 +++++++++++++++++++ .../CatalogNotificationTests.cs | 1 + .../DeviceBatteryInfo.Tests/HidFamilyTests.cs | 62 ++++++++++++++++- 11 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 docs/linux-setup.md create mode 100644 src/DeviceBatteryInfo/BatteryIntegration.Issues.cs create mode 100644 src/DeviceBatteryInfo/Core/DeviceAccessProblems.cs diff --git a/docs/linux-setup.md b/docs/linux-setup.md new file mode 100644 index 0000000..0faef88 --- /dev/null +++ b/docs/linux-setup.md @@ -0,0 +1,61 @@ +# Setting up Device Battery Info on Linux + +Most of the plugin works on Linux without any setup: this computer's battery, Android phones through +Macro Deck's adb, and Bluetooth devices. **Only the USB devices from the "Other devices" catalog** (Razer, +Logitech, Corsair, Rapoo, AULA and Sony) need a one-time permission. If Macro Deck shows the problem +*"Linux needs a one-time permission to read a device"* for this plugin, this page is the fix. + +## Why it is needed + +The plugin asks these devices for their battery level directly over USB, through a file Linux creates for +each of them (`/dev/hidraw0`, `/dev/hidraw1`, ...). By default only root may open those files, so the +plugin can see that your mouse is plugged in but cannot ask it anything. + +The fix is a udev rule: a small text file that tells Linux to give **the user logged in at this computer** +access to devices from these brands only. Steam, OpenRGB and similar tools set up their devices the same +way. Nothing runs as root, and other users on the machine get no access. + +## Install the rule + +Open a terminal and run: + +```bash +sudo curl -fsSL -o /etc/udev/rules.d/70-device-battery-info.rules \ + https://raw.githubusercontent.com/PyFlat/Device-Battery-Info/main/packaging/linux/70-device-battery-info.rules +sudo udevadm control --reload-rules +sudo udevadm trigger --subsystem-match=hidraw +``` + +Then **unplug the device (or its USB receiver) and plug it back in**. The problem in Macro Deck disappears +on its own at the plugin's next battery read. No restart is needed. + +You can read the rule before installing it: +[packaging/linux/70-device-battery-info.rules](../packaging/linux/70-device-battery-info.rules). + +## Check that it worked + +```bash +ls -l /dev/hidraw* +``` + +The nodes of your device now end in a `+` (for example `crw-rw----+`), which means an extra permission is +attached. `getfacl /dev/hidraw0` should list your user name with `rw-`. + +## If it still does not work + +- **The rule file name must start with `70-`** (or any number below 73). Linux applies the permission in + `73-seat-late.rules`, and a rule that runs later has no effect. +- **Your distribution needs systemd** (true for Ubuntu, Fedora, Debian, Arch, openSUSE and most others). + The rule relies on systemd granting access to the logged-in user, and the Bluetooth support relies on + systemd's `busctl`. +- **A device connected over Bluetooth** instead of USB is not covered by this rule. Add it as a + **Bluetooth device** in the plugin instead. +- **Updating the plugin can add new brands.** If a newly supported device shows the problem again, run the + install commands once more to get the updated rule. + +## Removing it + +```bash +sudo rm /etc/udev/rules.d/70-device-battery-info.rules +sudo udevadm control --reload-rules +``` diff --git a/src/DeviceBatteryInfo/BatteryIntegration.Issues.cs b/src/DeviceBatteryInfo/BatteryIntegration.Issues.cs new file mode 100644 index 0000000..90c0dfd --- /dev/null +++ b/src/DeviceBatteryInfo/BatteryIntegration.Issues.cs @@ -0,0 +1,68 @@ +using System.ComponentModel; +using System.Diagnostics; +using MacroDeck.Sdk.Issues; + +namespace DeviceBatteryInfo; + +public sealed partial class BatteryIntegration : IIntegrationIssueProvider +{ + internal const string LinuxDeviceAccessIssueId = "linux-device-access"; + + internal const string LinuxSetupGuideUrl = + "https://github.com/PyFlat/Device-Battery-Info/blob/main/docs/linux-setup.md"; + + // Polled by the host to render the badge, so it only reads state the poll loop already recorded. + public Task> GetIssuesAsync(CancellationToken cancellationToken = default) + { + var blocked = OperatingSystem.IsLinux() + ? _catalog.Devices.Where(d => _accessProblems.IsBlocked(d.Id)).Select(d => d.DisplayName).ToArray() + : []; + + IReadOnlyList issues = + blocked.Length == 0 + ? [] + : + [ + new IntegrationIssue + { + Id = LinuxDeviceAccessIssueId, + Title = Strings.Issues.LinuxDeviceAccess.Title(), + Description = Strings.Issues.LinuxDeviceAccess.Description(string.Join(", ", blocked)), + Severity = IntegrationIssueSeverity.Error, + ActionLabel = Strings.Issues.LinuxDeviceAccess.Action(), + }, + ]; + return Task.FromResult(issues); + } + + // Tests replace it so resolving the issue does not open a real browser. + internal Action OpenInBrowser { get; set; } = OpenWithDesktop; + + // The SDK has no follow-up that opens a link, but the plugin runs in the user's desktop session, so it + // opens the guide itself (xdg-open on Linux). The issue clears on the next poll once the device opens. + public Task ResolveIssueAsync(string issueId, CancellationToken cancellationToken = default) + { + if (issueId != LinuxDeviceAccessIssueId) + { + return Task.FromResult(IssueResolution.Failed(Strings.Issues.Unknown())); + } + + try + { + OpenInBrowser(LinuxSetupGuideUrl); + return Task.FromResult(IssueResolution.Ok(Strings.Issues.LinuxDeviceAccess.Opened())); + } + catch (Exception exception) when (exception is Win32Exception or InvalidOperationException) + { + _logger.Warning(exception, "Could not open the Linux setup guide {Url}.", LinuxSetupGuideUrl); + return Task.FromResult( + IssueResolution.Failed(Strings.Issues.LinuxDeviceAccess.OpenFailed(LinuxSetupGuideUrl)) + ); + } + } + + private static void OpenWithDesktop(string url) + { + using var browser = Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } +} diff --git a/src/DeviceBatteryInfo/BatteryIntegration.cs b/src/DeviceBatteryInfo/BatteryIntegration.cs index 706d70e..5284f0e 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.cs @@ -20,6 +20,7 @@ public sealed partial class BatteryIntegration( DeviceCatalog catalog, DeviceModelCatalog models, IDeviceDiscovery discovery, + DeviceAccessProblems accessProblems, ILogger logger ) : IPluginIntegration, IVariableProvider, IEventProvider, IConfigFlowProvider { @@ -35,6 +36,7 @@ ILogger logger private readonly DeviceCatalog _catalog = catalog; private readonly DeviceModelCatalog _models = models; private readonly IDeviceDiscovery _discovery = discovery; + private readonly DeviceAccessProblems _accessProblems = accessProblems; private readonly ILogger _logger = logger.ForContext(); private IIntegrationContext? _context; diff --git a/src/DeviceBatteryInfo/Core/DeviceAccessProblems.cs b/src/DeviceBatteryInfo/Core/DeviceAccessProblems.cs new file mode 100644 index 0000000..2791bea --- /dev/null +++ b/src/DeviceBatteryInfo/Core/DeviceAccessProblems.cs @@ -0,0 +1,16 @@ +using System.Collections.Concurrent; + +namespace DeviceBatteryInfo.Core; + +// Device entries whose hardware is connected but that the operating system refuses to open, keyed by device id. +public sealed class DeviceAccessProblems +{ + private readonly ConcurrentDictionary _blocked = new(StringComparer.Ordinal); + + // True only when the entry was not blocked before, so a caller logs once per episode. + internal bool MarkBlocked(string deviceId) => _blocked.TryAdd(deviceId, 0); + + internal void MarkReachable(string deviceId) => _blocked.TryRemove(deviceId, out _); + + internal bool IsBlocked(string deviceId) => _blocked.ContainsKey(deviceId); +} diff --git a/src/DeviceBatteryInfo/Localization/Strings.resx b/src/DeviceBatteryInfo/Localization/Strings.resx index 37eb075..47933a5 100644 --- a/src/DeviceBatteryInfo/Localization/Strings.resx +++ b/src/DeviceBatteryInfo/Localization/Strings.resx @@ -321,4 +321,22 @@ Kind + + Linux needs a one-time permission to read a device + + + Connected, but Linux lets only root open it, so the battery cannot be read: {devices}. A one-time udev rule from the Linux setup guide fixes this; then unplug and replug the device. + + + Open setup guide + + + The Linux setup guide opened in your browser. This problem disappears once the device can be read. + + + Your browser could not be opened. The Linux setup guide is at {url} + + + This problem is no longer known to the plugin. + diff --git a/src/DeviceBatteryInfo/Sources/BatterySourceRegistration.cs b/src/DeviceBatteryInfo/Sources/BatterySourceRegistration.cs index 9d58c44..1f5b4b8 100644 --- a/src/DeviceBatteryInfo/Sources/BatterySourceRegistration.cs +++ b/src/DeviceBatteryInfo/Sources/BatterySourceRegistration.cs @@ -13,6 +13,7 @@ internal static class BatterySourceRegistration public static IServiceCollection AddBatterySources(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); if (OperatingSystem.IsMacOS()) diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs index 4f72255..297190b 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidFamily.cs @@ -7,13 +7,15 @@ namespace DeviceBatteryInfo.Sources.Hid; internal sealed class HidFamily( IEnumerable protocols, IHidTransport transport, - ILogger logger + ILogger logger, + DeviceAccessProblems? accessProblems = null ) : IDeviceFamily { private sealed record HidModel(HidProtocol Protocol, HidDeviceInfo Device) : DeviceModel(Protocol.Brand, Device.Name, Device.Kind); private readonly ILogger _logger = logger.ForContext(); + private readonly DeviceAccessProblems _accessProblems = accessProblems ?? new(); // A dongle keeps its path while plugged into the same port, so remember which interface answered. private readonly ConcurrentDictionary _resolvedPaths = new( @@ -33,7 +35,7 @@ CancellationToken cancellationToken { var model = (HidModel)byModel.First().Model; var slots = byModel.Select(e => e.Slot).OrderBy(s => s.Id, StringComparer.Ordinal).ToArray(); - IReadOnlyList candidates = + IReadOnlyList found = [ .. model.Device.ProductIds.SelectMany(productId => transport.FindCandidates( @@ -44,8 +46,13 @@ .. model.Device.ProductIds.SelectMany(productId => ? model.Protocol.ReportLength : 0 ) - ).Where(c => Matches(model.Protocol, c)), + ), + ]; + IReadOnlyList candidates = + [ + .. found.Where(c => !c.CouldNotOpen && Matches(model.Protocol, c)), ]; + TrackAccess(model, slots, blocked: candidates.Count == 0 && found.Any(c => c.CouldNotOpen)); // One entry may use any interface. Two entries for the same model must not both claim // whichever unit answers first, so each gets its own. @@ -104,6 +111,27 @@ .. model.Device.ProductIds.SelectMany(productId => return sources; } + private void TrackAccess(HidModel model, BatterySlot[] slots, bool blocked) + { + foreach (var slot in slots) + { + if (!blocked) + { + _accessProblems.MarkReachable(slot.Id); + } + else if (_accessProblems.MarkBlocked(slot.Id)) + { + _logger.Warning( + "{Brand} {Product} is connected but could not be opened, so {DeviceId} cannot be read. On Linux " + + "its hidraw nodes need the udev rule from docs/linux-setup.md.", + model.Brand, + model.Name, + slot.Id + ); + } + } + } + private static bool Matches(HidProtocol protocol, HidCandidate candidate) => protocol.ReportKind == HidReportKind.Feature || ( diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs index df93db1..10782fd 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs @@ -24,6 +24,10 @@ internal sealed record HidCandidate( // presents one device per collection, so the first usage alone is not enough to find a vendor collection. public bool HasUsage(int? page, int? usage) => Usages is { } all ? all.Contains((page ?? -1, usage ?? -1)) : UsagePage == page && Usage == usage; + + // Every HID interface has at least one report, so no length at all means it could not be opened: on Linux + // a hidraw node without the udev rule, which would otherwise look exactly like an absent device. + public bool CouldNotOpen => FeatureReportLength == 0 && InputReportLength == 0 && OutputReportLength == 0; } // Plumbing only: a protocol owns its report layout and passes finished request bytes in. @@ -117,7 +121,7 @@ int minFeatureReportLength DeviceList .Local.GetHidDevices(vendorId, productId) .Select(d => Describe(d, withUsage: true)) - .Where(c => c.FeatureReportLength >= minFeatureReportLength) + .Where(c => c.CouldNotOpen || c.FeatureReportLength >= minFeatureReportLength) .Where(c => interfaceNumber is null || c.InterfaceNumber == interfaceNumber) // Which HID collection answers varies by model, so try them in ascending order and let the caller's // probe decide. diff --git a/tests/DeviceBatteryInfo.Tests/BatteryIntegrationTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryIntegrationTests.cs index 334b96a..f2380a3 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryIntegrationTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryIntegrationTests.cs @@ -173,6 +173,74 @@ private sealed class SeedSource(string id, string name) : IBatterySource public ValueTask ReadAsync(CancellationToken cancellationToken) => ValueTask.FromResult(BatteryReading.Unavailable); } + + private static async Task> IssuesAsync( + PluginTestHarness harness + ) => await harness.Services.GetRequiredService().GetIssuesAsync(); + + [Test] + public async Task A_blocked_device_raises_the_linux_setup_issue_only_on_linux() + { + await using var harness = CreateHarness(); + SeedDevices(harness); + harness.Services.GetRequiredService().MarkBlocked("headset"); + + var issues = await IssuesAsync(harness); + + if (!OperatingSystem.IsLinux()) + { + Assert.That(issues, Is.Empty); + return; + } + + Assert.That(issues, Has.Count.EqualTo(1)); + using (Assert.EnterMultipleScope()) + { + Assert.That(issues[0].Id, Is.EqualTo(BatteryIntegration.LinuxDeviceAccessIssueId)); + Assert.That(issues[0].Severity, Is.EqualTo(MacroDeck.Sdk.Issues.IntegrationIssueSeverity.Error)); + } + } + + [Test] + public async Task A_removed_entry_no_longer_raises_the_issue() + { + await using var harness = CreateHarness(); + harness.Services.GetRequiredService().MarkBlocked("gone"); + SeedDevices(harness); + + Assert.That(await IssuesAsync(harness), Is.Empty); + } + + [Test] + public async Task Resolving_opens_the_setup_guide_and_an_unknown_issue_fails() + { + await using var harness = CreateHarness(); + var integration = harness.Services.GetRequiredService(); + var opened = new List(); + integration.OpenInBrowser = opened.Add; + + var known = await integration.ResolveIssueAsync(BatteryIntegration.LinuxDeviceAccessIssueId); + var unknown = await integration.ResolveIssueAsync("something-else"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(known.Success, Is.True); + Assert.That(opened, Is.EqualTo(new[] { BatteryIntegration.LinuxSetupGuideUrl })); + Assert.That(unknown.Success, Is.False); + } + } + + [Test] + public async Task Without_a_browser_the_failure_still_carries_the_guide() + { + await using var harness = CreateHarness(); + var integration = harness.Services.GetRequiredService(); + integration.OpenInBrowser = _ => throw new System.ComponentModel.Win32Exception("no xdg-open"); + + var resolution = await integration.ResolveIssueAsync(BatteryIntegration.LinuxDeviceAccessIssueId); + + Assert.That(resolution.Success, Is.False); + } } [TestFixture] diff --git a/tests/DeviceBatteryInfo.Tests/CatalogNotificationTests.cs b/tests/DeviceBatteryInfo.Tests/CatalogNotificationTests.cs index 35fcd3d..d1dd42e 100644 --- a/tests/DeviceBatteryInfo.Tests/CatalogNotificationTests.cs +++ b/tests/DeviceBatteryInfo.Tests/CatalogNotificationTests.cs @@ -73,6 +73,7 @@ private static BatteryIntegration Build(out DeviceCatalog catalog, out BatteryRe catalog, TestModels.Catalog(), new NoDiscovery(), + new DeviceAccessProblems(), Serilog.Core.Logger.None ); } diff --git a/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs b/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs index bdd9ad2..35925cb 100644 --- a/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs +++ b/tests/DeviceBatteryInfo.Tests/HidFamilyTests.cs @@ -22,7 +22,7 @@ int minFeatureReportLength ) => candidates .Where(c => interfaceNumber is null || c.InterfaceNumber == interfaceNumber) - .Where(c => c.FeatureReportLength >= minFeatureReportLength) + .Where(c => c.CouldNotOpen || c.FeatureReportLength >= minFeatureReportLength) .ToArray(); public IReadOnlyList ListFeatureReportDevices() => candidates; @@ -79,14 +79,20 @@ private static BatterySlot MouseSlot(string id, string name) => CatalogDeviceId: "razer-deathadder-v3-pro" ); + private static Task> DiscoverAsync( + FakeTransport transport, + params BatterySlot[] slots + ) => DiscoverAsync(transport, new DeviceAccessProblems(), slots); + private static async Task> DiscoverAsync( FakeTransport transport, + DeviceAccessProblems accessProblems, params BatterySlot[] slots ) { var catalog = new DeviceCatalog(); catalog.Set(slots); - var family = new HidFamily([new RazerProtocol()], transport, Serilog.Core.Logger.None); + var family = new HidFamily([new RazerProtocol()], transport, Serilog.Core.Logger.None, accessProblems); return await new DeviceFamilyProvider([family], catalog).DiscoverAsync( CancellationToken.None ); @@ -146,4 +152,56 @@ public async Task Fewer_units_than_entries_leaves_the_extra_entry_without_a_sour Assert.That(sources.Select(s => s.Id), Is.EqualTo(["mouse-a"])); } + + // A hidraw node without the udev rule: present, but every report length reads as 0. + private static HidCandidate Refused(int iface) => + Candidate(iface) with { FeatureReportLength = 0 }; + + [Test] + public async Task A_connected_device_that_cannot_be_opened_is_recorded_and_not_probed() + { + var problems = new DeviceAccessProblems(); + var transport = new FakeTransport(Refused(0), Refused(2)); + + var sources = await DiscoverAsync(transport, problems, MouseSlot("mouse", "Mouse")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(sources, Is.Empty); + Assert.That(transport.Queried, Is.Empty); + Assert.That(problems.IsBlocked("mouse"), Is.True); + } + } + + [Test] + public async Task Access_or_absence_clears_the_record() + { + var problems = new DeviceAccessProblems(); + await DiscoverAsync(new FakeTransport(Refused(0)), problems, MouseSlot("mouse", "Mouse")); + await DiscoverAsync(new FakeTransport(Candidate(2)), problems, MouseSlot("mouse", "Mouse")); + var readable = problems.IsBlocked("mouse"); + + await DiscoverAsync(new FakeTransport(Refused(0)), problems, MouseSlot("mouse", "Mouse")); + await DiscoverAsync(new FakeTransport(), problems, MouseSlot("mouse", "Mouse")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(readable, Is.False); + Assert.That(problems.IsBlocked("mouse"), Is.False); + } + } + + [Test] + public async Task One_readable_interface_is_enough() + { + var problems = new DeviceAccessProblems(); + + var sources = await DiscoverAsync(new FakeTransport(Refused(0), Candidate(2)), problems, MouseSlot("mouse", "Mouse")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(sources, Has.Count.EqualTo(1)); + Assert.That(problems.IsBlocked("mouse"), Is.False); + } + } } From a883e506907532ee3bf2230bff15d059a9333619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:39:29 +0200 Subject: [PATCH 07/12] fix: pack the linux-x64 artifact with make pack on Linux --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ddb6c97..e6d10bf 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ RUN := $(UTF8) macrodeck-plugin run --project $(PROJECT) --state-directory SDK := grep -o 'MacroDeck.Sdk" Version="[^"]*' Directory.Packages.props | cut -d'"' -f3 TESTS := dotnet test DeviceBatteryInfo.slnx --configuration Release --filter "Category!=Hardware" -RID := $(if $(filter Windows_NT,$(OS)),win-x64,osx-arm64) +RID := $(if $(filter Windows_NT,$(OS)),win-x64,$(if $(filter Linux,$(shell uname -s)),linux-x64,osx-arm64)) # Store images: every [UiPreview] scenario at each deck shape. Override on the command line, # e.g. make preview CELLS="--cells 2x2" PREVIEW_ARGS="--theme light". From dc4866bdae0d7210f3a68680f1da95250df7df13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:39:45 +0200 Subject: [PATCH 08/12] chore: align comments with the comment rules --- src/DeviceBatteryInfo/Core/BatteryPollingService.cs | 2 +- src/DeviceBatteryInfo/Core/BatteryReading.cs | 4 ++-- src/DeviceBatteryInfo/Core/BatteryRegistry.cs | 2 +- src/DeviceBatteryInfo/Core/BatteryTrendTracker.cs | 2 +- src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs | 6 +++--- src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs | 5 ++--- .../BatterySourceParsingTests.cs | 11 ++++++----- .../BatteryTrendTrackerTests.cs | 4 ++-- .../CatalogNotificationTests.cs | 2 +- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/DeviceBatteryInfo/Core/BatteryPollingService.cs b/src/DeviceBatteryInfo/Core/BatteryPollingService.cs index c25775d..56c5acc 100644 --- a/src/DeviceBatteryInfo/Core/BatteryPollingService.cs +++ b/src/DeviceBatteryInfo/Core/BatteryPollingService.cs @@ -26,7 +26,7 @@ public void RequestRefresh() } catch (SemaphoreFullException) { - // A refresh is already pending; nothing to do + // A refresh is already pending. } } diff --git a/src/DeviceBatteryInfo/Core/BatteryReading.cs b/src/DeviceBatteryInfo/Core/BatteryReading.cs index 767b274..4bba3d3 100644 --- a/src/DeviceBatteryInfo/Core/BatteryReading.cs +++ b/src/DeviceBatteryInfo/Core/BatteryReading.cs @@ -1,13 +1,13 @@ namespace DeviceBatteryInfo.Core; -// A source that cannot answer must return Unavailable, not a zero percentage +// A source that cannot answer must return Unavailable, not a zero percentage. public sealed record BatteryReading { public int? Percent { get; init; } public BatteryStatus Status { get; init; } = BatteryStatus.Unknown; - // Rarely available outside the host system's own battery + // Rarely available outside the host system's own battery. public TimeSpan? TimeToFull { get; init; } public TimeSpan? TimeToEmpty { get; init; } diff --git a/src/DeviceBatteryInfo/Core/BatteryRegistry.cs b/src/DeviceBatteryInfo/Core/BatteryRegistry.cs index 3489854..c2321be 100644 --- a/src/DeviceBatteryInfo/Core/BatteryRegistry.cs +++ b/src/DeviceBatteryInfo/Core/BatteryRegistry.cs @@ -103,7 +103,7 @@ private static bool SnapshotEquivalent(BatterySnapshot a, BatterySnapshot b) => private sealed record Entry(BatterySnapshot Snapshot, int ConsecutiveFailures); } -// Current is null when the source was removed +// Current is null when the source was removed. public sealed class BatterySnapshotChangedEventArgs( BatterySnapshot? previous, BatterySnapshot? current diff --git a/src/DeviceBatteryInfo/Core/BatteryTrendTracker.cs b/src/DeviceBatteryInfo/Core/BatteryTrendTracker.cs index 0e95d11..38022c9 100644 --- a/src/DeviceBatteryInfo/Core/BatteryTrendTracker.cs +++ b/src/DeviceBatteryInfo/Core/BatteryTrendTracker.cs @@ -6,7 +6,7 @@ namespace DeviceBatteryInfo.Core; // step replaces the segment with a new immutable instance instead of mutating one. public sealed class BatteryTrendTracker { - // Bounds how far back a rate can look, so a segment cannot grow unbounded in memory + // Bounds how far back a rate can look, so a segment cannot grow unbounded in memory. private static readonly TimeSpan MaxHistory = TimeSpan.FromHours(3); // Below this the delta is dominated by poll jitter rather than the device's actual drain, so diff --git a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs index 10782fd..8dbd1e4 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/HidTransport.cs @@ -20,8 +20,8 @@ internal sealed record HidCandidate( IReadOnlyList<(int Page, int Usage)>? Usages = null ) { - // macOS presents one device per interface with every top-level collection inside it, while Windows - // presents one device per collection, so the first usage alone is not enough to find a vendor collection. + // macOS and Linux present one device per interface with every top-level collection inside it, Windows one + // per collection, so the first usage alone is not enough to find a vendor collection. public bool HasUsage(int? page, int? usage) => Usages is { } all ? all.Contains((page ?? -1, usage ?? -1)) : UsagePage == page && Usage == usage; @@ -83,7 +83,7 @@ internal sealed partial class HidSharpTransport : IHidTransport public HidSharpTransport(ILogger logger) : this(logger, OpenPlatformChannel, boundBlockingOpen: !OperatingSystem.IsWindows()) { } - // HidSharp retries a refused open for about a second on macOS, so there the whole exchange is + // HidSharp retries a refused open for about a second on macOS, so off Windows the whole exchange is // bounded by its budget instead of blocking the poll. internal HidSharpTransport( ILogger logger, diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs index e60315e..ce37e41 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetView.cs @@ -417,9 +417,8 @@ double fraction private const double CaptionSize = 0.058; private const double PercentSize = 0.09; - // The reader measures in the viewer's font and draws the inline rows unless a name or caption - // would be cut off. Texts in a row have no shrink priority, and an unsized first-fit would take - // the stacked layout's height, so the whole list switches at once. + // The reader draws the inline rows unless a name or caption would be cut off in the viewer's font. Texts + // have no shrink priority and an unsized first-fit takes the stacked height, so the whole list switches. private static UiFirstFit ListBody(UiState state) => new() { diff --git a/tests/DeviceBatteryInfo.Tests/BatterySourceParsingTests.cs b/tests/DeviceBatteryInfo.Tests/BatterySourceParsingTests.cs index a79a52a..64e7f23 100644 --- a/tests/DeviceBatteryInfo.Tests/BatterySourceParsingTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatterySourceParsingTests.cs @@ -161,7 +161,7 @@ public void Request_has_command_and_checksum() Assert.That(request[5], Is.EqualTo(0x02)); Assert.That(request[6], Is.EqualTo(0x07)); Assert.That(request[7], Is.EqualTo(0x80)); - // XOR of bytes 3..87: only 0x02, 0x07 and 0x80 are non-zero + // XOR of bytes 3..87, where only 0x02, 0x07 and 0x80 are non-zero. Assert.That(request[88], Is.EqualTo(0x02 ^ 0x07 ^ 0x80)); } } @@ -179,11 +179,12 @@ public void Percent_scales_from_byte_range() private static byte[] CompletedFrame(byte commandId, byte value) { + // Status successful, the power class and the command echoed, the value as the first argument. var report = new byte[91]; - report[1] = 0x02; // status: successful - report[7] = 0x07; // command class echo: power - report[8] = commandId; // command id echo - report[10] = value; // Razer argument 1 + report[1] = 0x02; + report[7] = 0x07; + report[8] = commandId; + report[10] = value; return report; } diff --git a/tests/DeviceBatteryInfo.Tests/BatteryTrendTrackerTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryTrendTrackerTests.cs index b922cba..c8fd8b9 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryTrendTrackerTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryTrendTrackerTests.cs @@ -96,7 +96,7 @@ public void A_charging_state_flip_starts_a_fresh_segment() time.Advance(TimeSpan.FromMinutes(10)); registry.Update(source, BatteryReading.FromPercent(45, BatteryStatus.Discharging)); - // Charging starts: the discharging history must not leak into the charging rate + // Charging starts, so the discharging history must not leak into the charging rate. registry.Update(source, BatteryReading.FromPercent(45, BatteryStatus.Charging)); time.Advance(TimeSpan.FromMinutes(1)); registry.Update(source, BatteryReading.FromPercent(50, BatteryStatus.Charging)); @@ -117,7 +117,7 @@ public void A_reading_at_the_same_percent_does_not_move_the_reference_point() registry.Update(source, BatteryReading.FromPercent(80, BatteryStatus.Discharging)); // Going stale and recovering fires Changed without the percent moving; the oldest sample - // used for the window must still be the very first reading, not this one + // used for the window must still be the very first reading, not this one. time.Advance(TimeSpan.FromMinutes(30)); registry.RecordFailure(source, staleAfter: 1); diff --git a/tests/DeviceBatteryInfo.Tests/CatalogNotificationTests.cs b/tests/DeviceBatteryInfo.Tests/CatalogNotificationTests.cs index d1dd42e..163811f 100644 --- a/tests/DeviceBatteryInfo.Tests/CatalogNotificationTests.cs +++ b/tests/DeviceBatteryInfo.Tests/CatalogNotificationTests.cs @@ -50,7 +50,7 @@ public async Task A_runtime_source_appearing_after_init_never_throws() BatteryReading.FromPercent(50, BatteryStatus.Discharging) ) ); - // let any stray fire-and-forget work settle + // Lets any stray fire-and-forget work settle. await Task.Delay(200); } From e7c4c9b8a59809a9d4f432b3c68a77a3a06eb973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:40:02 +0200 Subject: [PATCH 09/12] docs: document Linux support and refresh outdated docs --- AGENTS.md | 112 ++++++++++++++++++++++++++++++---------- CONTRIBUTING.md | 6 +-- README.md | 60 ++++++++++++++------- docs/adding-a-device.md | 9 ++-- 4 files changed, 135 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44fa681..c118c6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ work in this repository, update this file as part of that change rather than lea This folder is **Device Battery Info** (`manifest.json` `name`), a Macro Deck 3 out-of-process plugin, scaffolded from `macrodeck-plugin new`. It reads battery state from three generic backends (the host -computer (Windows or macOS), an Android phone over adb, Bluetooth devices on Windows and macOS) plus a +computer (Windows, macOS or Linux), an Android phone over adb, Bluetooth devices on all three) plus a growing catalog of specific products under "Other devices" (each model lives in a device family, see `Sources/DeviceFamily.cs`), and exposes each as a set of Macro Deck variables plus charging/low events, alongside a custom deck widget (a multi-device panel and a single-device tile). @@ -20,11 +20,14 @@ package itself is covered by ``` src/DeviceBatteryInfo/ Program.cs builder chain: bind options, register registry + sources + poll loop - manifest.json identity, icon, win-x64 and osx-arm64 entrypoints (one managed build) + manifest.json identity, icon, win-x64, osx-arm64 and linux-x64 entrypoints (one managed + build) macrodeck-build.json the publish target per entrypoint BatteryIntegration.cs IPluginIntegration + IVariableProvider (on-demand catalog, push) + IEventProvider + IConfigFlowProvider (AllowsMultipleConfigurations) BatteryIntegration.Widgets.cs the same partial class: IWidgetTypeProvider + IUiProvider + BatteryIntegration.Issues.cs the same partial class: IIntegrationIssueProvider (the Linux HID + permission issue) 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 @@ -33,8 +36,9 @@ src/DeviceBatteryInfo/ from the registered device families, for the "Other devices" step), DeviceEntryReader (entries -> BatterySlot[]), DeviceConfigKeys, SystemDeviceDiscovery - Core/DeviceCatalog.cs the live device set: seeded from BatteryPluginOptions, replaced from config - entries + Core/DeviceAccessProblems.cs device ids whose hardware is connected but refused to open (HidFamily + records, the issue reads) + Core/DeviceCatalog.cs the live device set, replaced from the config entries Core/IDeviceDiscovery.cs public: lists present Bluetooth devices and attached Android phones for the config-flow pickers (HID enumeration is kept for a future "scan for supported devices" step) @@ -44,18 +48,21 @@ src/DeviceBatteryInfo/ BatteryWidgetModel, BatteryWidgetTypes (descriptors + JSON Schema), BatteryWidgetSamples (fixed demo models shared by the widget "sample" surface and the previews), BatteryWidgetPreviews ([UiPreview] scenarios - the host's Developer Tools list and render) + the host's Developer Tools list and render), TextWidth (estimated text + widths for the list percentage) Actions/RefreshBatteryAction.cs "refresh" action: wakes the poll loop Core/ IBatterySource, BatteryReading, BatteryRegistry, BatteryPollingService, BatteryPluginOptions, BatterySlots (config -> device set, one place), BatteryTrendTracker (per-device charge history -> BatteryTrend), BatteryTrendFormatter (BatteryTrend -> display text / a normalized rate) - Sources/ one folder per backend (SystemBattery, Razer, Logitech, Corsair, Rapoo, Aula, Sony, Adb, Bluetooth), each a - pure parser + an IO wrapper behind an interface + IBatterySource(+Provider); - SystemBattery has ISystemPowerReader (Windows: kernel32, macOS: pmset) and - Bluetooth has IBluetoothBatteryReader (Windows: PowerShell PnP, macOS: - system_profiler plus pmset accps), picked by OperatingSystem in - BatterySourceRegistration; + Sources/ one folder per backend (SystemBattery, Adb, Bluetooth, and the HID brands + Razer, Logitech, Corsair, Rapoo, Aula, Sony), each a pure parser + an IO + wrapper behind an interface + IBatterySource(+Provider); + SystemBattery has ISystemPowerReader (Windows: kernel32, macOS: pmset, + Linux: /sys/class/power_supply) and Bluetooth has IBluetoothBatteryReader + (Windows: PowerShell PnP, macOS: system_profiler plus pmset accps, Linux: + busctl against BlueZ; the last two share BluetoothSnapshot), picked by + OperatingSystem in BatterySourceRegistration; ExternalProcess runs the command line tools; BatterySourceRegistration wires them into DI. DeviceFamily.cs holds the device-family contracts (IDeviceFamily, DeviceModel, SimpleDeviceFamily, @@ -69,17 +76,31 @@ src/DeviceBatteryInfo/ Properties/launchSettings.json the single real-host debug profile tests/DeviceBatteryInfo.Tests/ BatteryIntegrationTests.cs builds, initializes, the variables catalogue + a read work - BatterySourceParsingTests.cs Razer report / PnP / Win32 power-status parsers + 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 + 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 + HidFamilyTests.cs probing, path caching, identical units, refused (unopenable) devices + HidProtocolTests.cs a second protocol on the shared HID plumbing, dongle or cable, push-only ProtocolTests.cs per-brand HID frames captured on real hardware (Logitech, Corsair, Rapoo, AULA, Sony) MacOsSourceTests.cs pmset and system_profiler parsers, the Bluetooth snapshot, the system source - HidTransportPlatformTests.cs macOS interface and unit keys, the bounded exchange on a fake channel + LinuxSourceTests.cs power_supply and BlueZ parsers, sysfs HID interface and unit keys + HidTransportPlatformTests.cs macOS and Windows interface and unit keys, the bounded exchange on a fake + channel, vendor-collection matching BatteryRegistryTests.cs registry update / stale / retain, and catalog id round-trips HardwareTests.cs [Explicit, Category=Hardware]: lists Bluetooth and HID interfaces, reads every supported HID device through the plugin, and reads one again while a foreign poller hammers the same control interface (the Synapse case); all output goes through HardwareReport so every line has the same shape +docs/linux-setup.md end-user guide the Linux issue links to (installs the rule by URL) +packaging/linux/70-device-battery-info.rules udev rule granting the seat user the HID vendors' hidraw + nodes; one line per HidProtocol vendor id (LinuxUdevRuleTests checks it) ``` Design knowledge that is not obvious from the code alone: @@ -395,8 +416,8 @@ Design knowledge that is not obvious from the code alone: and writing `0x05` is something no reference implementation does. Lightbar, player LEDs, rumble and adaptive triggers live in output report `0x02` (USB) and are not used. - **HID on macOS uses HidSharp for the feature reports and enumeration, through `IFeatureChannel`.** - `HidSharpTransport` opens a device with `NativeHid` on Windows and with - `HidStream.SetFeature/GetFeature` elsewhere; the loop, timing and buffer layout are unchanged apart + `HidSharpTransport` opens a device with `NativeHid` on Windows, `LinuxHidraw` on Linux and + `HidStream.SetFeature/GetFeature` on macOS; the loop, timing and buffer layout are unchanged apart from those open/set/get call sites. HidSharp lists only devices with a real USB id on macOS (it returns nothing on a Mac with only built-in Apple devices), paths look like `.../IOUSBHostInterface@N/AppleUserUSBHostHIDDevice` with `N` in hex, and the feature report length @@ -416,13 +437,51 @@ Design knowledge that is not obvious from the code alone: that report id (`OutputReportLength`), not the longest output report of the interface. Read on macOS: Razer Basilisk V3 Pro (cable and dongle) and the Logitech G Pro X Wireless headset; the Logitech mice are unverified there. - -Authoritative upstream documentation, in the -[Macro Deck 3 repository](https://github.com/Macro-Deck-App/Macro-Deck-3/tree/main/docs/plugin-development): -`sdk-reference.md` (every contract type), `plugin-hosting.md` (builder, registration modes, manifest, -artifact, environment variables), `capability-parity.md` (what behaves differently out of process), -`analyzers.md`, `conformance.md`, `testing-plugins.md`, `cli.md`. When a question is about SDK behaviour -rather than this template's own code, look there rather than guessing. +- **Linux reads files and D-Bus, and needs a udev rule for HID.** `PowerSupplyBatteryParser` reads + `/sys/class/power_supply/*/uevent` (no process): `TYPE=Battery` only, never `SCOPE=Device` (a + peripheral the kernel drives, such as a Logitech mouse through hid-logitech-hidpp) and never + `PRESENT=0`. A battery reports either energy (`ENERGY_*` in uWh with `POWER_NOW` in uW) or charge + (`CHARGE_*` in uAh with `CURRENT_NOW` in uA), the two are never summed together, and a rate can be + negative on some drivers. Several batteries (ThinkPads) combine into one reading. `Not charging` (a charge + threshold) maps like macOS's `AC attached`. `BlueZBatteryReader` runs + `/usr/bin/busctl --system --json=short call org.bluez / org.freedesktop.DBus.ObjectManager GetManagedObjects` + once per snapshot; `BlueZDeviceParser` lists connected `org.bluez.Device1` objects by `Alias` (the + desktop's name, falling back to `Name`) with `org.bluez.Battery1.Percentage`. busctl wraps every variant + as `{ "type", "data" }`. **Never use HidSharp's `SetFeature`/`GetFeature` on Linux**: against a + DeathAdder V3 Pro dongle they returned an all-zero buffer and no error, while `HIDIOCSFEATURE` / + `HIDIOCGFEATURE` on the same node answered correctly, so `LinuxFeatureChannel` issues those ioctls + itself (`LinuxHidraw`, report id at byte 0 as everywhere). HidSharp is still used on Linux for + enumeration, report lengths and the input/output report exchange (plain hidraw read/write, unverified + on hardware there). HidSharp on Linux uses hidraw and its `DevicePath` is the sysfs path + (`/sys/devices/.../1-5.3/1-5.3:1.1/0003:1532:00B7.0002/hidraw/hidraw1`): the interface number is the + decimal `N` of the `:.N` node and `PhysicalUnitKey` is the USB device directory above it + (`l:` prefix). Like macOS, one hidraw node carries every collection of its interface, so the non-Windows + report-length and usage handling applies. hidraw nodes are `root:root 0600` by default and HidSharp + must open a node even to read its report lengths, so without the udev rule every candidate comes back + with all lengths 0 (`HidCandidate.CouldNotOpen`). `FindCandidates` returns those flagged rather than + dropping them, and `HidFamily` records an entry in `DeviceAccessProblems` (logging once per episode) + when its model is connected but no interface opens; a readable interface or an absent device clears it. + On Linux `BatteryIntegration` turns that into an Error integration issue naming the entries (only those + still in `DeviceCatalog`, so a deleted entry drops out). An issue's button runs only `ResolveIssueAsync` + and the SDK's follow-ups are `None` or `StartConfigFlow`, so the plugin opens `docs/linux-setup.md` + itself (`Process.Start` with `UseShellExecute`, which is `xdg-open` on Linux, as System-Media does for + its VLC add-on) and a failure toast carries the URL. The host re-lists issues on its own; the issue + clears at the next poll once the device opens. Removing the udev rule does not revoke access until the + device is replugged or `udevadm trigger` runs, so test the issue only after that. + `GetIssuesAsync` is polled by the host and must stay a read of recorded state. The beta.15 + `PluginTestHarness` has no `Issues` member yet, so tests call the integration from the harness's DI. The rule tags the vendors' nodes `uaccess`, which must happen before + `73-seat-late.rules`, hence the `70-` prefix; Bluetooth HID nodes (uhid) have no USB vendor attribute + and are not covered. Read on Linux: the Razer DeathAdder V3 Pro (dongle and cable). + +Authoritative upstream documentation is at (the Macro Deck 3 repository itself is not public): +[plugin hosting](https://docs.macro-deck.app/reference/plugin-hosting/) (builder, registration modes, manifest, artifact, +environment variables), [capability parity](https://docs.macro-deck.app/reference/capability-parity/) (what behaves differently +out of process), [SDK packages](https://docs.macro-deck.app/reference/sdk-packages/), the [feature guides](https://docs.macro-deck.app/features/) (one +per capability, e.g. [integration issues](https://docs.macro-deck.app/features/integration-issues/)), +[analyzers](https://docs.macro-deck.app/reference/analyzers/), [conformance](https://docs.macro-deck.app/reference/conformance/), +[testing](https://docs.macro-deck.app/features/testing/) and the [CLI](https://docs.macro-deck.app/cli/). When a question is about SDK behaviour rather +than this plugin's own code, look there; the SDK assemblies ship without XML docs, so where a page is +missing, reflect over the package instead of guessing. ## Before you start on a fresh plugin @@ -615,9 +674,10 @@ change; a notification landing *during* the reinit makes it unregister and re-re integration, which re-enters `InitializeAsync`, which notifies again - an 8-deep loop that ends with every provided variable failing to register as `AlreadyExists` (net: zero variables, and the localization + widget-type registration churned so strings render as `[[plugin::Key]]` and the -widgets vanish). `BatteryIntegration` gates every announcement behind a `_ready` flag set at the end -of `InitializeAsync` and de-dupes against the last announced device-id set; `CatalogNotificationTests` -locks this in. Genuine runtime changes (a source appears mid-session) announce fine once `_ready`. +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 +with a live call on its own schedule (confirmed: the Linux HID issue appears without one). ### Configuration and secrets @@ -772,7 +832,7 @@ A `dotnet build -c Release` output is *not* packable: the manifest points at `ru only `build` assembles, so `validate`/`pack` against `bin/Release/net10.0` fails on a missing entrypoint. Adding a platform means adding it to `entrypoints` **and** `macrodeck-build.json`. -This plugin is **framework-dependent**: `entrypoints.win-x64` and `osx-arm64` each name +This plugin is **framework-dependent**: `entrypoints.win-x64`, `osx-arm64` and `linux-x64` each name `runtimes//DeviceBatteryInfo.dll` (the same managed build, no native assets) with `"runtime": { "kind": "FrameworkDependent", "dotnetVersion": "10.0" }`, and `macrodeck-build.json` publishes with `--self-contained false -p:UseAppHost=false`. Macro Deck ships a .NET 10 runtime (ASP.NET Core included) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0c9e8a..bb34d5b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,9 @@ plugged in and tested - the fastest way to make it more useful is to add the one ## Ways to contribute - **Add a device backend.** [`docs/adding-a-device.md`](docs/adding-a-device.md) walks through the - interfaces involved (`IBatterySource`, `IBatterySourceProvider`) and where a new backend registers - itself. Most device PRs touch one new folder under `Sources/`, one catalog entry, and a handful of - tests. + cases: one line for another model of a known brand, one `HidProtocol` class for a new USB HID brand, or + one device family for anything else. Each registers itself; most device PRs touch one new folder under + `Sources/`, the supported-devices table in the README, and a handful of tests. - **Report a bug** or **request a device** you don't have time to implement yourself, using the issue templates. - **Improve the docs.** [AGENTS.md](AGENTS.md) and [`docs/adding-a-device.md`](docs/adding-a-device.md) diff --git a/README.md b/README.md index 6ce98d4..5a7b8e5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Device Battery Info A [Macro Deck 3](https://macro-deck.app/) plugin that shows the battery level of your computer -(Windows or macOS), your Android phone, your Bluetooth devices and a growing list of specific gaming +(Windows, macOS or Linux), your Android phone, your Bluetooth devices and a growing list of specific gaming peripherals, right on your deck. ## Contents @@ -49,9 +49,11 @@ These work with whatever hardware of that kind you have. | ------------------------------ | -------- | ----------------------------------------------- | ------- | -------- | | This computer / laptop | Windows | Win32 `GetSystemPowerStatus` | yes | yes | | This computer / laptop | macOS | `pmset -g batt` | yes | yes | -| Android phone | both | Macro Deck's own adb connection | yes | yes | +| This computer / laptop | Linux | `/sys/class/power_supply` | yes | yes | +| Android phone | all | Macro Deck's own adb connection | yes | yes | | Bluetooth audio device | Windows | PnP battery property via PowerShell | yes | rarely | | Bluetooth device | macOS | `system_profiler` plus `pmset -g accps` | yes | no | +| Bluetooth device | Linux | BlueZ's `Battery1` over D-Bus (`busctl`) | yes | no | On macOS a Bluetooth device reports one level: its main battery, or the lower of the left and right earbud (the case is ignored). Connected devices that `system_profiler` lists without a battery, such as @@ -60,6 +62,12 @@ batteries. Only devices that are connected right now are read, because macOS kee devices that are not. A Mac that is held below full on AC by Optimized Battery Charging reports its level with an unknown charging state, since it is neither charging nor discharging. +On Linux the computer's batteries are read from the kernel, and a laptop with two batteries reports them +as one level. A battery held below full by a charge threshold reports an unknown charging state, like a +Mac on Optimized Battery Charging. A Bluetooth device reports the level BlueZ publishes for it: Bluetooth +LE devices with a battery service do so on their own, headsets usually through PipeWire. Only connected +devices are listed, by the name the desktop shows for them. + ### Specific devices ("Other devices") A catalog of individual products that someone has implemented and tested against real hardware. It @@ -87,21 +95,28 @@ Own a device that is not listed? Adding it is the main way this catalog grows, s ## Installing Download the packed `.macroDeckPlugin` file from the -[latest release](https://github.com/PyFlat-JR/Device-Battery-Info/releases) (or from the store +[latest release](https://github.com/PyFlat/Device-Battery-Info/releases) (or from the store listing, once published) and install it from Macro Deck's plugin manager. -The plugin ships for **Windows x64** and **macOS on Apple silicon**. Intel Macs and Linux are not +The plugin ships for **Windows x64**, **macOS on Apple silicon** and **Linux x64**. Intel Macs are not supported. On macOS the Bluetooth source relies on the `device_connected` layout of `system_profiler`, which macOS 12 and later are expected to produce (checked on macOS 27). A connected device with a battery in `system_profiler` itself, such as earbuds, has not been seen on real hardware; a Logitech MX Master 3S was read through `pmset -g accps`. -USB HID devices (the Razer and Logitech models below) are read on macOS through the same HID code as on +USB HID devices (the catalog models above) are read on macOS through the same HID code as on Windows. That was checked on macOS with a Razer Basilisk V3 Pro (cable and dongle) and a Logitech G Pro X Wireless headset. macOS can ask for **Input Monitoring** before an application may open some HID interfaces; if a device stays unavailable, allow Macro Deck under System Settings > Privacy & Security > Input Monitoring. The Logitech mice have not been tried on macOS. +On Linux the plugin needs a systemd-based distribution (it calls `busctl` for Bluetooth) and, for the USB +HID devices, read and write access to their `/dev/hidraw*` nodes, which only root has by default. A +one-time udev rule grants that; [Linux setup](docs/linux-setup.md) has the commands. Until it is +installed, Macro Deck shows a problem on the plugin naming the device and pointing to that guide. +On Linux this was checked with a Razer DeathAdder V3 Pro (dongle and cable); the other catalog devices +have not been tried there yet. + ## Setting up devices Devices are managed inside Macro Deck through the plugin's config flow. Add one entry per device: @@ -121,7 +136,7 @@ phone. A phone that is not attached yet can be entered by hand: a USB phone by i one by `host:port`, which the plugin connects to on its own (Android 11+ needs the phone paired first). A Bluetooth device is found by the name the operating system shows for it. An entry that is moved from -Windows to macOS keeps working only if that name is the same on both, so rename it in the entry otherwise. +one operating system to another keeps working only if that name is the same on both, so rename it in the entry otherwise. Editing an existing entry pre-fills its fields. There is no default device: a fresh install shows nothing until you add one, and the widgets say so until then. @@ -162,7 +177,8 @@ launch the plugin against the running Macro Deck through `macrodeck-plugin run` credential kept in `src/DeviceBatteryInfo/.macrodeck-dev-state/`), `make stub` against a stub host, `make preview` renders the widget previews to PNGs in `artifacts/previews/` (the store images), `make cli` keeps the CLI at the SDK's version, `make pack` builds and inspects the artifact for this -machine's platform (`win-x64` on Windows, `osx-arm64` otherwise; the release workflow builds both), and +machine's platform (`win-x64` on Windows, `linux-x64` on Linux, `osx-arm64` on a Mac; the release +workflow builds all three), and `make release VERSION=x.y.z` tests and packs, bumps `manifest.json`, commits, tags `vx.y.z` and pushes - the tag starts the release workflow. On Windows it needs GNU make and Git Bash's `sh` on `PATH`. @@ -171,14 +187,16 @@ the tag starts the release workflow. On Windows it needs GNU make and Git Bash's ``` src/DeviceBatteryInfo/ Program.cs builder chain: bind options, register registry + sources + poll loop - manifest.json identity, icon, win-x64 and osx-arm64 entrypoints + manifest.json identity, icon, win-x64, osx-arm64 and linux-x64 entrypoints 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) ConfigFlow/ the device config flow (add/edit steps, discovery, the "Other devices" catalog) Core/ IBatterySource, BatteryReading, BatteryRegistry, BatteryPollingService, - BatteryPluginOptions, DeviceCatalog - Sources/ one folder per backend (SystemBattery, Razer, Logitech, Adb, Bluetooth): a pure - parser, an IO wrapper behind an interface, and an IBatterySource(+Provider). + BatteryPluginOptions, DeviceCatalog, DeviceAccessProblems + Sources/ one folder per backend (SystemBattery, Adb, Bluetooth, and the HID brands Razer, + Logitech, Corsair, Rapoo, Aula, Sony): a pure parser, an IO wrapper behind an + interface, and an IBatterySource(+Provider). Hid/ is the shared HID transport, base class and family; a brand adds one protocol file whose device list is one line per supported model Variables/ slot x field -> VariableDefinition, and the reverse resolve @@ -186,8 +204,10 @@ src/DeviceBatteryInfo/ Actions/ the "Refresh battery levels" action Localization/Strings.resx default-culture strings; Strings..resx per language tests/DeviceBatteryInfo.Tests/ - one file per capability under test (integration, source parsers, registry, widgets, config flow, - catalog notifications, HID family and protocols) + one file per capability under test (integration, source parsers per platform, registry, widgets, + config flow, catalog notifications, HID family and protocols) +docs/ adding a device, and the Linux setup guide the plugin links to +packaging/linux/ the udev rule Linux needs for the USB HID devices ``` ### Run and debug against Macro Deck @@ -403,11 +423,11 @@ MIT, see [LICENSE](LICENSE). Macro Deck itself is licensed under Apache 2.0. ## Further reading - [Adding a device](docs/adding-a-device.md): the contract a new battery source implements -- [Plugin development docs](https://github.com/Macro-Deck-App/Macro-Deck-3/tree/main/docs/plugin-development) +- [Plugin development docs](https://docs.macro-deck.app/) - [Sample plugins](https://github.com/Macro-Deck-App/Macro-Deck-Sample-Plugins): a worked example per capability -- [`plugin-hosting.md`](https://github.com/Macro-Deck-App/Macro-Deck-3/blob/main/docs/plugin-development/plugin-hosting.md): the builder API, registration modes, the artifact format and every `MACRO_DECK_PLUGIN_*` variable -- [`sdk-reference.md`](https://github.com/Macro-Deck-App/Macro-Deck-3/blob/main/docs/plugin-development/sdk-reference.md): every interface and record the plugin builds against -- [`cli.md`](https://github.com/Macro-Deck-App/Macro-Deck-3/blob/main/docs/plugin-development/cli.md): every CLI command and option -- [`testing-plugins.md`](https://github.com/Macro-Deck-App/Macro-Deck-3/blob/main/docs/plugin-development/testing-plugins.md): the test harness, the fakes and the manual clock -- [`conformance.md`](https://github.com/Macro-Deck-App/Macro-Deck-3/blob/main/docs/plugin-development/conformance.md): the conformance suite and its check ids -- [`analyzers.md`](https://github.com/Macro-Deck-App/Macro-Deck-3/blob/main/docs/plugin-development/analyzers.md): the compile-time diagnostics +- [Plugin hosting](https://docs.macro-deck.app/reference/plugin-hosting/): the builder API, registration modes, the artifact format and every `MACRO_DECK_PLUGIN_*` variable +- [SDK packages](https://docs.macro-deck.app/reference/sdk-packages/) and the [feature guides](https://docs.macro-deck.app/features/): the contracts the plugin builds against +- [CLI](https://docs.macro-deck.app/cli/): every CLI command and option +- [Testing](https://docs.macro-deck.app/features/testing/): the test harness, the fakes and the manual clock +- [Conformance](https://docs.macro-deck.app/reference/conformance/): the conformance suite and its check ids +- [Analyzers](https://docs.macro-deck.app/reference/analyzers/): the compile-time diagnostics diff --git a/docs/adding-a-device.md b/docs/adding-a-device.md index 0c824ca..d07ab29 100644 --- a/docs/adding-a-device.md +++ b/docs/adding-a-device.md @@ -21,7 +21,8 @@ new("Some Wireless Mouse", 0x00AB, 0x00AA), // dongle, cable ``` A wireless mouse has one product id for its dongle and another when it is plugged in with the cable -(Device Manager, hardware ids: `VID_1532&PID_00AB`). Leave the cable one out and the mouse disappears as +(Device Manager's hardware ids `VID_1532&PID_00AB` on Windows, `lsusb` on Linux, System Information on +macOS). Leave the cable one out and the mouse disappears as soon as it is wired. Run the hardware tests once in each mode to find them and to check that both answer: ``` @@ -62,7 +63,8 @@ sits on `LogitechHidppProtocol`, which holds the framing every Logitech device s Logitech device is a feature id, a function and a parser. What each part means: - `vendorId` is the brand's USB vendor id. `reportLength` is the smallest report the right interface - supports. + supports. On Linux a hidraw node is readable only with a udev rule, so a new vendor id also needs a line + in `packaging/linux/70-device-battery-info.rules`. - Most brands (Razer) exchange **feature reports**, which is the default. Some (Logitech HID++) write an output report and read input reports on a vendor interface instead. For those pass `HidReportKind.InputOutput` and the interface's `usagePage`/`usage` to the base constructor, as @@ -87,7 +89,8 @@ Logitech device is a feature id, a function and a parser. What each part means: To see what your device exposes, run `HardwareTests` (`dotnet test --filter Category=Hardware` from `tests/DeviceBatteryInfo.Tests`). It lists every HID interface of the supported brands with its report sizes and usage page, then reads each supported device the way the plugin does. Add your brand's protocol -to its `Protocols` list. +to its `Protocols` list. On Linux, install the [udev rule](linux-setup.md) (with your vendor id added) +first, or every interface reads as unopenable. `Sources/Razer/RazerProtocol.cs` is a complete real example. Keep the byte layout in small `static` methods (`BuildRequest`, `IsCompletedResponse`) so a test can check them without a device. From 5144404cd35cd6b09dbdbed4d48c14cd03251098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:50:53 +0200 Subject: [PATCH 10/12] fix: harden Linux setup against root downloads, vendor-wide grants and shell execute --- AGENTS.md | 26 ++++--- docs/adding-a-device.md | 7 +- docs/linux-setup.md | 56 ++++++++++++--- packaging/linux/70-device-battery-info.rules | 46 +++++++----- .../BatteryIntegration.Issues.cs | 15 +++- .../Sources/Hid/LinuxHidraw.cs | 6 +- .../LinuxSourceTests.cs | 70 +++++++++++++++---- 7 files changed, 168 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c118c6f..2edeea2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,9 +98,10 @@ tests/DeviceBatteryInfo.Tests/ every supported HID device through the plugin, and reads one again while a foreign poller hammers the same control interface (the Synapse case); all output goes through HardwareReport so every line has the same shape -docs/linux-setup.md end-user guide the Linux issue links to (installs the rule by URL) -packaging/linux/70-device-battery-info.rules udev rule granting the seat user the HID vendors' hidraw - nodes; one line per HidProtocol vendor id (LinuxUdevRuleTests checks it) +docs/linux-setup.md end-user guide the Linux issue opens; carries the udev rule inline +packaging/linux/70-device-battery-info.rules udev rule granting the seat user the hidraw nodes of the + supported models only; one line per product id (LinuxUdevRuleTests + checks it, and that the guide's copy is identical) ``` Design knowledge that is not obvious from the code alone: @@ -299,7 +300,8 @@ Design knowledge that is not obvious from the code alone: keys. Brand and model names are proper nouns and are the one deliberate exception to the no-user-facing-literal rule. Do not add a `DeviceType` value, config key or config-flow branch for a family; that is exactly what this design removed. Adding or removing a model also means updating the - "Supported devices" table in `README.md`. See `docs/adding-a-device.md`. + "Supported devices" table in `README.md` and, for a USB HID model, the Linux udev rule and its copy in + `docs/linux-setup.md`. See `docs/adding-a-device.md`. - **HID feature reports are shared plumbing plus a base family; Razer is the first protocol on it.** `Sources/Hid/` holds `NativeHid` (raw `hid.dll` feature-report interop), `IHidTransport`/ `HidSharpTransport` (enumeration plus a protocol-agnostic `ExchangeAsync` that retries until the @@ -464,14 +466,20 @@ Design knowledge that is not obvious from the code alone: On Linux `BatteryIntegration` turns that into an Error integration issue naming the entries (only those still in `DeviceCatalog`, so a deleted entry drops out). An issue's button runs only `ResolveIssueAsync` and the SDK's follow-ups are `None` or `StartConfigFlow`, so the plugin opens `docs/linux-setup.md` - itself (`Process.Start` with `UseShellExecute`, which is `xdg-open` on Linux, as System-Media does for - its VLC add-on) and a failure toast carries the URL. The host re-lists issues on its own; the issue + itself, as System-Media does for its VLC add-on, and a failure toast carries the URL. It runs + `/usr/bin/xdg-open` with the URL as its one argument: never `UseShellExecute` (a PATH lookup in the + host's environment, and the pattern a Store review already rejected once as PowerShell). The host re-lists issues on its own; the issue clears at the next poll once the device opens. Removing the udev rule does not revoke access until the device is replugged or `udevadm trigger` runs, so test the issue only after that. `GetIssuesAsync` is polled by the host and must stay a read of recorded state. The beta.15 - `PluginTestHarness` has no `Issues` member yet, so tests call the integration from the harness's DI. The rule tags the vendors' nodes `uaccess`, which must happen before - `73-seat-late.rules`, hence the `70-` prefix; Bluetooth HID nodes (uhid) have no USB vendor attribute - and are not covered. Read on Linux: the Razer DeathAdder V3 Pro (dongle and cable). + `PluginTestHarness` has no `Issues` member yet, so tests call the integration from the harness's DI. + **The udev rule is a security boundary.** It tags `uaccess` (which must happen before + `73-seat-late.rules`, hence the `70-` prefix) per supported USB product id, never per vendor: a vendor + grant would let every program the user runs read that vendor's keyboards. A Bluetooth HID node (uhid) + has no USB attributes and is matched by its HID device name instead (`KERNELS=="0005:054C:0CE6.*"` + for the DualSense, unverified). The guide prints the rule inline, one single-quoted `printf` argument per line, into `sudo tee` + (fish, the default on CachyOS, has no heredocs); never tell users to + download it as root from a branch, because a udev rule can `RUN+=` any program as root. Read on Linux: the Razer DeathAdder V3 Pro (dongle and cable). Authoritative upstream documentation is at (the Macro Deck 3 repository itself is not public): [plugin hosting](https://docs.macro-deck.app/reference/plugin-hosting/) (builder, registration modes, manifest, artifact, diff --git a/docs/adding-a-device.md b/docs/adding-a-device.md index d07ab29..a2ce24d 100644 --- a/docs/adding-a-device.md +++ b/docs/adding-a-device.md @@ -63,8 +63,9 @@ sits on `LogitechHidppProtocol`, which holds the framing every Logitech device s Logitech device is a feature id, a function and a parser. What each part means: - `vendorId` is the brand's USB vendor id. `reportLength` is the smallest report the right interface - supports. On Linux a hidraw node is readable only with a udev rule, so a new vendor id also needs a line - in `packaging/linux/70-device-battery-info.rules`. + supports. On Linux a hidraw node is readable only with a udev rule, so every new product id also needs a + line in `packaging/linux/70-device-battery-info.rules`, and the guide's inline copy in + [linux-setup.md](linux-setup.md) must match it (a test checks both). - Most brands (Razer) exchange **feature reports**, which is the default. Some (Logitech HID++) write an output report and read input reports on a vendor interface instead. For those pass `HidReportKind.InputOutput` and the interface's `usagePage`/`usage` to the base constructor, as @@ -89,7 +90,7 @@ Logitech device is a feature id, a function and a parser. What each part means: To see what your device exposes, run `HardwareTests` (`dotnet test --filter Category=Hardware` from `tests/DeviceBatteryInfo.Tests`). It lists every HID interface of the supported brands with its report sizes and usage page, then reads each supported device the way the plugin does. Add your brand's protocol -to its `Protocols` list. On Linux, install the [udev rule](linux-setup.md) (with your vendor id added) +to its `Protocols` list. On Linux, install the [udev rule](linux-setup.md) (with your product ids added) first, or every interface reads as unopenable. `Sources/Razer/RazerProtocol.cs` is a complete real example. Keep the byte layout in small `static` diff --git a/docs/linux-setup.md b/docs/linux-setup.md index 0faef88..060dd7a 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -12,16 +12,53 @@ each of them (`/dev/hidraw0`, `/dev/hidraw1`, ...). By default only root may ope plugin can see that your mouse is plugged in but cannot ask it anything. The fix is a udev rule: a small text file that tells Linux to give **the user logged in at this computer** -access to devices from these brands only. Steam, OpenRGB and similar tools set up their devices the same -way. Nothing runs as root, and other users on the machine get no access. +access to exactly the supported models listed in it, by their USB product id, and nothing else. Steam, +OpenRGB and similar tools set up their devices the same way. Nothing runs as root, and other users on the +machine get no access. + +Keep in mind what the access means: any program you run can then talk to these devices directly. For a +keyboard (the AULA F75) or a receiver that other devices share (the Logitech receiver), that includes +reading what is typed on it. Only install the rule if you use one of these devices with the plugin. ## Install the rule -Open a terminal and run: +The whole rule is below, one quoted line each, so you can see exactly what you install. Open a terminal +and paste this block (it works in bash, zsh and fish), which writes it to `/etc/udev/rules.d/` and applies +it: ```bash -sudo curl -fsSL -o /etc/udev/rules.d/70-device-battery-info.rules \ - https://raw.githubusercontent.com/PyFlat/Device-Battery-Info/main/packaging/linux/70-device-battery-info.rules +printf '%s\n' \ + '# Lets the user logged in at the seat open the hidraw nodes of the devices Device Battery Info reads,' \ + '# and nothing else: one line per supported USB product id, never a whole vendor.' \ + '# The file name must sort before 73-seat-late.rules, which is where the uaccess tag is applied.' \ + '# Install it as described in docs/linux-setup.md.' \ + '' \ + '# AULA F75' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="3554", ATTRS{idProduct}=="fa09", TAG+="uaccess"' \ + '# Corsair VOID PRO Wireless' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1b1c", ATTRS{idProduct}=="0a75", TAG+="uaccess"' \ + '# Logitech G Pro X Wireless' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0aba", TAG+="uaccess"' \ + '# Logitech G Pro X Superlight 2' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c54d", TAG+="uaccess"' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c09b", TAG+="uaccess"' \ + '# Rapoo VT3 PRO' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="1215", TAG+="uaccess"' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="4415", TAG+="uaccess"' \ + '# Razer DeathAdder V3 Pro' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00b7", TAG+="uaccess"' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00b6", TAG+="uaccess"' \ + '# Razer Basilisk V3 Pro' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00ab", TAG+="uaccess"' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00aa", TAG+="uaccess"' \ + '# Razer Viper V2 Pro' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00a6", TAG+="uaccess"' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00a5", TAG+="uaccess"' \ + '# Sony DualSense' \ + 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0ce6", TAG+="uaccess"' \ + '# Sony DualSense over Bluetooth, which has no USB attributes (bus 0005 in the HID device name)' \ + 'SUBSYSTEM=="hidraw", KERNELS=="0005:054C:0CE6.*", TAG+="uaccess"' \ + | sudo tee /etc/udev/rules.d/70-device-battery-info.rules > /dev/null sudo udevadm control --reload-rules sudo udevadm trigger --subsystem-match=hidraw ``` @@ -29,8 +66,9 @@ sudo udevadm trigger --subsystem-match=hidraw Then **unplug the device (or its USB receiver) and plug it back in**. The problem in Macro Deck disappears on its own at the plugin's next battery read. No restart is needed. -You can read the rule before installing it: -[packaging/linux/70-device-battery-info.rules](../packaging/linux/70-device-battery-info.rules). +The same rule is in the repository as +[packaging/linux/70-device-battery-info.rules](../packaging/linux/70-device-battery-info.rules). Never +install a udev rule you have not read: a rule can run any program as root. ## Check that it worked @@ -50,8 +88,8 @@ attached. `getfacl /dev/hidraw0` should list your user name with `rw-`. systemd's `busctl`. - **A device connected over Bluetooth** instead of USB is not covered by this rule. Add it as a **Bluetooth device** in the plugin instead. -- **Updating the plugin can add new brands.** If a newly supported device shows the problem again, run the - install commands once more to get the updated rule. +- **Updating the plugin can add new models.** If a newly supported device shows the problem again, run the + install block once more to get the updated rule. ## Removing it diff --git a/packaging/linux/70-device-battery-info.rules b/packaging/linux/70-device-battery-info.rules index 07144a5..c4598cb 100644 --- a/packaging/linux/70-device-battery-info.rules +++ b/packaging/linux/70-device-battery-info.rules @@ -1,20 +1,30 @@ -# Lets the user logged in at the seat open the hidraw nodes of the USB devices Device Battery Info reads. +# Lets the user logged in at the seat open the hidraw nodes of the devices Device Battery Info reads, +# and nothing else: one line per supported USB product id, never a whole vendor. # The file name must sort before 73-seat-late.rules, which is where the uaccess tag is applied. -# -# Install: -# sudo cp 70-device-battery-info.rules /etc/udev/rules.d/ -# sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=hidraw -# then replug the device (or its receiver). +# Install it as described in docs/linux-setup.md. -# Razer -SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="1532", TAG+="uaccess" -# Logitech -SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="046d", TAG+="uaccess" -# Corsair -SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="1b1c", TAG+="uaccess" -# Rapoo -SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="24ae", TAG+="uaccess" -# AULA -SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="3554", TAG+="uaccess" -# Sony -SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="054c", TAG+="uaccess" +# AULA F75 +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="3554", ATTRS{idProduct}=="fa09", TAG+="uaccess" +# Corsair VOID PRO Wireless +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1b1c", ATTRS{idProduct}=="0a75", TAG+="uaccess" +# Logitech G Pro X Wireless +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0aba", TAG+="uaccess" +# Logitech G Pro X Superlight 2 +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c54d", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c09b", TAG+="uaccess" +# Rapoo VT3 PRO +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="1215", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="24ae", ATTRS{idProduct}=="4415", TAG+="uaccess" +# Razer DeathAdder V3 Pro +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00b7", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00b6", TAG+="uaccess" +# Razer Basilisk V3 Pro +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00ab", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00aa", TAG+="uaccess" +# Razer Viper V2 Pro +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00a6", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1532", ATTRS{idProduct}=="00a5", TAG+="uaccess" +# Sony DualSense +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0ce6", TAG+="uaccess" +# Sony DualSense over Bluetooth, which has no USB attributes (bus 0005 in the HID device name) +SUBSYSTEM=="hidraw", KERNELS=="0005:054C:0CE6.*", TAG+="uaccess" diff --git a/src/DeviceBatteryInfo/BatteryIntegration.Issues.cs b/src/DeviceBatteryInfo/BatteryIntegration.Issues.cs index 90c0dfd..cd4129c 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.Issues.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.Issues.cs @@ -39,7 +39,7 @@ public Task> GetIssuesAsync(CancellationToken ca internal Action OpenInBrowser { get; set; } = OpenWithDesktop; // The SDK has no follow-up that opens a link, but the plugin runs in the user's desktop session, so it - // opens the guide itself (xdg-open on Linux). The issue clears on the next poll once the device opens. + // opens the guide itself. The issue clears on the next poll once the device opens. public Task ResolveIssueAsync(string issueId, CancellationToken cancellationToken = default) { if (issueId != LinuxDeviceAccessIssueId) @@ -52,7 +52,8 @@ public Task ResolveIssueAsync(string issueId, CancellationToken OpenInBrowser(LinuxSetupGuideUrl); return Task.FromResult(IssueResolution.Ok(Strings.Issues.LinuxDeviceAccess.Opened())); } - catch (Exception exception) when (exception is Win32Exception or InvalidOperationException) + catch (Exception exception) + when (exception is Win32Exception or InvalidOperationException or PlatformNotSupportedException) { _logger.Warning(exception, "Could not open the Linux setup guide {Url}.", LinuxSetupGuideUrl); return Task.FromResult( @@ -61,8 +62,16 @@ public Task ResolveIssueAsync(string issueId, CancellationToken } } + // No shell and no PATH lookup: the URL is one argument to an absolute xdg-open, like every other tool. private static void OpenWithDesktop(string url) { - using var browser = Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + if (!OperatingSystem.IsLinux()) + { + throw new PlatformNotSupportedException("The setup guide is only opened on Linux."); + } + + var start = new ProcessStartInfo("/usr/bin/xdg-open") { UseShellExecute = false }; + start.ArgumentList.Add(url); + using var browser = Process.Start(start); } } diff --git a/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs b/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs index 2fcf7eb..3fd238b 100644 --- a/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs +++ b/src/DeviceBatteryInfo/Sources/Hid/LinuxHidraw.cs @@ -13,6 +13,7 @@ internal static class LinuxHidraw private const uint HidrawType = 'H'; private const uint SetFeatureNumber = 0x06; private const uint GetFeatureNumber = 0x07; + private const int MaxReportLength = 0x3FFF; // Classic DllImport, matching NativeHid. ioctl is variadic, which x64 and arm64 Linux pass like fixed args. [SupportedOSPlatform("linux")] @@ -40,8 +41,11 @@ internal static void SetFeature(SafeFileHandle handle, byte[] report) => internal static void GetFeature(SafeFileHandle handle, byte[] report) => Call(handle, GetFeatureNumber, report, "HIDIOCGFEATURE"); + // The size field has 14 bits; a longer length would spill into the direction bits and name another ioctl. internal static nuint Request(uint number, int length) => - ((IocRead | IocWrite) << 30) | ((uint)length << 16) | (HidrawType << 8) | number; + length is > 0 and <= MaxReportLength + ? ((IocRead | IocWrite) << 30) | ((uint)length << 16) | (HidrawType << 8) | number + : throw new ArgumentOutOfRangeException(nameof(length), length, "Not a valid hidraw report length."); [SupportedOSPlatform("linux")] private static void Call(SafeFileHandle handle, uint number, byte[] report, string name) diff --git a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs index 40bf466..c6ec963 100644 --- a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs @@ -252,6 +252,8 @@ public void The_feature_ioctls_carry_the_report_length() { Assert.That(LinuxHidraw.Request(0x06, 91), Is.EqualTo((nuint)0xC05B4806)); Assert.That(LinuxHidraw.Request(0x07, 91), Is.EqualTo((nuint)0xC05B4807)); + Assert.Throws(() => LinuxHidraw.Request(0x06, 0x4000)); + Assert.Throws(() => LinuxHidraw.Request(0x06, 0)); } } @@ -297,36 +299,74 @@ public void Interfaces_of_one_unit_share_a_key_and_a_second_port_does_not() [TestFixture] public sealed class LinuxUdevRuleTests { - private static string RuleFile() + private static string RuleFile() => RepositoryFile("packaging", "linux", "70-device-battery-info.rules"); + + private static string RepositoryFile(params string[] parts) { for (var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); directory is not null; directory = directory.Parent) { - var rule = System.IO.Path.Combine(directory.FullName, "packaging", "linux", "70-device-battery-info.rules"); - if (File.Exists(rule)) + var file = System.IO.Path.Combine([directory.FullName, .. parts]); + if (File.Exists(file)) { - return File.ReadAllText(rule); + return File.ReadAllText(file); } } - throw new FileNotFoundException("packaging/linux/70-device-battery-info.rules not found above the test directory."); + throw new FileNotFoundException($"{string.Join('/', parts)} not found above the test directory."); } - // Without its line a brand's devices read as not connected on Linux. + private static IEnumerable<(int VendorId, int ProductId)> SupportedUsbIds() => + typeof(HidProtocol) + .Assembly.GetTypes() + .Where(t => t is { IsAbstract: false } && t.IsAssignableTo(typeof(HidProtocol))) + .Select(t => (HidProtocol)Activator.CreateInstance(t, nonPublic: true)!) + .SelectMany(p => p.Devices.SelectMany(d => d.ProductIds.Select(id => (p.VendorId, id)))); + + // Without its line a model reads as not connected on Linux. [Test] - public void Every_hid_vendor_has_a_udev_line() + public void Every_supported_product_id_has_a_udev_line() { var rule = RuleFile(); - var vendors = typeof(HidProtocol) - .Assembly.GetTypes() - .Where(t => t is { IsAbstract: false } && t.IsAssignableTo(typeof(HidProtocol))) - .Select(t => ((HidProtocol)Activator.CreateInstance(t, nonPublic: true)!).VendorId) - .Distinct() - .ToArray(); + var ids = SupportedUsbIds().ToArray(); - Assert.That(vendors, Is.Not.Empty); + Assert.That(ids, Is.Not.Empty); Assert.That( - vendors.Where(v => !rule.Contains($"ATTRS{{idVendor}}==\"{v:x4}\"", StringComparison.Ordinal)), + ids.Where(id => + !rule.Contains( + $"ATTRS{{idVendor}}==\"{id.VendorId:x4}\", ATTRS{{idProduct}}==\"{id.ProductId:x4}\"", + StringComparison.Ordinal + ) + ), Is.Empty ); } + + // A vendor-wide grant would also expose that vendor's keyboards to every program the user runs. + [Test] + public void The_rule_never_grants_a_whole_vendor() + { + var grants = RuleFile() + .Split('\n') + .Where(line => line.Contains("uaccess", StringComparison.Ordinal) && !line.StartsWith('#')); + + Assert.That( + grants.Where(line => !line.Contains("idProduct", StringComparison.Ordinal) && !line.Contains("KERNELS", StringComparison.Ordinal)), + Is.Empty + ); + } + + // The guide prints the rule one single-quoted line at a time (fish has no heredocs) instead of + // downloading it as root, so the two must not drift apart. + [Test] + public void The_setup_guide_carries_the_rule_verbatim() + { + var guide = RepositoryFile("docs", "linux-setup.md"); + var lines = RuleFile().TrimEnd('\n').Split('\n'); + + using (Assert.EnterMultipleScope()) + { + Assert.That(lines.Where(line => !guide.Contains($"'{line}'", StringComparison.Ordinal)), Is.Empty); + Assert.That(guide, Does.Not.Contain("curl")); + } + } } From 4defed060da13061c874abb59ee60e71877f2440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 11:55:24 +0200 Subject: [PATCH 11/12] fix: release the manifest version without a forced bump in make release --- Makefile | 56 ++++++++++++++++++++++++++++++++++--------------------- README.md | 5 +++-- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index e6d10bf..66e8ece 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,10 @@ SDK := grep -o 'MacroDeck.Sdk" Version="[^"]*' Directory.Packages.props | c TESTS := dotnet test DeviceBatteryInfo.slnx --configuration Release --filter "Category!=Hardware" RID := $(if $(filter Windows_NT,$(OS)),win-x64,$(if $(filter Linux,$(shell uname -s)),linux-x64,osx-arm64)) +# Shared by pack and release. release must not call $(MAKE): make runs such a line even under -n. +PACK := rm -f artifacts/*.macroDeckPlugin && \ + macrodeck-plugin build --source $(PROJECT) --rid $(RID) --output ./artifacts && \ + macrodeck-plugin inspect --artifact "$$(ls artifacts/*.macroDeckPlugin)" # Store images: every [UiPreview] scenario at each deck shape. Override on the command line, # e.g. make preview CELLS="--cells 2x2" PREVIEW_ARGS="--theme light". @@ -33,8 +37,8 @@ help: @echo "make pack build this platform's .macroDeckPlugin ($(RID)) into artifacts/ and inspect it" @echo "make conformance run the conformance suite, report in conformance.md" @echo "make update bump every package to its newest release (review the diff)" - @echo "make release VERSION=x.y.z" - @echo " test + pack, bump manifest.json, commit, tag vx.y.z, push" + @echo "make release [VERSION=x.y.z]" + @echo " test + pack, bump manifest.json only if VERSION differs, tag, push" cli: dotnet tool update --global MacroDeck.Plugin.Cli --version "$$($(SDK))" @@ -62,9 +66,7 @@ preview: $(UTF8) macrodeck-plugin preview render --project $(PROJECT) $(CELLS) --output $(PREVIEWS) $(PREVIEW_ARGS) pack: - rm -f artifacts/*.macroDeckPlugin - macrodeck-plugin build --source $(PROJECT) --rid $(RID) --output ./artifacts - macrodeck-plugin inspect --artifact "$$(ls artifacts/*.macroDeckPlugin)" + $(PACK) conformance: macrodeck-plugin test --project $(PROJECT) --report markdown --output conformance.md @@ -73,21 +75,33 @@ update: dotnet package update # Pushing the tag starts .github/workflows/release.yml, which checks the manifest version against the -# tag, creates the GitHub release and publishes to the Creator Portal. Everything that can fail runs -# before the bump commit, so a failed check leaves nothing to undo. +# tag, creates the GitHub release and publishes to the Creator Portal. Without VERSION the manifest's own +# version is released as is; a VERSION that differs is bumped first. Everything that can fail runs before +# the bump commit, so a failed check leaves nothing to undo. release: - @case "$(VERSION)" in \ + @set -e; \ + git pull --ff-only; \ + current="$$(sed -n 's/^ "version": "\(.*\)",$$/\1/p' $(MANIFEST))"; \ + version="$(VERSION)"; [ -n "$$version" ] || version="$$current"; \ + case "$$version" in \ [0-9]*.[0-9]*.[0-9]*) ;; \ - *) echo "usage: make release VERSION=x.y.z (current: $$(sed -n 's/^ "version": "\(.*\)",$$/\1/p' $(MANIFEST)))"; exit 1 ;; \ - esac - @test "$$(git rev-parse --abbrev-ref HEAD)" = main || { echo "release from main only"; exit 1; } - @test -z "$$(git status --porcelain)" || { echo "working tree is not clean"; exit 1; } - @! git rev-parse -q --verify "refs/tags/v$(VERSION)" >/dev/null || { echo "tag v$(VERSION) already exists"; exit 1; } - git pull --ff-only - $(TESTS) - $(MAKE) pack - sed -i 's/^ "version": ".*",$$/ "version": "$(VERSION)",/' $(MANIFEST) - @grep -q '^ "version": "$(VERSION)",$$' $(MANIFEST) || { echo "could not set the version in $(MANIFEST)"; git checkout -- $(MANIFEST); exit 1; } - git commit -m "chore: bump version" -- $(MANIFEST) - git tag v$(VERSION) - git push --atomic origin main v$(VERSION) + *) echo "usage: make release [VERSION=x.y.z] (manifest: $$current)"; exit 1 ;; \ + esac; \ + test "$$(git rev-parse --abbrev-ref HEAD)" = main || { echo "release from main only"; exit 1; }; \ + test -z "$$(git status --porcelain)" || { echo "working tree is not clean"; exit 1; }; \ + git fetch --tags --quiet origin; \ + ! git rev-parse -q --verify "refs/tags/v$$version" >/dev/null || { echo "tag v$$version already exists"; exit 1; }; \ + latest="$$(git tag -l 'v[0-9]*' | sed 's/^v//' | sort -V | tail -n 1)"; \ + if [ -n "$$latest" ] && [ "$$(printf '%s\n%s\n' "$$latest" "$$version" | sort -V | tail -n 1)" != "$$version" ]; then \ + echo "v$$version is not newer than the latest release v$$latest"; exit 1; \ + fi; \ + echo "releasing v$$version (latest release: v$${latest:-none}, manifest: $$current)"; \ + $(TESTS); \ + $(PACK); \ + if [ "$$version" != "$$current" ]; then \ + sed -i.bak 's/^ "version": ".*",$$/ "version": "'"$$version"'",/' $(MANIFEST); rm -f $(MANIFEST).bak; \ + grep -q "^ \"version\": \"$$version\",$$" $(MANIFEST) || { echo "could not set the version in $(MANIFEST)"; git checkout -- $(MANIFEST); exit 1; }; \ + git commit -m "chore: bump version to $$version" -- $(MANIFEST); \ + fi; \ + git tag "v$$version"; \ + git push --atomic origin main "v$$version" diff --git a/README.md b/README.md index 5a7b8e5..2f75029 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,9 @@ credential kept in `src/DeviceBatteryInfo/.macrodeck-dev-state/`), `make stub` a `make cli` keeps the CLI at the SDK's version, `make pack` builds and inspects the artifact for this machine's platform (`win-x64` on Windows, `linux-x64` on Linux, `osx-arm64` on a Mac; the release workflow builds all three), and -`make release VERSION=x.y.z` tests and packs, bumps `manifest.json`, commits, tags `vx.y.z` and pushes - -the tag starts the release workflow. On Windows it needs GNU make and Git Bash's `sh` on `PATH`. +`make release` tests and packs, then tags the version in `manifest.json` and pushes - the tag starts the +release workflow. `make release VERSION=x.y.z` does the same for another version, bumping and committing +`manifest.json` first. Either refuses a version that is not newer than the latest release tag. On Windows it needs GNU make and Git Bash's `sh` on `PATH`. ### Project layout From bf44090d1ce67eb1f726c0c227072061037a87ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sat, 3 Oct 2026 12:00:15 +0200 Subject: [PATCH 12/12] fix: compare the udev rule and guide line by line on CRLF checkouts --- tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs index c6ec963..fa67813 100644 --- a/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs +++ b/tests/DeviceBatteryInfo.Tests/LinuxSourceTests.cs @@ -308,7 +308,8 @@ private static string RepositoryFile(params string[] parts) var file = System.IO.Path.Combine([directory.FullName, .. parts]); if (File.Exists(file)) { - return File.ReadAllText(file); + // A Windows checkout ends lines with CRLF, which would leave a '\r' on every split line. + return File.ReadAllText(file).ReplaceLineEndings("\n"); } }