Skip to content

bluetooth: show per-peripheral battery for split devices (ZMK)#2752

Open
dwright134 wants to merge 2 commits into
AvengeMedia:masterfrom
dwright134:feat/split-keyboard-peripheral-battery
Open

bluetooth: show per-peripheral battery for split devices (ZMK)#2752
dwright134 wants to merge 2 commits into
AvengeMedia:masterfrom
dwright134:feat/split-keyboard-peripheral-battery

Conversation

@dwright134

@dwright134 dwright134 commented Jul 5, 2026

Copy link
Copy Markdown

Description

Split keyboards (ZMK Corne and similar) report the battery level of each half
separately
, but the Bluetooth detail view only ever showed one number.

The cause is that BlueZ's org.bluez.Battery1 interface — which
Quickshell.Bluetooth's BluetoothDevice.battery wraps — exposes a single
percentage per device. A split keyboard is one bonded device: the central half
holds the radio and relays the peripheral half's charge as an additional
GATT Battery Service (0x180F) on the same device object. BlueZ folds only one
of those into Battery1, so the second half was invisible.

This PR reads the extra 0x2A19 (Battery Level) characteristics directly over
D-Bus and surfaces them:

  • BluetoothService gains a peripheralBatteries map (keyed by device
    address) populated by a small sh/busctl Process. It walks the GATT tree
    of each connected device and emits one address|percent|label line per
    Battery Level characteristic that carries a 0x2901 user-description
    descriptor — which is exactly how ZMK tags peripheral batteries (e.g.
    "Peripheral 0"). The label is read verbatim from that descriptor, so it
    generalizes to N peripherals (Peripheral 0, Peripheral 1, …) and to
    multiple keyboards.
  • BluetoothDetail renders one extra entry per peripheral next to the
    existing battery text via a Repeater.

Design notes:

  • Purely additive. The central battery is deliberately skipped (it's
    already covered by Battery1/device.battery), so non-split devices render
    exactly as before — peripheralBatteriesFor() returns [] for them.
  • Association is structural, not heuristic. Every battery lives under the
    same /org/bluez/.../dev_XXX object path, so peripherals attach to the right
    device by construction — there is no name-matching that could go wrong.
  • Survives sleep. A split half drops its BLE link when idle; a live
    ReadValue then fails fast with "Not connected", so the reader falls back to
    BlueZ's cached Value (last known level) and best-effort StartNotify to keep
    that cache warm. Every busctl call is bounded with --timeout=5.
  • Refreshes on shell start, on connected changes, and every 120 s (guarded so
    runs can't overlap).

Type of change

  • New feature (non-breaking change that adds functionality)

Related issues

Screenshots / video

Bluetooth detail row for a ZMK Corne, before → after:

  • Before: Connected • 90%

  • After:

Screenshot from 2026-07-05 01-35-28

Checklist

  • My code follows the conventions in CONTRIBUTING.md (sh -c + busctl,
    matching existing Process usage in this service)
  • I have tested my changes locally (live on DMS v1.4.6 with a real ZMK Corne;
    central 90% + peripheral 83% render, clean shell reload, no QML warnings)
  • New user-facing strings: none new to translate — the format mirrors the
    existing (untranslated) battery/signal text ("• " + value + "%"); the
    label text comes from the device's own GATT descriptor
  • Go changes: n/a (QML-only)
  • QML changes: ran make lint-qml — please run in CI/devshell; my local Qt's
    qmllint couldn't parse pragma ComponentBehavior: Bound (bailed at line 2)
    so I could not get a clean local run
  • dlx-docs PR: happy to add docs if you'd like this documented

BlueZ's org.bluez.Battery1 (and therefore Quickshell's BluetoothDevice.battery)
exposes only a single battery percentage per device, so split keyboards such as
ZMK Corne boards only ever showed the central half's charge.

Such devices report each half's charge on a separate GATT Battery Level (0x2A19)
characteristic under the same device object; peripheral characteristics are
tagged with a 0x2901 user-description descriptor (e.g. "Peripheral 0"). Read
those extra characteristics over D-Bus (busctl) and expose them from
BluetoothService as `peripheralBatteries`, keyed by device address, then render
one entry per peripheral in the Bluetooth detail view next to the existing
central battery.

The central battery is intentionally skipped (it is already covered by
Battery1), keeping the change purely additive: non-split devices are unaffected.
Reads fall back to BlueZ's cached value when a half is asleep, so the last known
level keeps showing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dwright134 dwright134 marked this pull request as ready for review July 5, 2026 06:34
The peripheral (0x2901 user-description) label was read via `busctl
get-property ... Value` first, falling back to `ReadValue` only when the
result was empty. But busctl prints `ay 0` for an empty byte array, which
is a non-empty string, so the `[ -n "$lv" ]` guard never triggered the
ReadValue fallback.

BlueZ does not populate that descriptor's cached Value until something
issues a ReadValue on it, so on a cold cache (the normal state right after
connect) the label came back empty and the peripheral was dropped —
split keyboards showed only the central half again.

Read the label via ReadValue first when connected (matching how the
percentage is already read), then fall back to the cached Value, using an
`"ay "[1-9]*` check so the empty `ay 0` case is detected properly. This
also warms BlueZ's cache itself instead of depending on it already being
warm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dwright134

Copy link
Copy Markdown
Author

Pushed a follow-up fix (b59462f): the split-peripheral battery label was read from BlueZ's cached Value first, but busctl prints ay 0 for an empty byte array, so the [ -n "$lv" ] guard never fell back to ReadValue. On a cold cache (the normal state right after connect) the label came back empty and the peripheral half was dropped, so split keyboards still showed only the central battery.

Now the label is read via ReadValue first (matching how the percentage is read), falling back to the cached Value, which also warms BlueZ's cache instead of depending on it already being warm. Verified live on a ZMK Corne — both halves now show.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant