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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
122 changes: 95 additions & 27 deletions AGENTS.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
58 changes: 36 additions & 22 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ 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))
# 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".
Expand All @@ -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))"
Expand Down Expand Up @@ -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
Expand All @@ -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"
65 changes: 43 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -162,32 +177,38 @@ 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
`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`.
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` 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

```
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
Ui/ widget rendering, config view and preview scenarios
Actions/ the "Refresh battery levels" action
Localization/Strings.resx default-culture strings; Strings.<culture>.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
Expand Down Expand Up @@ -403,11 +424,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
10 changes: 7 additions & 3 deletions docs/adding-a-device.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down Expand Up @@ -62,7 +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.
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
Expand All @@ -87,7 +90,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 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`
methods (`BuildRequest`, `IsCompletedResponse`) so a test can check them without a device.
Expand Down
Loading
Loading