diff --git a/common/command_queue.py b/common/command_queue.py
new file mode 100644
index 000000000..d5ddf3c19
--- /dev/null
+++ b/common/command_queue.py
@@ -0,0 +1,124 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""Serialized background executor shared by the wifi and bluetooth managers.
+
+Commands run one at a time on a worker thread; results are delivered on the
+main thread via poll(). The manager it runs against is duck-typed to
+CommandContext, so neither subsystem has to know about the other."""
+
+import logging
+import queue
+import threading
+from abc import ABC, abstractmethod
+from typing import Any, Callable, Generic, Protocol, TypeVar
+
+T = TypeVar("T")
+
+
+class CommandContext(Protocol):
+ """What CommandQueue needs of the manager it executes against."""
+
+ def request_refresh(self) -> None: ...
+
+
+class Command(ABC, Generic[T]):
+ """A unit of serialized work. Deduped by key() — if a command with the
+ same key is pending or in-flight, a fresh submission is dropped."""
+
+ # Positional-only so subclasses are free to name (and narrow) the manager.
+ @abstractmethod
+ def run(self, ctx: Any, /) -> T: ...
+
+ @abstractmethod
+ def key(self) -> str: ...
+
+
+_SHUTDOWN_SENTINEL = object()
+
+
+class CommandQueue:
+ """Serialized executor over a manager. Worker thread runs Commands;
+ results are delivered on the main thread via poll(). Dedupes by key()."""
+
+ def __init__(self, ctx: CommandContext) -> None:
+ self._ctx = ctx
+ self._cmd_queue: queue.Queue = queue.Queue()
+ self._result_queue: queue.Queue = queue.Queue()
+ self._lock = threading.Lock()
+ self._pending_op_count = 0
+ self._pending_keys: set[str] = set()
+ self._worker = threading.Thread(target=self._drain, daemon=True)
+ self._worker.start()
+
+ def submit(self, cmd: "Command[T]", on_done: Callable[[T], None]) -> bool:
+ return self._enqueue(cmd, on_done, bumps_pending=True)
+
+ def submit_scan(self, cmd: "Command[T]", on_done: Callable[[T], None]) -> bool:
+ return self._enqueue(cmd, on_done, bumps_pending=False)
+
+ def _enqueue(self, cmd: Command, on_done: Callable, bumps_pending: bool) -> bool:
+ key = cmd.key()
+ with self._lock:
+ if key in self._pending_keys:
+ return False
+ self._pending_keys.add(key)
+ if bumps_pending:
+ self._pending_op_count += 1
+ self._cmd_queue.put((cmd, on_done, bumps_pending))
+ return True
+
+ def _drain(self) -> None:
+ while True:
+ item = self._cmd_queue.get()
+ if item is _SHUTDOWN_SENTINEL:
+ return
+ cmd, on_done, bumps_pending = item
+ try:
+ result = cmd.run(self._ctx)
+ except Exception as e:
+ logging.exception("Command failed: %s", cmd)
+ result = e
+ with self._lock:
+ self._pending_keys.discard(cmd.key())
+ if bumps_pending:
+ self._pending_op_count -= 1
+ if bumps_pending:
+ # Nudge the poller for fresh status — don't wait out the tick.
+ try:
+ self._ctx.request_refresh()
+ except Exception:
+ logging.exception("Status refresh request failed")
+ self._result_queue.put((on_done, result))
+
+ def poll(self) -> None:
+ assert threading.current_thread() is threading.main_thread(), "CommandQueue.poll() must run on the main thread"
+ while True:
+ try:
+ on_done, result = self._result_queue.get_nowait()
+ except queue.Empty:
+ return
+ try:
+ on_done(result)
+ except Exception:
+ logging.exception("Command result callback failed")
+
+ def pending_op_count(self) -> int:
+ with self._lock:
+ return self._pending_op_count
+
+ def shutdown(self) -> None:
+ self._cmd_queue.put(_SHUTDOWN_SENTINEL)
+ self._worker.join(timeout=2.0)
diff --git a/docs/bluetooth-menu.md b/docs/bluetooth-menu.md
new file mode 100644
index 000000000..e2ca219c7
--- /dev/null
+++ b/docs/bluetooth-menu.md
@@ -0,0 +1,445 @@
+# Bluetooth menu
+
+## Context
+
+The OS side of Bluetooth already works and is deliberately tiny. `pi-gen-pistomp`
+`feat/bluetooth` adds `bluez` + `Experimental = true` in `/etc/bluetooth/main.conf`
+(which activates bluez's built-in MIDI GATT plugin), an rfkill-unblock drop-in on
+`bluetooth.service`, and a `BLUETOOTH_ENABLED="false"` flag in `pistomp.conf`. Once a
+BLE-MIDI device is *connected*, bluez publishes an ALSA seq port and `jackd -X seq`
+bridges it into JACK automatically — mod-ui needs no help and pi-Stomp needs no MIDI
+code.
+
+Three things do not exist and are ours:
+
+- **No pairing agent is registered anywhere on the image.** With no `org.bluez.Agent1`
+ owner and `AlwaysPairable` unset, pairing cannot complete headlessly at all. This is
+ the load-bearing gap; everything else is UI.
+- **No trust or reconnect policy.** `[Policy] ReconnectUUIDs` doesn't cover the MIDI
+ UUID, so a device must be paired *and* trusted for bluez to auto-accept its
+ reconnection.
+- **No on/off control after first boot.** `BLUETOOTH_ENABLED` is consumed once by
+ `firstboot.sh`, which then renames itself to `firstboot.done`.
+
+Bluetooth hardware exists on **Pi 5 only** — `config.txt` gives the BT UART to DIN MIDI
+on Pi 3/4 via `dtoverlay=pi3-disable-bt`.
+
+Outcome: a phone-style pair/connect/forget menu for BLE-MIDI devices, plus HID input
+devices (presenters, page-turners, keyboards, mice) usable as wireless footswitches.
+
+## Decisions
+
+| # | Decision | Rationale |
+|---|---|---|
+| 1 | **D-Bus via `dbus-fast`**, added to `pyproject.toml` → uv venv | No pi-gen change for the Python side. Gives a persistent `org.bluez.Agent1` (required for headless pairing), `PropertiesChanged` signals for RSSI/Connected/Paired instead of polling, and clean `Pair`/`Connect` calls. We already run as root (`ps-run` uses sudo), so stock D-Bus policy suffices. |
+| 2 | **Reuse `CommandQueue`**; `dbus-fast` replaces only its worker *thread's* job | The queue's real work — serialize, dedupe by `key()`, drain results on the main thread — is still exactly what we want. Each `Command.run()` blocks on `asyncio.run_coroutine_threadsafe(coro, loop).result(timeout)`. `poll()`, `pending_op_count()`, exception-as-result and the whole test-fixture shape stay untouched. |
+| 3 | **Entry point: a row in the Network (WiFi) menu** | Same conditional-row idiom as "Wired Connection" → `EthernetMenu`. Toolbar stays at 4 tiles (it is full: 210/240/270/296 at 20px on a 320px screen). |
+| 4 | **No BT hardware → no row, no mention of Bluetooth anywhere** | Pi 3/4 users must not see a feature their board cannot have. |
+| 5 | **Menu owns enable/disable**; no `pistomp.conf` write-back | `systemctl enable --now bluetooth` persists on its own (it writes a symlink), so the firstboot flag never needs rewriting. Avoids touching the boot partition at runtime. |
+| 6 | **Nearby list shows only MIDI and HID devices** | Everything else (phones, laptops, beacons, fitness trackers) is noise we can do nothing with. Predicate in §"Device filter". |
+| 7 | **Pair + trust + connect is one action** | The three-way distinction is bluez's model, not the user's. Trust-on-pair is also what makes reconnection work at all (see Context). |
+| 8 | **HID devices emit MIDI CC, and arrows/wheel synthesize NAV** | CC emission means mod-ui's existing MIDI-learn maps them like any footswitch — no new config surface, and it is what the MIDI-learn axiom in `pistomp/input/README.md` prescribes. NAV synthesis lets a cheap presenter drive the entire menu system. |
+| 9 | **HID mappings live in `default_config*.yml`** | Same declarative shape as `footswitches:` / `encoders:`, including the `longpress: ` idiom. |
+
+## Cognitive-load design
+
+The information architecture is the WiFi menu with the nouns swapped — which is also
+what Android and iOS do, so it is the shape users already carry.
+
+```
+Bluetooth · EV-1-WL ← the title answers "is it working?"
+ EV-1-WL ✔ ▮▮▮ ← paired; ✔ = connected right now
+ R400 Presenter ← paired, out of range or disconnected
+ Nearby devices... ← drill-in; scans only while open
+ Turn Bluetooth off
+ ⬅
+```
+
+- Tap a paired row → connect. Long-press → `Disconnect / Forget` submenu.
+- Tap a nearby row → pair + trust + connect as one action, with in-row
+ `Pairing…` / `Connecting…` text (there is no BT toolbar tile to spin).
+- **An empty nearby list must read `No devices found. Put your device in pairing
+ mode.`** BLE-MIDI peripherals only advertise while discoverable; this one string
+ prevents more confusion than everything else in the feature combined.
+- Paired rows carry a type badge (`M` = MIDI, `I` = input) so "why isn't this doing
+ anything" has an on-screen answer.
+
+## Device filter
+
+A discovered device is shown iff it is already paired, **or** it has a real `Name`
+**and** any of:
+
+- `UUIDs` contains the BLE-MIDI service `03B80E5A-EDE8-4B33-A751-6CE34EC4C700`
+- `UUIDs` contains HID-over-GATT `0x1812` or BR/EDR HID `0x1124`
+- `Class` major device class is `0x05` (Peripheral)
+- `Appearance` is in the HID category (`0x03C0`–`0x03C4`)
+
+**Test `Name`, never `Alias`.** BlueZ populates `Alias` with a MAC-derived string
+(`D4-06-0F-EE-16-83`) when the device has no name, so `Alias` is *always* truthy and
+would let every beacon through. `Name` is absent on nameless devices — that is the
+discriminator.
+
+`Appearance` is the weakest arm: not one device in a live scan reported it. UUIDs are
+the reliable signal; keep the `Appearance` and `Class` arms as belt-and-braces for
+classic HID, but do not depend on them.
+
+## Verified on hardware
+
+Run against a Pi 5 (`Raspberry Pi 5 Model B Rev 1.0`, bluez 5.82-1.1+rpt1) with a BOSS
+EV-1-WL. Findings that shaped the design above:
+
+- **The MIDI plugin is compiled into Debian's bluez** (`profiles/midi/midi.c` and the
+ `snd_midi_event_*` ALSA symbols are present in the `bluetoothd` binary), and it is
+ gated on `Experimental = true` — with the stock config no MIDI profile registers.
+- **The filter predicate holds.** Unpaired and pre-pair, the EV-1-WL advertises
+ `03b80e5a-ede8-4b33-a751-6ce34ec4c700`, readable straight off `Device1.UUIDs`. A
+ scan of the surrounding area returned five other devices, of which zero were useful
+ (four nameless beacons, one Fitbit) — the unfiltered list would be pure noise.
+- **The Pi 5 BT radio comes up rfkill soft-blocked** (`/sys/class/rfkill/rfkill0/soft`
+ = 1), confirming the `feat/bluetooth` rfkill-unblock drop-in is load-bearing, not
+ defensive. Note `rfkill` the CLI is not installed on the current image.
+- **End-to-end chain confirmed.** After pair + trust + connect, `bluetoothd` published
+ ALSA seq client `133: 'EV-1-WL'` port `EV-1-WL Bluetooth`, which JACK's `-X seq`
+ bridged to `system:midi_capture_6` with alias `EV-1-WL:midi/capture_1`. Nothing in
+ pi-Stomp had to touch MIDI.
+
+### Discovery lifecycle (affects `ops.py`)
+
+**BlueZ purges every unpaired LE device object the instant discovery stops** — the
+`org.bluez.Device1` interface is removed and any subsequent `Pair()` fails with "not
+available". Two requirements follow:
+
+- The nearby list must hold `StartDiscovery()` open for as long as it is on screen, and
+ `Pair()` must be issued against a live object **while discovery is still running**.
+ Scan and pair cannot be independent serialized commands.
+- Unpaired rows can vanish between refreshes as a matter of course. The `_rows_sig`
+ rebuild path must treat disappearance as normal, never as an error.
+
+Paired devices persist in `/var/lib/bluetooth` and are unaffected.
+
+### Trust races the explicit pair
+
+Setting `Trusted = true` makes bluez attempt a **background auto-connect the moment the
+device is discovered**. If the UI then calls `Pair()`, that auto-connect is already
+in flight and bluez returns `org.bluez.Error.InProgress`. Observed repeatedly: the only
+pairings that succeeded were against a fresh, untrusted device.
+
+So: **pair first, trust second** — never trust a device we have not finished pairing.
+And treat `InProgress` as "an attempt is already running, wait for its result", not as a
+failure to surface to the user.
+
+### Addresses rotate; do not key state on MAC alone
+
+BLE devices using resolvable private addresses re-randomise regularly — a Fitbit in the
+room appeared under three different addresses (`74:9F:EF:44:A6:99`,
+`55:4F:14:89:F7:5F`, `43:9C:27:65:0F:DB`) across one session. A "known devices" store
+keyed purely on MAC will accumulate duplicates and fail to recognise returning devices.
+
+The EV-1-WL itself is safe (`AddressType=static` — a random *static* address, which does
+not rotate), and for bonded devices bluez resolves RPAs via the IRK. But the store
+should key on MAC **plus** name, treat a MAC change under the same name as the same
+device, and never present a raw MAC to the user as identity.
+
+### Some devices never bond — and that changes the model
+
+The EV-1-WL **refuses bonding at the protocol level**. From a `btmon` capture of the
+SMP exchange:
+
+```
+Authentication requirement: Bonding, MITM, SC, No Keypresses, CT2 (0x2d) ← BlueZ asks
+Authentication requirement: No bonding, No MITM, Legacy, No Keypresses (0x00) ← device refuses
+```
+
+An LTK is derived for the session, but the peer declared it must not be stored, so
+`Bonded: no` and a `/var/lib/bluetooth///info` containing only
+`[General]` and `[ConnectionParameters]` is *correct behaviour*, not a fault. This was
+confirmed independent of the client: it reproduces with the adapter `Pairable`, with
+`JustWorksRepairing = always`, and when driving `bluetoothctl` under a real pty (ruling
+out [bluez#748](https://github.com/bluez/bluez/issues/748), where piped stdin yields
+`store_hint 0` and keys silently aren't persisted — still a good reason to prefer a
+properly registered agent).
+
+The consequence is severe and drives the UI: **for a non-bonding device, a plain
+`Disconnect` sets `Paired` back to `no`.** There is no key, so there is nothing to be
+paired with. Every reconnection — after sleep, going out of range, a power cycle, or a
+`bluetoothd` restart — needs the device physically put back into pairing mode.
+
+So there are two device classes, and the menu must express both:
+
+| | Bonding device (most BLE-MIDI, HID) | Non-bonding device (EV-1-WL) |
+|---|---|---|
+| Survives disconnect | yes, stays paired | **no**, drops to unpaired |
+| Survives reboot | yes | no |
+| Reconnect | automatic, or a `Connect()` call | requires the physical pairing button |
+
+Design consequences:
+
+- **The paired list cannot come from bluez alone.** A non-bonding device vanishes from
+ `Paired` the moment it disconnects, so it would silently disappear from the menu. Keep
+ our own small "known devices" store (MAC → name, last connected) and render the root
+ list from the union of that and bluez's paired set.
+- **A known-but-disconnected row must say what to do**, not just "Disconnected" — e.g.
+ `EV-1-WL · press its pairing button`. Tapping it should start discovery and pair as
+ soon as it appears, so the user's only job is the button press.
+- **Do not promise auto-reconnect in the UI.** Attempt it (it works for bonding
+ devices), and fall back to the pairing-mode prompt when the device is gone.
+- Forget = remove from our store *and* `RemoveDevice` from bluez.
+
+## Architecture
+
+Three layers, mirroring `modalapi/wifi/` exactly.
+
+```
+modalapi/bluetooth/
+ __init__.py re-export surface, like modalapi/wifi/__init__.py
+ types.py BtDevice TypedDict, DeviceKind enum, parse_bluez_error()
+ bluez.py dbus-fast client owning an asyncio loop in its own thread
+ agent.py org.bluez.Agent1, NoInputNoOutput (Just Works, no prompts)
+ ops.py stateless verbs: scan/pair/trust/connect/disconnect/remove
+ manager.py BluetoothManager: adapter-state thread + CommandQueue + poll()
+ commands.py ScanCmd, PairCmd, ConnectCmd, DisconnectCmd, ForgetCmd, PowerCmd
+```
+
+**Threading.** `bluez.py` starts one asyncio thread hosting the dbus-fast connection,
+the agent, and the `InterfacesAdded` / `PropertiesChanged` subscriptions. Discovered
+state is accumulated into a lock-guarded dict. `BluetoothManager.poll()` runs on the
+main thread from `Modhandler.poll_bluetooth()` and does two things: drain the
+`CommandQueue` result queue, and publish a changed adapter/device snapshot through
+`on_status_change`. **No panel-stack mutation ever happens off the main thread** — the
+`assert threading.current_thread() is threading.main_thread()` in `CommandQueue.poll`
+stays.
+
+**Signals instead of polling.** Unlike WiFi, there is no 5s status poll: bluez pushes
+`PropertiesChanged`. The manager keeps a `changed` flag the same way `WifiManager` does,
+so the handler-side contract is identical.
+
+**The agent.** `NoInputNoOutput` — BLE MIDI is Just Works, so every pairing
+auto-accepts and the user sees zero passkey prompts. Registered once at manager start
+via `AgentManager1.RegisterAgent` + `RequestDefaultAgent`. We are central-only (bluez's
+`midi` plugin is a GATT *client*), so the adapter never needs to be discoverable —
+`Discoverable` stays false, which is also why an auto-accept agent is safe here.
+
+## UI
+
+```
+ui/bluetooth_menu.py BluetoothMenu — a plain controller, NOT a Panel
+```
+
+Modelled on `ui/wifi_menu.py`: holds `Optional[Menu]` refs, reaches the handler through
+a structural `_BluetoothHost` Protocol, pushes menus via `lcd.draw_selection_menu`, and
+copies these idioms verbatim:
+
+- `_rows_sig()` content signature so RSSI jitter never triggers a rebuild
+ (BT RSSI is noisier than WiFi's — this matters more here than there)
+- `_rerender_*()` pop-and-rebuild preserving the cursor by `label_key`
+- the `notify_status_change` modal guard (refuse to rebuild unless our menu is
+ `pstack.current`, so a rebuild can't yank a dialog out from under the user)
+- `MessageDialog` for every failure, success silent
+- `Spacer()` + `SignalBarsGlyph` rich rows for right-aligned RSSI bars
+- `PillGlyph` for the M/I type badge, as the wifi menu does for open networks
+
+`declare_bindings()` returns `()` — a settings menu is NAV-only, which is the
+documented default and the only thing v2 hardware has.
+
+Wiring:
+
+- `Lcd.__init__` (`pistomp/lcd320x240.py` ~L225) constructs `BluetoothMenu(self)`
+ next to `self.wifi_menu`
+- `WifiMenu._build_items` gains a conditional `"Bluetooth · >"` row beside
+ the existing `"Wired Connection"` row, gated on adapter presence
+- `Modhandler.poll_bluetooth()` next to `poll_wifi`, called from the `period % 200`
+ branch of `modalapistomp.py`
+
+## HID input
+
+```
+pistomp/hid_controller.py HidController(Controller) + HidDeviceWatcher
+```
+
+`ControlRef.id` already accepts `str` for footswitch-class identity (`"channel:CC"`
+today), so a HID button is `"hid::KEY_PAGEDOWN"` with **no schema change**. The
+controller fits the existing source/sink model: `poll_hw()` does a non-blocking read of
+`/dev/input/eventN` and packages a `SwitchEvent`, exactly as a GPIO footswitch does.
+
+No new dependency: a Linux input event is a fixed 24-byte struct on 64-bit, so
+`struct.unpack` beats pulling in `python-evdev` (a C extension with thin aarch64 wheel
+coverage). Open the fd `O_NONBLOCK` and drain it each tick.
+
+`HidDeviceWatcher` rescans `/dev/input/by-id/*` on the 2s poll to pick up hotplug when
+a device pairs or reconnects — no udev rule, no root-owned daemon.
+
+Config, alongside `footswitches:` and `encoders:`:
+
+```yaml
+ # hid_controllers:
+ # Bluetooth or USB HID devices — presenters, page-turners, keyboards, mice.
+ # key: The Linux keycode the device emits (required)
+ # midi_CC: CC sent on press; MIDI-learn it in MOD-UI (optional)
+ # nav: Synthesize a NAV event instead of / as well as CC
+ # longpress: Handler method on long press (optional)
+ hid_controllers:
+ - key: KEY_PAGEDOWN
+ midi_CC: 80
+ - key: KEY_PAGEUP
+ midi_CC: 81
+ longpress: previous_snapshot
+ - key: KEY_RIGHT
+ nav: right
+ - key: KEY_LEFT
+ nav: left
+ - key: KEY_ENTER
+ nav: click
+```
+
+Synthesizing NAV from a HID key does **not** violate the NAV axiom: the axiom forbids
+panels from *consuming* raw NAV events and forbids `cls=NAV` binding rows. A new
+physical *source* producing NAV events is the intended shape, and is worth real
+consideration for v2 hardware, where NAV is the only control.
+
+The schema in `pistomp/config.py` (the dict literal at L28) gains a `hid_controllers`
+array; `longpress` reuses the existing callback-name enum.
+
+## Implementation tree
+
+```
+pi-stomp/
+├── pyproject.toml + dbus-fast dep → run `uv sync`
+├── uv.lock regenerated
+├── docs/bluetooth-menu.md this file
+│
+├── modalapi/bluetooth/ NEW — mirrors modalapi/wifi/
+│ ├── __init__.py
+│ ├── types.py BtDevice, DeviceKind, parse_bluez_error
+│ ├── bluez.py dbus-fast client + asyncio thread
+│ ├── agent.py org.bluez.Agent1 (NoInputNoOutput)
+│ ├── ops.py scan/pair/trust/connect/disconnect/remove
+│ ├── commands.py Command subclasses (reuses wifi CommandQueue)
+│ └── manager.py BluetoothManager
+│
+├── ui/bluetooth_menu.py NEW — BluetoothMenu controller
+│
+├── pistomp/
+│ ├── hid_controller.py NEW — HidController + HidDeviceWatcher
+│ ├── config.py + hid_controllers schema (L28 dict)
+│ └── lcd320x240.py + self.bluetooth_menu (~L225)
+│
+├── ui/wifi_menu.py + conditional "Bluetooth · >" row
+├── modalapi/modhandler.py + poll_bluetooth(), + hid controller wiring
+├── modalapistomp.py + poll_bluetooth() in the period%200 branch
+├── emulator/stubs.py + StubBluetoothManager
+├── emulator/modhandler.py + swap in the stub
+├── setup/config_templates/
+│ └── default_config_pistomptre.yml + commented hid_controllers example
+│
+└── tests/
+ ├── test_bluetooth_manager.py NEW — ops/manager against a mocked bluez
+ ├── test_hid_controller.py NEW — struct decode, event synthesis
+ ├── v3/conftest.py + bluetooth_state fixture (inline queue shim)
+ ├── v3/test_bluetooth_menu.py NEW — snapshot suite
+ └── snapshots/v3/test_bluetooth_menu/ NEW baselines
+```
+
+Nothing in `pi-gen-pistomp` changes for the Python side.
+
+## What pi-gen-pistomp must provide
+
+Written to be implemented on a **fresh branch**; do not assume `feat/bluetooth`
+survives. Everything below was verified on a live Pi 5 running the current image.
+
+### Required
+
+1. **A `bluetooth.service` drop-in** at
+ `stage2/05-pistomp/files/services/pistomp.conf` →
+ `/etc/systemd/system/bluetooth.service.d/pistomp.conf`:
+
+ ```ini
+ # pi-stomp: -E activates bluez's BLE-MIDI GATT plugin (an experimental profile);
+ # without it no ALSA seq port is ever created. The Pi 5 BT radio boots
+ # rfkill soft-blocked, which leaves the adapter PowerState=off-blocked.
+ [Service]
+ ExecStartPre=-/bin/sh -c 'for f in /sys/class/rfkill/*; do [ "$(cat $f/type)" = bluetooth ] && echo 0 > $f/soft; done'
+ ExecStart=
+ ExecStart=/usr/libexec/bluetooth/bluetoothd -E
+ ```
+
+ This one file replaces three things `feat/bluetooth` used: the 374-line `main.conf`
+ copy, the `rfkill` package, and a separate rfkill drop-in. Verified: with
+ `main.conf` at stock and the radio deliberately re-blocked, the adapter still comes
+ up `Powered: yes` and experimental profiles still probe.
+
+2. **Remove the unconditional Bluetooth disable from `firstboot.sh`.** `main` currently
+ runs `systemctl disable --now bluetooth.service hciuart.service`. Bluetooth stays
+ off until that line goes.
+
+3. `bluez` — already installed on `main` (`stage2/01-sys-tweaks/00-packages`). Debian
+ trixie ships 5.82 built with the MIDI plugin; no rebuild, no pin.
+
+### Not needed — do not carry these over
+
+| `feat/bluetooth` change | Why not |
+|---|---|
+| `bluetooth-main.conf` (374 lines) | `main.conf` is a **dpkg conffile**; shipping a copy means owning it forever and taking a conffile conflict on every bluez upgrade. `-E` in the drop-in gets the same result with no ownership. |
+| `BLUETOOTH_ENABLED` in `pistomp.conf` | The menu owns enable/disable at runtime. The flag is also first-boot-only, so it silently does nothing when edited later — a trap, not a feature. |
+| `firstboot.sh` conditional enable/disable | Falls out with the flag. |
+| `rfkill` package (+ the `00-packages-nr` comment) | The drop-in's `ExecStartPre` unblocks via sysfs, keyed on `type` = `bluetooth` so it doesn't depend on the rfkill index. |
+| separate `bluetooth-rfkill-unblock.conf` | Folded into the single drop-in. |
+| anything touching `hciuart.service` | `pi-bluetooth` is not installed, so the unit does not exist and the call is a no-op swallowed by `|| true`. Pi 5 attaches BT over serdev (`hci_uart` + `btbcm`, `hci0 Bus: UART`). |
+| `debpkgs/mod-ui/debian/changelog` | Unrelated (session recording) — it rode along on the branch. |
+
+### Possibly required — pending the bonding investigation
+
+`JustWorksRepairing` and adapter `Pairable` may need to be set. `Pairable` is an
+adapter property the UI can set over D-Bus at runtime (preferred — no image change).
+`JustWorksRepairing` is `main.conf`-only, so if it proves necessary the cheapest form
+is a targeted `sed` of the packaged file during the build, not a wholesale copy.
+
+## Verification
+
+**Unit / snapshot** — `uv run pytest`, `uv run pyright` clean.
+
+Mirror the wifi suite's categories, which are the ones that caught real bugs there:
+multi-frame sagas via a deferred-callback list; scan pacing (`tick()` must not rescan
+while the root menu is open); parametrized error kinds; modal-safety (a status change
+must not close an open dialog); and pure-function tests of the filter predicate with no
+fixture. The `bluetooth_state` fixture copies `wifi_state`'s inline `CommandQueue` shim
+so callbacks fire synchronously and no worker thread runs under pytest.
+
+State the expected snapshot changes before running `--snapshot-update`: the WiFi root
+menu baselines gain a Bluetooth row and must be regenerated; everything else is new.
+
+**Device state left behind by the investigation** (pistomp.local, 2026-08-06). None of
+this is required by the implementation; it is recorded so it can be undone or
+reproduced:
+
+1. `echo 0 > /sys/class/rfkill/rfkill0/soft` — radio unblocked. Non-persistent.
+2. `systemctl start bluetooth` — `start`, not `enable`. Non-persistent.
+3. `/etc/bluetooth/main.conf` line 128 `Experimental = true` — **reverted** to the
+ stock `#Experimental = false`; experimental now comes from `-E` in the drop-in.
+4. `/etc/bluetooth/main.conf` line 104 `#JustWorksRepairing = never` →
+ `JustWorksRepairing = always`. **Persistent, no backup file.** Made no difference to
+ bonding; revert by restoring the comment unless it proves useful for other devices.
+5. `/etc/systemd/system/bluetooth.service.d/pistomp.conf` — the drop-in from
+ "What pi-gen-pistomp must provide". **Persistent.** This one we want to keep; it is
+ the thing being proposed for the image.
+6. Adapter set `Pairable: yes` (persists in `/var/lib/bluetooth//settings`).
+ The UI should set this explicitly over D-Bus rather than relying on it.
+7. The EV-1-WL pairing was removed at the end of testing — no device entry remains.
+
+**On hardware** (Pi 5 with `feat/bluetooth` flashed or `Experimental = true` set):
+
+1. `grep -c '^Experimental' /etc/bluetooth/main.conf` → 1, `systemctl is-active bluetooth`
+2. Menu → Network → Bluetooth. Turn on. Nearby devices → the EV-1-WL appears with an
+ `M` badge and RSSI bars, and **only** MIDI/HID devices are listed.
+3. Tap it → `Pairing…` → `Connecting…` → ✔. Confirm `aconnect -l` shows a new ALSA seq
+ client named for the device, that it appears as a JACK MIDI port, and that mod-ui
+ offers it for MIDI-learn.
+4. Power-cycle the pedal → it reconnects unprompted. **Run this early** — see "Open
+ risk: pairing without bonding". If it fails, paired rows need an explicit
+ "Reconnect" action.
+5. Long-press → Forget → it leaves the paired list and stops auto-connecting.
+6. Reboot → still paired, still auto-connects, `bluetooth.service` still enabled.
+7. Pair a presenter → `/dev/input/eventN` appears; its arrow keys scan the NAV
+ reticule and its click activates; a mapped key emits its CC and is MIDI-learnable in
+ mod-ui.
+8. Regression: on a Pi 3/4 image, the Network menu shows **no** Bluetooth row.
+```
diff --git a/emulator/modhandler.py b/emulator/modhandler.py
index eb70e2340..6105b97bb 100644
--- a/emulator/modhandler.py
+++ b/emulator/modhandler.py
@@ -31,7 +31,13 @@
from modalapi.pedalboard_monitor import FileChangeMonitor
from modalapi.websocket_bridge import AsyncWebSocketBridge
import pistomp.settings as Settings
-from emulator.stubs import StubEthernetManager, StubJackMute, StubWifiManager, VirtualAudiocard
+from emulator.stubs import (
+ StubBluetoothManager,
+ StubEthernetManager,
+ StubJackMute,
+ StubWifiManager,
+ VirtualAudiocard,
+)
class EmulatorModhandler(Modhandler):
@@ -55,6 +61,9 @@ def __init__(self, homedir):
self.root_uri = "http://127.0.0.1:18181/"
self.wifi_manager = StubWifiManager(on_status_change=self._on_wifi_status_change)
self.wifi_manager.poll()
+ self.bluetooth_manager.shutdown()
+ self.bluetooth_manager = StubBluetoothManager(on_status_change=self._on_bluetooth_status_change)
+ self.bluetooth_manager.poll()
# Replace the real EthernetManager (and its sysfs/systemctl polling
# thread) created by super().__init__() with the always-up stub.
diff --git a/emulator/stubs.py b/emulator/stubs.py
index ec1db5f86..95ae89886 100644
--- a/emulator/stubs.py
+++ b/emulator/stubs.py
@@ -17,6 +17,7 @@
VirtualAudiocard — in-memory audiocard; no ALSA/hardware access.
StubWifiManager — in-memory wifi; satisfies Mod/Modhandler's wifi_manager.
+StubBluetoothManager — in-memory bluetooth; no D-Bus, no bluez, no threads.
StubEthernetManager — pinned-up ethernet stub; no sysfs / systemctl / threads.
StubRelay — no-op relay; satisfies the Relay interface without GPIO.
"""
@@ -25,10 +26,12 @@
import time
from typing import Callable, Optional
+from modalapi.bluetooth import BtDevice, BtStatus, DeviceKind, KnownDevice
+from modalapi.bluetooth.manager import BluetoothManager
from modalapi.ethernet import EthernetManager
from modalapi.jack_mute import JackMute
from modalapi.wifi import SavedConnection, ScannedNetwork, WifiStatus
-from modalapi.wifi.commands import CommandQueue
+from common.command_queue import CommandQueue
from modalapi.wifi.manager import WifiManager
from pistomp.audiocard import Audiocard
import pistomp.relay
@@ -253,6 +256,167 @@ def delete_connection(self, name: str) -> Optional[bytes]:
return None
+class StubBluetoothManager(BluetoothManager):
+ """In-memory bluetooth manager; no D-Bus connection and no bluez.
+
+ Devices only become visible once discovery is running, mirroring the real
+ thing: bluez publishes unpaired LE objects during a scan and purges them
+ the moment it stops. 'Stubborn Speaker' is a tripwire — pairing it always
+ fails, so the menu's error path is reachable in the emulator."""
+
+ FAILING_NAME = "Stubborn Speaker"
+
+ _NEARBY: list[BtDevice] = [
+ BtDevice(
+ path="/org/bluez/hci0/dev_D4_06_0F_EE_16_83",
+ address="D4:06:0F:EE:16:83",
+ name="EV-1-WL",
+ kind=DeviceKind.MIDI,
+ paired=False,
+ connected=False,
+ trusted=False,
+ rssi=-52,
+ ),
+ BtDevice(
+ path="/org/bluez/hci0/dev_C8_3B_44_10_02_9A",
+ address="C8:3B:44:10:02:9A",
+ name="R400 Presenter",
+ kind=DeviceKind.INPUT,
+ paired=False,
+ connected=False,
+ trusted=False,
+ rssi=-71,
+ ),
+ BtDevice(
+ path="/org/bluez/hci0/dev_11_22_33_44_55_66",
+ address="11:22:33:44:55:66",
+ name=FAILING_NAME,
+ kind=DeviceKind.MIDI,
+ paired=False,
+ connected=False,
+ trusted=False,
+ rssi=-88,
+ ),
+ ]
+
+ def __init__(self, on_status_change: Optional[Callable[[BtStatus], None]] = None) -> None:
+ self.lock = threading.Lock()
+ self.settings = None
+ self.on_status_change = on_status_change
+ self.last_status: BtStatus = {}
+ self._last_sig: tuple = ()
+ self.changed: bool = True
+ self._enabled: bool = True
+ self._capable: bool = True
+ self._discovering: bool = False
+ self._known: list[KnownDevice] = []
+ self._devices: dict[str, BtDevice] = {}
+ self.queue: CommandQueue = CommandQueue(self)
+
+ # ----- overrides of the real manager's bluez-backed surface -----
+
+ @property
+ def supported(self) -> bool:
+ return True
+
+ @property
+ def capable(self) -> bool:
+ return self._capable
+
+ def status(self) -> BtStatus:
+ return BtStatus(
+ supported=True,
+ capable=self._capable,
+ enabled=self._enabled,
+ powered=self._enabled,
+ discovering=self._discovering,
+ connected=[d["name"] for d in self._devices.values() if d["connected"]],
+ )
+
+ def request_refresh(self) -> None:
+ with self.lock:
+ self.changed = True
+
+ def shutdown(self) -> None:
+ try:
+ self.queue.shutdown()
+ except Exception:
+ pass
+
+ def devices(self) -> list[BtDevice]:
+ return list(self._devices.values())
+
+ def known_devices(self) -> list[KnownDevice]:
+ return list(self._known)
+
+ def remember(self, device: BtDevice) -> None:
+ self._known = [k for k in self._known if k["address"] != device["address"]]
+ self._known.append(
+ KnownDevice(
+ address=device["address"],
+ name=device["name"],
+ kind=device["kind"].value,
+ last_connected=int(time.time()),
+ )
+ )
+
+ def forget(self, address: str, name: str) -> None:
+ self._known = [k for k in self._known if k["address"] != address]
+
+ def set_enabled(self, enabled: bool) -> Optional[str]:
+ self._enabled = enabled
+ if not enabled:
+ self._devices.clear()
+ self._discovering = False
+ self.request_refresh()
+ return None
+
+ def install_support(self) -> Optional[str]:
+ self._capable = True
+ self.request_refresh()
+ return None
+
+ def start_discovery(self) -> None:
+ self._discovering = True
+ for device in self._NEARBY:
+ self._devices.setdefault(device["address"], device.copy())
+ self.request_refresh()
+
+ def stop_discovery(self) -> None:
+ self._discovering = False
+ # Unpaired objects do not survive the end of a scan.
+ self._devices = {a: d for a, d in self._devices.items() if d["paired"]}
+ self.request_refresh()
+
+ def pair(self, device: BtDevice) -> None:
+ if device["name"] == self.FAILING_NAME:
+ raise RuntimeError("org.bluez.Error.AuthenticationFailed: stub refuses to pair")
+ live = self._devices.setdefault(device["address"], device.copy())
+ live["paired"] = True
+ live["trusted"] = True
+ live["connected"] = True
+ self.remember(live)
+ self.request_refresh()
+
+ def connect(self, device: BtDevice) -> None:
+ live = self._devices.setdefault(device["address"], device.copy())
+ live["connected"] = True
+ self.remember(live)
+ self.request_refresh()
+
+ def disconnect(self, device: BtDevice) -> None:
+ live = self._devices.get(device["address"])
+ if live is not None:
+ live["connected"] = False
+ live["paired"] = False # the EV-1-WL's non-bonding behaviour
+ self.request_refresh()
+
+ def remove(self, device: BtDevice) -> None:
+ self._devices.pop(device["address"], None)
+ self.forget(device["address"], device["name"])
+ self.request_refresh()
+
+
class StubEthernetManager(EthernetManager):
"""Pinned-up ethernet stub for the emulator.
diff --git a/modalapi/bluetooth/__init__.py b/modalapi/bluetooth/__init__.py
new file mode 100644
index 000000000..65e758fb7
--- /dev/null
+++ b/modalapi/bluetooth/__init__.py
@@ -0,0 +1,46 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+from .commands import (
+ ConnectCmd,
+ DisconnectCmd,
+ ForgetCmd,
+ InstallSupportCmd,
+ PairCmd,
+ PowerCmd,
+ StartDiscoveryCmd,
+ StopDiscoveryCmd,
+)
+from .manager import BluetoothManager
+from .types import BtDevice, BtStatus, DeviceKind, KnownDevice, device_kind, is_interesting, parse_bluez_error
+
+__all__ = [
+ "BluetoothManager",
+ "BtDevice",
+ "BtStatus",
+ "ConnectCmd",
+ "DeviceKind",
+ "DisconnectCmd",
+ "ForgetCmd",
+ "InstallSupportCmd",
+ "KnownDevice",
+ "PairCmd",
+ "PowerCmd",
+ "StartDiscoveryCmd",
+ "StopDiscoveryCmd",
+ "device_kind",
+ "is_interesting",
+ "parse_bluez_error",
+]
diff --git a/modalapi/bluetooth/agent.py b/modalapi/bluetooth/agent.py
new file mode 100644
index 000000000..df3334c95
--- /dev/null
+++ b/modalapi/bluetooth/agent.py
@@ -0,0 +1,77 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""org.bluez.Agent1, NoInputNoOutput.
+
+With no agent registered anywhere on the image, headless pairing cannot
+complete at all — bluez has nobody to ask. NoInputNoOutput selects Just Works,
+so every request auto-accepts and the user never sees a passkey prompt. Safe
+here only because we are central-only and never discoverable: nothing can
+solicit a pairing we did not initiate."""
+
+import logging
+
+from dbus_fast import DBusError
+from dbus_fast.annotations import DBusObjectPath, DBusStr, DBusUInt16, DBusUInt32
+from dbus_fast.service import ServiceInterface, dbus_method
+
+from .types import AGENT_IFACE
+
+_REJECTED = "org.bluez.Error.Rejected"
+
+
+class PairingAgent(ServiceInterface):
+ def __init__(self) -> None:
+ super().__init__(AGENT_IFACE)
+
+ @dbus_method()
+ def Release(self) -> None: # noqa: N802 — D-Bus method names are CamelCase
+ logging.debug("BT agent released")
+
+ @dbus_method()
+ def RequestAuthorization(self, device: DBusObjectPath) -> None: # noqa: N802
+ logging.debug("BT agent authorizing %s", device)
+
+ @dbus_method()
+ def AuthorizeService(self, device: DBusObjectPath, uuid: DBusStr) -> None: # noqa: N802
+ logging.debug("BT agent authorizing service %s on %s", uuid, device)
+
+ @dbus_method()
+ def RequestConfirmation(self, device: DBusObjectPath, passkey: DBusUInt32) -> None: # noqa: N802
+ logging.debug("BT agent confirming passkey for %s", device)
+
+ @dbus_method()
+ def DisplayPasskey( # noqa: N802
+ self, device: DBusObjectPath, passkey: DBusUInt32, entered: DBusUInt16
+ ) -> None:
+ logging.debug("BT passkey for %s: %s", device, passkey)
+
+ @dbus_method()
+ def DisplayPinCode(self, device: DBusObjectPath, pincode: DBusStr) -> None: # noqa: N802
+ logging.debug("BT pin for %s: %s", device, pincode)
+
+ # NoInputNoOutput never negotiates a passkey or PIN. If bluez asks anyway
+ # the device wants an input method we do not have — reject rather than guess.
+ @dbus_method()
+ def RequestPasskey(self, device: DBusObjectPath) -> DBusUInt32: # noqa: N802
+ raise DBusError(_REJECTED, "pi-Stomp has no keypad")
+
+ @dbus_method()
+ def RequestPinCode(self, device: DBusObjectPath) -> DBusStr: # noqa: N802
+ raise DBusError(_REJECTED, "pi-Stomp has no keypad")
+
+ @dbus_method()
+ def Cancel(self) -> None: # noqa: N802
+ logging.debug("BT agent request cancelled")
diff --git a/modalapi/bluetooth/bluez.py b/modalapi/bluetooth/bluez.py
new file mode 100644
index 000000000..b2931178d
--- /dev/null
+++ b/modalapi/bluetooth/bluez.py
@@ -0,0 +1,331 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""dbus-fast client for org.bluez, owning an asyncio loop in its own thread.
+
+Device state is accumulated from InterfacesAdded / PropertiesChanged into a
+lock-guarded dict, so callers never await anything: they read snapshot() and
+issue verbs through call(), which blocks the calling (worker) thread on the
+loop. Nothing here touches the panel stack."""
+
+import asyncio
+import logging
+import threading
+import time
+from typing import Any, Callable, Coroutine, Optional, TypeVar
+
+from dbus_fast import BusType, DBusError, Message, MessageType, Variant
+from dbus_fast.aio import MessageBus
+
+from .agent import PairingAgent
+from .types import (
+ ADAPTER_IFACE,
+ AGENT_MANAGER_IFACE,
+ AGENT_PATH,
+ BLUEZ_SERVICE,
+ BtDevice,
+ DEVICE_IFACE,
+ device_kind,
+ is_interesting,
+)
+
+T = TypeVar("T")
+
+_PROPS_IFACE = "org.freedesktop.DBus.Properties"
+_OM_IFACE = "org.freedesktop.DBus.ObjectManager"
+_BLUEZ_ROOT = "/org/bluez"
+_ADAPTER_WAIT_S = 10.0
+
+_MATCH_RULES = (
+ f"type='signal',sender='{BLUEZ_SERVICE}',interface='{_PROPS_IFACE}',member='PropertiesChanged'",
+ f"type='signal',sender='{BLUEZ_SERVICE}',interface='{_OM_IFACE}'",
+)
+
+
+def _unwrap(props: dict[str, Any]) -> dict[str, Any]:
+ return {k: v.value if isinstance(v, Variant) else v for k, v in props.items()}
+
+
+class BluezClient:
+ """Live view of org.bluez. start() is idempotent-ish and never raises —
+ a missing bus, missing bluez, or missing adapter all land as available=False,
+ which the UI reads as "this board has no Bluetooth"."""
+
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self._devices: dict[str, dict[str, Any]] = {}
+ self._adapter_props: dict[str, Any] = {}
+ self._adapter_path: Optional[str] = None
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
+ self._thread: Optional[threading.Thread] = None
+ self._bus: Optional[MessageBus] = None
+ self._agent: Optional[PairingAgent] = None
+ self._ready = threading.Event()
+ self._started = False
+ self._on_change: Optional[Callable[[], None]] = None
+
+ # ----- lifecycle -----
+
+ def start(self, on_change: Optional[Callable[[], None]] = None) -> bool:
+ """Bring up the loop thread and connect. Blocks until the first
+ GetManagedObjects lands (or setup fails). Returns available()."""
+ if self._started:
+ return self.available
+ self._started = True
+ self._on_change = on_change
+ self._ready.clear()
+ self._thread = threading.Thread(target=self._run_loop, name="bluez", daemon=True)
+ self._thread.start()
+ self._ready.wait(timeout=_ADAPTER_WAIT_S + 5.0)
+ if not self.available:
+ # Retryable: the user can turn Bluetooth off and on again rather
+ # than being stuck until the process restarts.
+ self._started = False
+ return self.available
+
+ def _run_loop(self) -> None:
+ loop = asyncio.new_event_loop()
+ self._loop = loop
+ asyncio.set_event_loop(loop)
+ try:
+ loop.run_until_complete(self._setup())
+ except Exception as e:
+ logging.info("Bluetooth unavailable: %s", e)
+ loop.close()
+ self._ready.set()
+ return
+ finally:
+ self._ready.set()
+ try:
+ loop.run_forever()
+ finally:
+ loop.close()
+
+ async def _setup(self) -> None:
+ bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
+ self._bus = bus
+
+ agent = PairingAgent()
+ self._agent = agent
+ bus.export(AGENT_PATH, agent)
+
+ for rule in _MATCH_RULES:
+ await bus.call(
+ Message(
+ destination="org.freedesktop.DBus",
+ path="/org/freedesktop/DBus",
+ interface="org.freedesktop.DBus",
+ member="AddMatch",
+ signature="s",
+ body=[rule],
+ )
+ )
+ bus.add_message_handler(self._on_signal)
+
+ # `systemctl --now` returns once the unit is started, but bluetoothd
+ # registers its adapter object a moment later. Wait for it rather than
+ # concluding the board has no radio.
+ deadline = time.monotonic() + _ADAPTER_WAIT_S
+ while True:
+ await self._refresh_objects()
+ if self._adapter_path is not None:
+ break
+ if time.monotonic() >= deadline:
+ raise RuntimeError("no bluetooth adapter")
+ await asyncio.sleep(0.25)
+ await self._register_agent()
+
+ async def _register_agent(self) -> None:
+ try:
+ await self._raw_call(
+ _BLUEZ_ROOT, AGENT_MANAGER_IFACE, "RegisterAgent", "os", [AGENT_PATH, "NoInputNoOutput"]
+ )
+ except DBusError as e:
+ if "AlreadyExists" not in str(e):
+ raise
+ await self._raw_call(_BLUEZ_ROOT, AGENT_MANAGER_IFACE, "RequestDefaultAgent", "o", [AGENT_PATH])
+
+ async def _refresh_objects(self) -> None:
+ body = await self._raw_call("/", _OM_IFACE, "GetManagedObjects")
+ objects: dict[str, dict[str, dict[str, Any]]] = body[0]
+ with self._lock:
+ self._devices.clear()
+ for path, ifaces in objects.items():
+ if ADAPTER_IFACE in ifaces and self._adapter_path is None:
+ self._adapter_path = path
+ self._adapter_props = _unwrap(ifaces[ADAPTER_IFACE])
+ if DEVICE_IFACE in ifaces:
+ self._devices[path] = _unwrap(ifaces[DEVICE_IFACE])
+
+ def stop(self) -> None:
+ """Tear down completely so start() can bring up a fresh connection.
+
+ Stopping bluetoothd destroys every object it published, so a client
+ that keeps its adapter path across a restart will issue calls against
+ a path that no longer exists."""
+ loop = self._loop
+ if loop is not None:
+ loop.call_soon_threadsafe(loop.stop)
+ if self._thread is not None:
+ self._thread.join(timeout=2.0)
+ self._loop = None
+ self._thread = None
+ self._bus = None
+ self._agent = None
+ self._started = False
+ self._ready.clear()
+ with self._lock:
+ self._devices.clear()
+ self._adapter_props.clear()
+ self._adapter_path = None
+
+ # ----- signals -----
+
+ def _on_signal(self, msg: Message) -> Optional[bool]:
+ if msg.message_type is not MessageType.SIGNAL:
+ return None
+ changed = False
+ if msg.interface == _OM_IFACE and msg.member == "InterfacesAdded":
+ path, ifaces = msg.body[0], msg.body[1]
+ if DEVICE_IFACE in ifaces:
+ with self._lock:
+ self._devices[path] = _unwrap(ifaces[DEVICE_IFACE])
+ changed = True
+ elif msg.interface == _OM_IFACE and msg.member == "InterfacesRemoved":
+ path, ifaces = msg.body[0], msg.body[1]
+ if DEVICE_IFACE in ifaces:
+ with self._lock:
+ changed = self._devices.pop(path, None) is not None
+ elif msg.interface == _PROPS_IFACE and msg.member == "PropertiesChanged":
+ iface, props = msg.body[0], _unwrap(msg.body[1])
+ path = msg.path or ""
+ with self._lock:
+ if iface == DEVICE_IFACE:
+ self._devices.setdefault(path, {}).update(props)
+ changed = True
+ elif iface == ADAPTER_IFACE and path == self._adapter_path:
+ self._adapter_props.update(props)
+ changed = True
+ if changed and self._on_change is not None:
+ try:
+ self._on_change()
+ except Exception:
+ logging.exception("Bluetooth change callback failed")
+ return None
+
+ # ----- reads -----
+
+ @property
+ def available(self) -> bool:
+ return self._adapter_path is not None
+
+ @property
+ def powered(self) -> bool:
+ with self._lock:
+ return bool(self._adapter_props.get("Powered"))
+
+ @property
+ def discovering(self) -> bool:
+ with self._lock:
+ return bool(self._adapter_props.get("Discovering"))
+
+ def snapshot(self) -> list[BtDevice]:
+ """Every device bluez currently knows, filtered to MIDI/HID/paired."""
+ with self._lock:
+ items = list(self._devices.items())
+ out: list[BtDevice] = []
+ for path, props in items:
+ if not is_interesting(props):
+ continue
+ rssi = props.get("RSSI")
+ out.append(
+ BtDevice(
+ path=path,
+ address=str(props.get("Address") or ""),
+ name=str(props.get("Name") or ""),
+ kind=device_kind(props),
+ paired=bool(props.get("Paired")),
+ connected=bool(props.get("Connected")),
+ trusted=bool(props.get("Trusted")),
+ rssi=int(rssi) if isinstance(rssi, int) else None,
+ )
+ )
+ return out
+
+ def device_props(self, path: str) -> dict[str, Any]:
+ with self._lock:
+ return dict(self._devices.get(path) or {})
+
+ def find_path(self, address: str) -> Optional[str]:
+ with self._lock:
+ for path, props in self._devices.items():
+ if str(props.get("Address") or "").upper() == address.upper():
+ return path
+ return None
+
+ # ----- calls -----
+
+ def call(self, coro: Coroutine[Any, Any, T], timeout: float = 30.0) -> T:
+ """Run a coroutine on the client's loop and block until it returns.
+ Called from CommandQueue's worker thread, never the main thread."""
+ loop = self._loop
+ if loop is None or not loop.is_running():
+ raise RuntimeError("bluetooth is not running")
+ return asyncio.run_coroutine_threadsafe(coro, loop).result(timeout)
+
+ async def _raw_call(
+ self, path: str, iface: str, member: str, signature: str = "", body: Optional[list] = None
+ ) -> list:
+ bus = self._bus
+ if bus is None:
+ raise RuntimeError("bluetooth is not connected")
+ reply = await bus.call(
+ Message(
+ destination=BLUEZ_SERVICE,
+ path=path,
+ interface=iface,
+ member=member,
+ signature=signature,
+ body=body or [],
+ )
+ )
+ if reply is None:
+ return []
+ if reply.message_type is MessageType.ERROR:
+ name = reply.error_name or "org.bluez.Error.Failed"
+ # bluez often replies with an empty body; keep the name in the text
+ # or the whole reason is lost by the time the UI formats it.
+ detail = str(reply.body[0]) if reply.body else ""
+ raise DBusError(name, "%s: %s" % (name, detail) if detail else name)
+ return reply.body
+
+ @property
+ def adapter_path(self) -> str:
+ path = self._adapter_path
+ if path is None:
+ raise RuntimeError("no bluetooth adapter")
+ return path
+
+ async def set_adapter_property(self, name: str, value: Variant) -> None:
+ await self._raw_call(self.adapter_path, _PROPS_IFACE, "Set", "ssv", [ADAPTER_IFACE, name, value])
+
+ async def set_device_property(self, path: str, name: str, value: Variant) -> None:
+ await self._raw_call(path, _PROPS_IFACE, "Set", "ssv", [DEVICE_IFACE, name, value])
+
+ async def device_call(self, path: str, member: str) -> None:
+ await self._raw_call(path, DEVICE_IFACE, member)
+
+ async def adapter_call(self, member: str, signature: str = "", body: Optional[list] = None) -> None:
+ await self._raw_call(self.adapter_path, ADAPTER_IFACE, member, signature, body)
diff --git a/modalapi/bluetooth/commands.py b/modalapi/bluetooth/commands.py
new file mode 100644
index 000000000..04de6979b
--- /dev/null
+++ b/modalapi/bluetooth/commands.py
@@ -0,0 +1,113 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Optional
+
+from common.command_queue import Command
+
+from .types import BtDevice
+
+if TYPE_CHECKING:
+ from .manager import BluetoothManager
+
+
+@dataclass
+class PowerCmd(Command[Optional[str]]):
+ enabled: bool
+
+ def run(self, mgr: "BluetoothManager") -> Optional[str]:
+ return mgr.set_enabled(self.enabled)
+
+ def key(self) -> str:
+ return "power"
+
+
+@dataclass
+class InstallSupportCmd(Command[Optional[str]]):
+ def run(self, mgr: "BluetoothManager") -> Optional[str]:
+ return mgr.install_support()
+
+ def key(self) -> str:
+ return "install_support"
+
+
+@dataclass
+class StartDiscoveryCmd(Command[None]):
+ """Opens discovery and returns — it does not block on results.
+
+ BlueZ purges every unpaired LE device object the moment discovery stops,
+ so discovery is held open for as long as the nearby list is on screen and
+ Pair() is issued against a live object while it is still running. That is
+ why this is not a blocking scan the way wifi's ScanCmd is."""
+
+ def run(self, mgr: "BluetoothManager") -> None:
+ mgr.start_discovery()
+
+ def key(self) -> str:
+ return "discovery"
+
+
+@dataclass
+class StopDiscoveryCmd(Command[None]):
+ def run(self, mgr: "BluetoothManager") -> None:
+ mgr.stop_discovery()
+
+ def key(self) -> str:
+ return "discovery"
+
+
+@dataclass
+class PairCmd(Command[None]):
+ device: BtDevice
+
+ def run(self, mgr: "BluetoothManager") -> None:
+ mgr.pair(self.device)
+
+ def key(self) -> str:
+ return f"pair:{self.device['address']}"
+
+
+@dataclass
+class ConnectCmd(Command[None]):
+ device: BtDevice
+
+ def run(self, mgr: "BluetoothManager") -> None:
+ mgr.connect(self.device)
+
+ def key(self) -> str:
+ return f"connect:{self.device['address']}"
+
+
+@dataclass
+class DisconnectCmd(Command[None]):
+ device: BtDevice
+
+ def run(self, mgr: "BluetoothManager") -> None:
+ mgr.disconnect(self.device)
+
+ def key(self) -> str:
+ return f"disconnect:{self.device['address']}"
+
+
+@dataclass
+class ForgetCmd(Command[None]):
+ device: BtDevice
+
+ def run(self, mgr: "BluetoothManager") -> None:
+ mgr.remove(self.device)
+
+ def key(self) -> str:
+ return f"forget:{self.device['address']}"
diff --git a/modalapi/bluetooth/manager.py b/modalapi/bluetooth/manager.py
new file mode 100644
index 000000000..4368975cd
--- /dev/null
+++ b/modalapi/bluetooth/manager.py
@@ -0,0 +1,272 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+import logging
+import os
+import threading
+import time
+from typing import Callable, Optional, Protocol
+
+from common.command_queue import CommandQueue
+
+from . import ops
+from .bluez import BluezClient
+from .types import BtDevice, BtStatus, DeviceKind, KnownDevice
+
+SETTING_KEY = "bluetooth.known_devices"
+HCI_SYSFS_DIR = os.path.join(os.sep, "sys", "class", "bluetooth")
+
+
+def has_adapter(sysfs_dir: str = HCI_SYSFS_DIR) -> bool:
+ try:
+ return any(name.startswith("hci") for name in os.listdir(sysfs_dir))
+ except OSError:
+ return False
+
+
+class SettingsStore(Protocol):
+ """The slice of pistomp.settings.Settings the known-device store needs."""
+
+ def get_setting(self, name: str) -> object: ...
+ def set_setting(self, name: str, value: object) -> None: ...
+
+
+class BluetoothManager:
+ """Owns the bluez client, the known-device store, and a CommandQueue.
+
+ Unlike wifi there is no periodic status poll: bluez pushes
+ PropertiesChanged, so `changed` is set from the client's callback and
+ drained by poll() on the main thread."""
+
+ def __init__(
+ self,
+ settings: Optional[SettingsStore] = None,
+ on_status_change: Optional[Callable[[BtStatus], None]] = None,
+ ) -> None:
+ self.lock: threading.Lock = threading.Lock()
+ self.settings: Optional[SettingsStore] = settings
+ self.on_status_change: Optional[Callable[[BtStatus], None]] = on_status_change
+ self.client: BluezClient = BluezClient()
+ self.last_status: BtStatus = {}
+ self._last_sig: tuple = ()
+ self.changed: bool = False
+ self._has_adapter: bool = has_adapter()
+ self._capable: bool = False
+ self._enabled: bool = False
+ self._probed: bool = False
+ self.queue: CommandQueue = CommandQueue(self)
+ self._start_thread = threading.Thread(target=self._startup, name="bt-start", daemon=True)
+ self._start_thread.start()
+
+ # ----- startup / status -----
+
+ def _startup(self) -> None:
+ """Probe the image's capability and connect to bluez. Both block, so
+ neither may run on the UI thread."""
+ if not self._has_adapter:
+ self._probed = True
+ return
+ self._capable = ops.bluetoothd_is_capable()
+ self._enabled = ops.service_enabled()
+ if self._enabled and self.client.start(on_change=self.request_refresh):
+ self.client.call(ops.power_on(self.client))
+ self._probed = True
+ self.request_refresh()
+
+ def request_refresh(self) -> None:
+ with self.lock:
+ self.changed = True
+
+ @property
+ def supported(self) -> bool:
+ """Hardware present. Pi 3/4 hand the BT UART to DIN MIDI via
+ dtoverlay=pi3-disable-bt, so no hci device is registered and no row is
+ ever shown. The node exists whether or not bluetoothd is running, which
+ is what lets the menu offer to turn Bluetooth on."""
+ return self._has_adapter
+
+ @property
+ def capable(self) -> bool:
+ return self._capable
+
+ def status(self) -> BtStatus:
+ devices = self.client.snapshot() if self.client.available else []
+ return BtStatus(
+ supported=self.supported,
+ capable=self._capable,
+ enabled=self._enabled,
+ powered=self.client.powered,
+ discovering=self.client.discovering,
+ connected=[d["name"] for d in devices if d["connected"]],
+ )
+
+ def poll(self) -> None:
+ """Main-thread tick: drain callbacks, publish a changed snapshot."""
+ self.queue.poll()
+ publish = False
+ with self.lock:
+ if self.changed:
+ self.changed = False
+ publish = True
+ if not publish:
+ return
+ status = self.status()
+ # Devices are not part of the published status, so the status alone
+ # cannot tell a new discovery from a repeat — dedupe on both.
+ sig = (tuple(sorted(status.items())), self._device_sig())
+ with self.lock:
+ if sig == self._last_sig:
+ return
+ self._last_sig = sig
+ self.last_status = status
+ if self.on_status_change is not None:
+ self.on_status_change(status)
+
+ def _device_sig(self) -> tuple:
+ """RSSI bucketed to the drawn bar count so jitter doesn't republish."""
+ return tuple(
+ sorted(
+ (d["address"], d["name"], d["paired"], d["connected"], None if d["rssi"] is None else d["rssi"] // 10)
+ for d in self.devices()
+ )
+ )
+
+ def shutdown(self) -> None:
+ try:
+ self.queue.shutdown()
+ except Exception:
+ pass
+ self.client.stop()
+
+ # ----- devices -----
+
+ def devices(self) -> list[BtDevice]:
+ return self.client.snapshot() if self.client.available else []
+
+ def known_devices(self) -> list[KnownDevice]:
+ if self.settings is None:
+ return []
+ raw = self.settings.get_setting(SETTING_KEY)
+ if not isinstance(raw, list):
+ return []
+ out: list[KnownDevice] = []
+ for item in raw:
+ if not isinstance(item, dict) or not item.get("address"):
+ continue
+ out.append(
+ KnownDevice(
+ address=str(item.get("address") or ""),
+ name=str(item.get("name") or ""),
+ kind=str(item.get("kind") or DeviceKind.OTHER.value),
+ last_connected=int(item.get("last_connected") or 0),
+ )
+ )
+ return out
+
+ def remember(self, device: BtDevice) -> None:
+ """Record a successful pairing. Keyed on address *and* name: BLE
+ resolvable private addresses re-randomise, so a new address under a
+ known name is the same device, not a second one."""
+ if self.settings is None:
+ return
+ entry = KnownDevice(
+ address=device["address"],
+ name=device["name"],
+ kind=device["kind"].value,
+ last_connected=int(time.time()),
+ )
+ kept = [
+ k
+ for k in self.known_devices()
+ if k["address"].upper() != entry["address"].upper() and not (k["name"] and k["name"] == entry["name"])
+ ]
+ self.settings.set_setting(SETTING_KEY, list(kept) + [entry])
+
+ def forget(self, address: str, name: str) -> None:
+ if self.settings is None:
+ return
+ kept = [
+ k
+ for k in self.known_devices()
+ if k["address"].upper() != address.upper() and not (name and k["name"] == name)
+ ]
+ self.settings.set_setting(SETTING_KEY, kept)
+
+ # ----- verbs, called from the queue's worker thread -----
+
+ def set_enabled(self, enabled: bool) -> Optional[str]:
+ if not enabled:
+ # Drop the connection before the daemon goes away, so nothing is
+ # left holding object paths that stop existing.
+ self.client.stop()
+ err = ops.enable_service() if enabled else ops.disable_service()
+ if err is not None:
+ logging.error("Bluetooth %s failed: %s", "enable" if enabled else "disable", err)
+ return err
+ self._enabled = enabled
+ if enabled and self.client.start(on_change=self.request_refresh):
+ self.client.call(ops.power_on(self.client))
+ self.request_refresh()
+ return None
+
+ def install_support(self) -> Optional[str]:
+ err = ops.install_support_package()
+ if err is None:
+ self._capable = ops.bluetoothd_is_capable()
+ self.request_refresh()
+ return err
+
+ def start_discovery(self) -> None:
+ if self.client.available:
+ self.client.call(ops.start_discovery(self.client))
+
+ def stop_discovery(self) -> None:
+ if self.client.available:
+ self.client.call(ops.stop_discovery(self.client))
+
+ def resolve_path(self, device: BtDevice) -> Optional[str]:
+ """A stored path can be stale — bluez purges unpaired LE objects the
+ moment discovery stops. Fall back to a fresh address lookup."""
+ if self.client.device_props(device["path"]):
+ return device["path"]
+ return self.client.find_path(device["address"])
+
+ def pair(self, device: BtDevice) -> None:
+ path = self.resolve_path(device)
+ if path is None:
+ raise RuntimeError("the device is no longer in range")
+ ops.pair_and_connect(self.client, path)
+ self.remember(device)
+
+ def connect(self, device: BtDevice) -> None:
+ path = self.resolve_path(device)
+ if path is None:
+ raise RuntimeError("the device is no longer in range")
+ ops.connect(self.client, path)
+ self.remember(device)
+
+ def disconnect(self, device: BtDevice) -> None:
+ path = self.resolve_path(device)
+ if path is not None:
+ ops.disconnect(self.client, path)
+
+ def remove(self, device: BtDevice) -> None:
+ path = self.resolve_path(device)
+ if path is not None:
+ try:
+ ops.remove(self.client, path)
+ except Exception:
+ logging.exception("RemoveDevice failed for %s", device["address"])
+ self.forget(device["address"], device["name"])
diff --git a/modalapi/bluetooth/ops.py b/modalapi/bluetooth/ops.py
new file mode 100644
index 000000000..aa7d56846
--- /dev/null
+++ b/modalapi/bluetooth/ops.py
@@ -0,0 +1,222 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""Stateless bluetooth verbs. Every function here blocks and must run on the
+CommandQueue worker thread, never the UI thread."""
+
+import asyncio
+import logging
+import subprocess
+import time
+from typing import Optional
+
+from dbus_fast import DBusError, Variant
+
+from .bluez import BluezClient
+
+SUPPORT_PACKAGE = "pistomp-bluetooth"
+SERVICE = "bluetooth.service"
+
+_PAIR_TIMEOUT_S = 45.0
+_CONNECT_TIMEOUT_S = 30.0
+_POLL_INTERVAL_S = 0.25
+_BUSY_RETRIES = 16
+_BUSY_RETRY_INTERVAL_S = 0.25
+
+
+def _run(args: list[str], timeout: int = 30, sudo: bool = False) -> tuple[int, str]:
+ # pi-Stomp runs as the `pistomp` user; anything that mutates system state
+ # needs sudo, as the wifi module's nmcli calls already do.
+ cmd = (["sudo", "-n"] if sudo else []) + args
+ try:
+ p = subprocess.run(cmd, capture_output=True, timeout=timeout)
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
+ return 1, str(e)
+ out = (p.stdout or b"").decode("utf-8", "replace") + (p.stderr or b"").decode("utf-8", "replace")
+ return p.returncode, out.strip()
+
+
+# ----- image capability -----
+
+
+def bluetoothd_is_capable() -> bool:
+ """True when bluetoothd will start with -E, which is what registers the
+ BLE-MIDI GATT profile. systemd reports the merged unit config even while
+ the service is disabled, so this answers from a cold start."""
+ rc, out = _run(["systemctl", "show", SERVICE, "-p", "ExecStart", "--value"], timeout=10)
+ if rc != 0:
+ return False
+ return has_experimental_flag(out)
+
+
+def has_experimental_flag(exec_start: str) -> bool:
+ """Scan a systemd ExecStart value for bluetoothd's experimental flag."""
+ for token in exec_start.replace(";", " ").split():
+ if token == "--experimental":
+ return True
+ # Short flags may be clustered ("-nE"); long options must not match.
+ if token.startswith("-") and not token.startswith("--") and "E" in token[1:]:
+ return True
+ return False
+
+
+def service_enabled() -> bool:
+ rc, out = _run(["systemctl", "is-enabled", SERVICE], timeout=10)
+ return rc == 0 and out.startswith("enabled")
+
+
+def enable_service() -> Optional[str]:
+ rc, out = _run(["systemctl", "enable", "--now", SERVICE], timeout=60, sudo=True)
+ return None if rc == 0 else out
+
+
+def disable_service() -> Optional[str]:
+ rc, out = _run(["systemctl", "disable", "--now", SERVICE], timeout=60, sudo=True)
+ return None if rc == 0 else out
+
+
+def install_support_package() -> Optional[str]:
+ """Fetch pistomp-bluetooth from the pistomp apt repo. Needs a network."""
+ rc, out = _run(["apt-get", "update"], timeout=180, sudo=True)
+ if rc != 0:
+ logging.warning("apt-get update failed: %s", out)
+ rc, out = _run(["apt-get", "install", "-y", SUPPORT_PACKAGE], timeout=300, sudo=True)
+ return None if rc == 0 else out
+
+
+# ----- adapter -----
+
+
+async def _set_adapter_flag(client: BluezClient, name: str) -> None:
+ """A freshly restarted bluetoothd answers Busy until the adapter finishes
+ initialising. That resolves on its own, so retry a bounded number of times
+ rather than putting a dialog in front of the user."""
+ for attempt in range(_BUSY_RETRIES + 1):
+ try:
+ await client.set_adapter_property(name, Variant("b", True))
+ return
+ except DBusError as e:
+ if "Busy" not in str(e) or attempt == _BUSY_RETRIES:
+ raise
+ await asyncio.sleep(_BUSY_RETRY_INTERVAL_S)
+
+
+async def power_on(client: BluezClient) -> None:
+ await _set_adapter_flag(client, "Powered")
+ # Pairable persists in the adapter's settings, but say it explicitly rather
+ # than inherit whatever a previous session left behind.
+ await _set_adapter_flag(client, "Pairable")
+
+
+async def start_discovery(client: BluezClient) -> None:
+ if client.discovering:
+ return
+ await client.adapter_call(
+ "SetDiscoveryFilter",
+ "a{sv}",
+ [{"Transport": Variant("s", "auto"), "DuplicateData": Variant("b", False)}],
+ )
+ try:
+ await client.adapter_call("StartDiscovery")
+ except DBusError as e:
+ if "InProgress" not in str(e):
+ raise
+
+
+async def stop_discovery(client: BluezClient) -> None:
+ if not client.discovering:
+ return
+ try:
+ await client.adapter_call("StopDiscovery")
+ except DBusError as e:
+ logging.debug("StopDiscovery: %s", e)
+
+
+# ----- devices -----
+
+
+def _wait_for_flag(client: BluezClient, path: str, flag: str, timeout: float) -> bool:
+ """Poll the signal-fed device dict until `flag` goes true. Used to ride out
+ org.bluez.Error.InProgress, which means an attempt is already running."""
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ props = client.device_props(path)
+ if not props:
+ return False # bluez purged the object — the device went away
+ if props.get(flag):
+ return True
+ time.sleep(_POLL_INTERVAL_S)
+ return False
+
+
+async def _pair(client: BluezClient, path: str) -> None:
+ await client.device_call(path, "Pair")
+
+
+def pair_and_connect(client: BluezClient, path: str) -> None:
+ """Pair, then trust, then connect — in that order.
+
+ Trusting first makes bluez auto-connect the moment the device is seen, and
+ that in-flight attempt makes our own Pair() return InProgress. Only a
+ fresh, untrusted device pairs reliably."""
+ props = client.device_props(path)
+ if not props:
+ raise RuntimeError("the device is no longer in range")
+
+ if not props.get("Paired"):
+ try:
+ client.call(_pair(client, path), timeout=_PAIR_TIMEOUT_S)
+ except DBusError as e:
+ text = str(e)
+ if "AlreadyExists" in text:
+ pass
+ elif "InProgress" in text:
+ if not _wait_for_flag(client, path, "Paired", _PAIR_TIMEOUT_S):
+ raise
+ else:
+ raise
+
+ # Trust is what lets bluez auto-accept this device's future reconnections;
+ # ReconnectUUIDs doesn't cover MIDI.
+ try:
+ client.call(client.set_device_property(path, "Trusted", Variant("b", True)))
+ except DBusError as e:
+ logging.warning("Couldn't trust %s: %s", path, e)
+
+ connect(client, path)
+
+
+def connect(client: BluezClient, path: str) -> None:
+ if client.device_props(path).get("Connected"):
+ return
+ try:
+ client.call(client.device_call(path, "Connect"), timeout=_CONNECT_TIMEOUT_S)
+ except DBusError as e:
+ if "InProgress" not in str(e):
+ raise
+ if not _wait_for_flag(client, path, "Connected", _CONNECT_TIMEOUT_S):
+ raise
+
+
+def disconnect(client: BluezClient, path: str) -> None:
+ client.call(client.device_call(path, "Disconnect"), timeout=_CONNECT_TIMEOUT_S)
+
+
+def remove(client: BluezClient, path: str) -> None:
+ try:
+ client.call(client.adapter_call("RemoveDevice", "o", [path]))
+ except DBusError as e:
+ if "DoesNotExist" not in str(e):
+ raise
diff --git a/modalapi/bluetooth/types.py b/modalapi/bluetooth/types.py
new file mode 100644
index 000000000..b6df5f51f
--- /dev/null
+++ b/modalapi/bluetooth/types.py
@@ -0,0 +1,139 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+from enum import Enum
+from typing import Any, Optional, TypedDict
+
+BLUEZ_SERVICE = "org.bluez"
+ADAPTER_IFACE = "org.bluez.Adapter1"
+DEVICE_IFACE = "org.bluez.Device1"
+AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
+AGENT_IFACE = "org.bluez.Agent1"
+AGENT_PATH = "/org/pistomp/bt_agent"
+
+MIDI_UUID = "03b80e5a-ede8-4b33-a751-6ce34ec4c700"
+HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb" # HID over GATT
+HID_UUID = "00001124-0000-1000-8000-00805f9b34fb" # BR/EDR HID
+PERIPHERAL_MAJOR_CLASS = 0x05
+APPEARANCE_HID_RANGE = range(0x03C0, 0x03C5)
+
+
+class DeviceKind(str, Enum):
+ MIDI = "midi"
+ INPUT = "input"
+ OTHER = "other"
+
+
+class BtDevice(TypedDict):
+ path: str # D-Bus object path — the handle Pair/Connect are issued against
+ address: str
+ name: str # Device1.Name; "" when the device advertises none
+ kind: DeviceKind
+ paired: bool
+ connected: bool
+ trusted: bool
+ rssi: Optional[int]
+
+
+class KnownDevice(TypedDict):
+ """Our own record of a device the user has paired at least once. Survives
+ bluez forgetting a non-bonding device the moment it disconnects."""
+
+ address: str
+ name: str
+ kind: str
+ last_connected: int
+
+
+class BtStatus(TypedDict, total=False):
+ supported: bool # an adapter exists on this board
+ capable: bool # bluetoothd is running with -E, so the MIDI profile registers
+ enabled: bool # bluetooth.service is enabled
+ powered: bool
+ discovering: bool
+ connected: list[str] # names of currently connected devices
+
+
+def _uuids(props: dict[str, Any]) -> set[str]:
+ raw = props.get("UUIDs") or []
+ return {str(u).lower() for u in raw}
+
+
+def device_kind(props: dict[str, Any]) -> DeviceKind:
+ """Classify a Device1 property dict. MIDI wins over INPUT — a device that
+ is both is here to make music."""
+ uuids = _uuids(props)
+ if MIDI_UUID in uuids:
+ return DeviceKind.MIDI
+ if HOG_UUID in uuids or HID_UUID in uuids:
+ return DeviceKind.INPUT
+ cls = props.get("Class")
+ if isinstance(cls, int) and (cls >> 8) & 0x1F == PERIPHERAL_MAJOR_CLASS:
+ return DeviceKind.INPUT
+ appearance = props.get("Appearance")
+ if isinstance(appearance, int) and appearance in APPEARANCE_HID_RANGE:
+ return DeviceKind.INPUT
+ return DeviceKind.OTHER
+
+
+def is_interesting(props: dict[str, Any]) -> bool:
+ """True for devices worth listing: anything already paired, or a *named*
+ MIDI/HID device.
+
+ Tests Name, never Alias. BlueZ fills Alias with a MAC-derived string for
+ nameless devices, so Alias is always truthy and would admit every beacon
+ in the room; absent Name is the only discriminator."""
+ if props.get("Paired"):
+ return True
+ if not props.get("Name"):
+ return False
+ return device_kind(props) is not DeviceKind.OTHER
+
+
+_ERRORS = {
+ "org.bluez.Error.AuthenticationFailed": "pairing failed",
+ "org.bluez.Error.AuthenticationRejected": "the device rejected pairing",
+ "org.bluez.Error.AuthenticationCanceled": "pairing was cancelled",
+ "org.bluez.Error.AuthenticationTimeout": "the device stopped responding",
+ "org.bluez.Error.ConnectionAttemptFailed": "couldn't connect — is it still in pairing mode?",
+ "org.bluez.Error.NotReady": "the Bluetooth adapter isn't ready",
+ "org.bluez.Error.NotAvailable": "the device is no longer in range",
+ "org.bluez.Error.DoesNotExist": "the device is no longer in range",
+ "org.bluez.Error.NotSupported": "this device isn't supported",
+ "org.bluez.Error.InProgress": "already connecting",
+ "org.bluez.Error.Busy": "the adapter is busy — try again in a moment",
+ "org.bluez.Error.NotPermitted": "not permitted",
+ "org.bluez.Error.NotAuthorized": "not authorized",
+}
+
+
+def parse_bluez_error(err: object) -> str:
+ """Map a D-Bus error (or any exception) to a short user-facing reason."""
+ if err is None:
+ return "unknown error"
+ text = str(err)
+ for name, message in _ERRORS.items():
+ if name in text:
+ return message
+ lower = text.lower()
+ if "not available" in lower or "unknownobject" in lower or "no such" in lower:
+ return "the device is no longer in range"
+ if "in progress" in lower or "inprogress" in lower:
+ return "already connecting"
+ if "timeout" in lower or "timed out" in lower:
+ return "timed out"
+ # "br-connection-page-timeout" and friends: bluez's own hint is the useful part.
+ tail = text.rsplit(":", 1)[-1].strip()
+ return (tail or text)[:80] or "unknown error"
diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py
index ff7367359..cafc5766a 100755
--- a/modalapi/modhandler.py
+++ b/modalapi/modhandler.py
@@ -64,6 +64,7 @@
import modalapi.pedalboard as Pedalboard
from modalapi.pedalboard import BPM_SYMBOL, BPB_SYMBOL, ROLLING_SYMBOL
import modalapi.wifi as Wifi
+import modalapi.bluetooth as Bluetooth
# Importing the plugins package runs every plugin module's register() — this is
# the explicit, deterministic load of the customization registry. lookup is then
@@ -171,6 +172,7 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data")
self._encoder_fallback: dict[str, int] = {}
self.wifi_status: Wifi.WifiStatus = {}
+ self.bluetooth_status: Bluetooth.BtStatus = {}
self.eq_status = {}
self.SystemState = "unknown"
self.throttled = "unknown"
@@ -212,6 +214,9 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data")
self._sync_setter = SyncModeSetter(self.root_uri, self._rest_post)
self.wifi_manager = Wifi.WifiManager(on_status_change=self._on_wifi_status_change)
+ self.bluetooth_manager = Bluetooth.BluetoothManager(
+ settings=self.settings, on_status_change=self._on_bluetooth_status_change
+ )
self.ethernet_manager = EthernetManager()
self.jack_mute = JackMute()
@@ -603,6 +608,11 @@ def poll_wifi(self):
if self._lcd is not None and self.lcd.wifi_menu is not None:
self.lcd.wifi_menu.tick()
+ def poll_bluetooth(self):
+ self.bluetooth_manager.poll()
+ if self._lcd is not None and self.lcd.bluetooth_menu is not None:
+ self.lcd.bluetooth_menu.tick()
+
def poll_ethernet(self):
if self._lcd is None:
return
@@ -624,6 +634,15 @@ def _on_wifi_status_change(self, status):
if self.lcd.wifi_menu is not None:
self.lcd.wifi_menu.notify_status_change()
+ def _on_bluetooth_status_change(self, status):
+ self.bluetooth_status = status
+ if self._lcd is not None:
+ # The wifi root menu carries the Bluetooth row, so it repaints too.
+ if self.lcd.wifi_menu is not None:
+ self.lcd.wifi_menu.notify_status_change()
+ if self.lcd.bluetooth_menu is not None:
+ self.lcd.bluetooth_menu.notify_status_change()
+
def poll_system_info(self):
# Get the system state from the systemd service
try:
diff --git a/modalapi/wifi/__init__.py b/modalapi/wifi/__init__.py
index fe5a0e2c7..89c6a5d72 100644
--- a/modalapi/wifi/__init__.py
+++ b/modalapi/wifi/__init__.py
@@ -13,9 +13,9 @@
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see .
+from common.command_queue import Command, CommandQueue
+
from .commands import (
- Command,
- CommandQueue,
ConnectSavedCmd,
ConnectScannedCmd,
DisconnectCmd,
diff --git a/modalapi/wifi/commands.py b/modalapi/wifi/commands.py
index ae5547821..c3fc68f26 100644
--- a/modalapi/wifi/commands.py
+++ b/modalapi/wifi/commands.py
@@ -13,29 +13,14 @@
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see .
-import logging
-import queue
-import threading
-from abc import ABC, abstractmethod
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar
+from typing import TYPE_CHECKING, Optional
+
+from common.command_queue import Command
if TYPE_CHECKING:
from .manager import WifiManager
-T = TypeVar("T")
-
-
-class Command(ABC, Generic[T]):
- """A unit of serialized work. Deduped by key() — if a command with the
- same key is pending or in-flight, a fresh submission is dropped."""
-
- @abstractmethod
- def run(self, wm: Any) -> T: ...
-
- @abstractmethod
- def key(self) -> str: ...
-
@dataclass
class ConnectSavedCmd(Command[Optional[bytes]]):
@@ -122,81 +107,3 @@ def run(self, wm: "WifiManager") -> list:
def key(self) -> str:
return "scan"
-
-
-_SHUTDOWN_SENTINEL = object()
-
-
-class CommandQueue:
- """Serialized executor over a WifiManager. Worker thread runs Commands;
- results are delivered on the main thread via poll(). Dedupes by key()."""
-
- def __init__(self, wm: "WifiManager") -> None:
- self._wm = wm
- self._cmd_queue: queue.Queue = queue.Queue()
- self._result_queue: queue.Queue = queue.Queue()
- self._lock = threading.Lock()
- self._pending_op_count = 0
- self._pending_keys: set[str] = set()
- self._worker = threading.Thread(target=self._drain, daemon=True)
- self._worker.start()
-
- def submit(self, cmd: "Command[T]", on_done: Callable[[T], None]) -> bool:
- return self._enqueue(cmd, on_done, bumps_pending=True)
-
- def submit_scan(self, cmd: "Command[T]", on_done: Callable[[T], None]) -> bool:
- return self._enqueue(cmd, on_done, bumps_pending=False)
-
- def _enqueue(self, cmd: Command, on_done: Callable, bumps_pending: bool) -> bool:
- key = cmd.key()
- with self._lock:
- if key in self._pending_keys:
- return False
- self._pending_keys.add(key)
- if bumps_pending:
- self._pending_op_count += 1
- self._cmd_queue.put((cmd, on_done, bumps_pending))
- return True
-
- def _drain(self) -> None:
- while True:
- item = self._cmd_queue.get()
- if item is _SHUTDOWN_SENTINEL:
- return
- cmd, on_done, bumps_pending = item
- try:
- result = cmd.run(self._wm)
- except Exception as e:
- logging.exception("Command failed: %s", cmd)
- result = e
- with self._lock:
- self._pending_keys.discard(cmd.key())
- if bumps_pending:
- self._pending_op_count -= 1
- if bumps_pending:
- # Nudge the poller for fresh status — don't wait out the 5s tick.
- try:
- self._wm.request_refresh()
- except Exception:
- logging.exception("Status refresh request failed")
- self._result_queue.put((on_done, result))
-
- def poll(self) -> None:
- assert threading.current_thread() is threading.main_thread(), "CommandQueue.poll() must run on the main thread"
- while True:
- try:
- on_done, result = self._result_queue.get_nowait()
- except queue.Empty:
- return
- try:
- on_done(result)
- except Exception:
- logging.exception("Wifi result callback failed")
-
- def pending_op_count(self) -> int:
- with self._lock:
- return self._pending_op_count
-
- def shutdown(self) -> None:
- self._cmd_queue.put(_SHUTDOWN_SENTINEL)
- self._worker.join(timeout=2.0)
diff --git a/modalapi/wifi/manager.py b/modalapi/wifi/manager.py
index 5b56becbf..d2ea43dc4 100644
--- a/modalapi/wifi/manager.py
+++ b/modalapi/wifi/manager.py
@@ -18,8 +18,9 @@
import threading
from typing import Callable, Optional
+from common.command_queue import CommandQueue
+
from . import ops
-from .commands import CommandQueue
from .nmcli import nmcli, parse_kv_lines
from .types import SavedConnection, ScannedNetwork, WifiStatus
diff --git a/modalapistomp.py b/modalapistomp.py
index 70f40132d..1e5881e48 100755
--- a/modalapistomp.py
+++ b/modalapistomp.py
@@ -219,6 +219,7 @@ def main():
handler.poll_modui_changes()
if period % 200 == 0:
handler.poll_wifi()
+ handler.poll_bluetooth()
handler.poll_ethernet()
if period > 6000: # every 60 seconds (when sleep = 0.01)
handler.poll_system_info()
diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py
index 03e5f6929..99fc48c86 100644
--- a/pistomp/lcd320x240.py
+++ b/pistomp/lcd320x240.py
@@ -26,6 +26,7 @@
from common.contexts import BindingDecl, ControlClass, EventKind, MidiCcEffect, ParamEffect, ShadowState
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol, Type
from modalapi.plugin import Plugin
+from ui.bluetooth_menu import BluetoothMenu
from ui.ethernet_menu import EthernetMenu
from ui.wifi_menu import WifiMenu
from common.color import accent_color_for, TILE_DEFAULT_COLOR
@@ -223,6 +224,7 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe
# Constructed here (not with ethernet_menu above) because WifiMenu needs
# the PanelStack, which is created earlier in this block.
self.wifi_menu: WifiMenu = WifiMenu(self)
+ self.bluetooth_menu: BluetoothMenu = BluetoothMenu(self)
if not display.has_system_splash:
self.splash_show(True)
@@ -407,7 +409,7 @@ def draw_tools(self, wifi_type=None, eq_type=None, bypass_type=None, system_type
image=os.path.join(self.imagedir, "wifi_gray.png"),
parent=self.main_panel,
action=self.wifi_menu.open,
- subtitle="Network",
+ subtitle="Wi-Fi and Devices",
)
self.main_panel.add_sel_widget(self.w_wifi)
if self.w_eq is not None:
@@ -546,7 +548,16 @@ def draw_preset_menu(self, event, widget):
self.draw_selection_menu(items, "Snapshots", auto_dismiss=True, dismiss_option=True)
def draw_selection_menu(
- self, items, title="", auto_dismiss=False, dismiss_option=False, font=None, title_font=None, default_item=None
+ self,
+ items,
+ title="",
+ auto_dismiss=False,
+ dismiss_option=False,
+ font=None,
+ title_font=None,
+ default_item=None,
+ width=None,
+ footer=None,
):
# items is a list of tuples: (label, callback, arg) or (label, callback, arg, is_active)
# or (label, callback, arg, is_active, long_callback) where long_callback is called
@@ -570,7 +581,8 @@ def menu_action(event, params):
items=items,
auto_destroy=True,
default_item=default_item,
- max_width=180,
+ width=width,
+ footer=footer,
max_height=200,
auto_dismiss=auto_dismiss,
dismiss_option=dismiss_option,
diff --git a/pyproject.toml b/pyproject.toml
index 486da89a3..3a8e9aed0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,7 @@ dependencies = [
"gpiozero>=2.0; sys_platform == 'linux'",
"pygame-ce>=2.5.7",
"qrcode>=8.0",
+ "dbus-fast>=2.21",
]
[project.optional-dependencies]
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 36b6fc5df..cbdf03c5d 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -58,12 +58,19 @@ def _build_stack(
patch("pistomp.settings.Settings") as mock_settings_cls,
patch("modalapi.pedalboard.Pedalboard.hydrate"),
patch("modalapi.wifi.WifiManager") as mock_wm_cls,
+ patch("modalapi.bluetooth.BluetoothManager") as mock_bt_cls,
patch("subprocess.check_output", return_value=b"SystemState=running"),
patch("pistomp.lcd320x240.LcdIli9341", return_value=fake_lcd),
patch("modalapi.modhandler.AsyncWebSocketBridge", return_value=fake_bridge),
):
# Tests don't drive a poll loop, so stub pending_op_count to always return 0 (no pending ops).
mock_wm_cls.return_value.queue.pending_op_count.return_value = 0
+ mock_bt_cls.return_value.queue.pending_op_count.return_value = 0
+ # MagicMock would auto-truthify `supported` and surface the Bluetooth
+ # row in every wifi-menu snapshot. Pin it off; bluetooth_state opts in.
+ mock_bt_cls.return_value.supported = False
+ mock_bt_cls.return_value.devices.return_value = []
+ mock_bt_cls.return_value.known_devices.return_value = []
def get_side_effect(url, **kwargs):
resp = MagicMock()
diff --git a/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/wifi_menu.png b/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/wifi_menu.png
index c3894f3dc..3245f2ae2 100644
Binary files a/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/wifi_menu.png and b/tests/snapshots/test_lcd320x240/test_wifi_menu_snapshot/wifi_menu.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_empty_nearby_list_names_pairing_mode/nearby_empty.png b/tests/snapshots/v3/test_bluetooth_menu/test_empty_nearby_list_names_pairing_mode/nearby_empty.png
new file mode 100644
index 000000000..2dc79ef18
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_empty_nearby_list_names_pairing_mode/nearby_empty.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_hid_device_carries_an_input_badge/root_hid_badge.png b/tests/snapshots/v3/test_bluetooth_menu/test_hid_device_carries_an_input_badge/root_hid_badge.png
new file mode 100644
index 000000000..6dfbb6ca1
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_hid_device_carries_an_input_badge/root_hid_badge.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_incapable_image_offers_the_package_install/root_needs_package.png b/tests/snapshots/v3/test_bluetooth_menu/test_incapable_image_offers_the_package_install/root_needs_package.png
new file mode 100644
index 000000000..37f39cc45
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_incapable_image_offers_the_package_install/root_needs_package.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_known_but_absent_device_says_what_to_do/root_known_absent.png b/tests/snapshots/v3/test_bluetooth_menu/test_known_but_absent_device_says_what_to_do/root_known_absent.png
new file mode 100644
index 000000000..eed9efe91
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_known_but_absent_device_says_what_to_do/root_known_absent.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_nearby_lists_only_unpaired_devices/nearby_list.png b/tests/snapshots/v3/test_bluetooth_menu/test_nearby_lists_only_unpaired_devices/nearby_list.png
new file mode 100644
index 000000000..65ad59547
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_nearby_lists_only_unpaired_devices/nearby_list.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_pair_failure_snapshot/pair_failed_dialog.png b/tests/snapshots/v3/test_bluetooth_menu/test_pair_failure_snapshot/pair_failed_dialog.png
new file mode 100644
index 000000000..23fa0f1cd
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_pair_failure_snapshot/pair_failed_dialog.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_pairing_shows_progress_then_settles/pairing_done.png b/tests/snapshots/v3/test_bluetooth_menu/test_pairing_shows_progress_then_settles/pairing_done.png
new file mode 100644
index 000000000..f78a8265a
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_pairing_shows_progress_then_settles/pairing_done.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_pairing_shows_progress_then_settles/pairing_in_flight.png b/tests/snapshots/v3/test_bluetooth_menu/test_pairing_shows_progress_then_settles/pairing_in_flight.png
new file mode 100644
index 000000000..496ae933c
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_pairing_shows_progress_then_settles/pairing_in_flight.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_root_menu_lists_paired_device/root_connected.png b/tests/snapshots/v3/test_bluetooth_menu/test_root_menu_lists_paired_device/root_connected.png
new file mode 100644
index 000000000..1ca00ca86
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_root_menu_lists_paired_device/root_connected.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_root_menu_when_off_offers_only_power_on/root_off.png b/tests/snapshots/v3/test_bluetooth_menu/test_root_menu_when_off_offers_only_power_on/root_off.png
new file mode 100644
index 000000000..756bbd39e
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_root_menu_when_off_offers_only_power_on/root_off.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_wifi_menu_footer_without_connection/wifi_bt_none_connected.png b/tests/snapshots/v3/test_bluetooth_menu/test_wifi_menu_footer_without_connection/wifi_bt_none_connected.png
new file mode 100644
index 000000000..efba17484
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_wifi_menu_footer_without_connection/wifi_bt_none_connected.png differ
diff --git a/tests/snapshots/v3/test_bluetooth_menu/test_wifi_menu_many_saved_with_bluetooth_connected/wifi_many_saved_bt_connected.png b/tests/snapshots/v3/test_bluetooth_menu/test_wifi_menu_many_saved_with_bluetooth_connected/wifi_many_saved_bt_connected.png
new file mode 100644
index 000000000..53a61bbf6
Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_wifi_menu_many_saved_with_bluetooth_connected/wifi_many_saved_bt_connected.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_dialog_cancel_returns_to_menu/nearby_after_cancel.png b/tests/snapshots/v3/test_wifi_menu/test_dialog_cancel_returns_to_menu/nearby_after_cancel.png
index 23ac64af4..36924c7c8 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_dialog_cancel_returns_to_menu/nearby_after_cancel.png and b/tests/snapshots/v3/test_wifi_menu/test_dialog_cancel_returns_to_menu/nearby_after_cancel.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png b/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png
index 85694082f..62592ccb2 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png and b/tests/snapshots/v3/test_wifi_menu/test_disconnect_active/root_after_disconnect.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_duplicate_ssids_dedup/nearby_dedup.png b/tests/snapshots/v3/test_wifi_menu/test_duplicate_ssids_dedup/nearby_dedup.png
index 341a1a538..b76205570 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_duplicate_ssids_dedup/nearby_dedup.png and b/tests/snapshots/v3/test_wifi_menu/test_duplicate_ssids_dedup/nearby_dedup.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_empty_psk_submit_blocked/empty_psk_ok_pressed.png b/tests/snapshots/v3/test_wifi_menu/test_empty_psk_submit_blocked/empty_psk_ok_pressed.png
index 64f31fb34..54b36bd69 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_empty_psk_submit_blocked/empty_psk_ok_pressed.png and b/tests/snapshots/v3/test_wifi_menu/test_empty_psk_submit_blocked/empty_psk_ok_pressed.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_empty_scan/root_empty.png b/tests/snapshots/v3/test_wifi_menu/test_empty_scan/root_empty.png
index 80507dad7..368a6af64 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_empty_scan/root_empty.png and b/tests/snapshots/v3/test_wifi_menu/test_empty_scan/root_empty.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_error_dialog_snapshot/error_dialog.png b/tests/snapshots/v3/test_wifi_menu/test_error_dialog_snapshot/error_dialog.png
index 73047737b..380c8abd7 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_error_dialog_snapshot/error_dialog.png and b/tests/snapshots/v3/test_wifi_menu/test_error_dialog_snapshot/error_dialog.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_after_fallback.png b/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_after_fallback.png
index e8e672b79..6bc2c7290 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_after_fallback.png and b/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_after_fallback.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png b/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png
index 6a9359c94..f235a1d67 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png and b/tests/snapshots/v3/test_wifi_menu/test_forget_active_falls_back_to_best_saved/root_before_forget.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_forget_then_reload/root_after_forget.png b/tests/snapshots/v3/test_wifi_menu/test_forget_then_reload/root_after_forget.png
index 80507dad7..368a6af64 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_forget_then_reload/root_after_forget.png and b/tests/snapshots/v3/test_wifi_menu/test_forget_then_reload/root_after_forget.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_hotspot_active_indicator/root_hotspot_on.png b/tests/snapshots/v3/test_wifi_menu/test_hotspot_active_indicator/root_hotspot_on.png
index f4a74f429..30cef91e1 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_hotspot_active_indicator/root_hotspot_on.png and b/tests/snapshots/v3/test_wifi_menu/test_hotspot_active_indicator/root_hotspot_on.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_join_other_empty_ssid_blocked/join_empty_ssid.png b/tests/snapshots/v3/test_wifi_menu/test_join_other_empty_ssid_blocked/join_empty_ssid.png
index 80507dad7..368a6af64 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_join_other_empty_ssid_blocked/join_empty_ssid.png and b/tests/snapshots/v3/test_wifi_menu/test_join_other_empty_ssid_blocked/join_empty_ssid.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_long_press_active_submenu/active_actions_submenu.png b/tests/snapshots/v3/test_wifi_menu/test_long_press_active_submenu/active_actions_submenu.png
index 14510be19..11b0eddf1 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_long_press_active_submenu/active_actions_submenu.png and b/tests/snapshots/v3/test_wifi_menu/test_long_press_active_submenu/active_actions_submenu.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_many_saved_all_at_root/root_all_four_visible.png b/tests/snapshots/v3/test_wifi_menu/test_many_saved_all_at_root/root_all_four_visible.png
index 4b140a399..b80fb0bcb 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_many_saved_all_at_root/root_all_four_visible.png and b/tests/snapshots/v3/test_wifi_menu/test_many_saved_all_at_root/root_all_four_visible.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_multiple_profiles_same_ssid/root_disambiguated_by_name.png b/tests/snapshots/v3/test_wifi_menu/test_multiple_profiles_same_ssid/root_disambiguated_by_name.png
index 22b3b928e..c35619e13 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_multiple_profiles_same_ssid/root_disambiguated_by_name.png and b/tests/snapshots/v3/test_wifi_menu/test_multiple_profiles_same_ssid/root_disambiguated_by_name.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_nearby_empty_when_every_network_is_saved/nearby_none_found.png b/tests/snapshots/v3/test_wifi_menu/test_nearby_empty_when_every_network_is_saved/nearby_none_found.png
index 42cc88016..2f8448502 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_nearby_empty_when_every_network_is_saved/nearby_none_found.png and b/tests/snapshots/v3/test_wifi_menu/test_nearby_empty_when_every_network_is_saved/nearby_none_found.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_populated.png b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_populated.png
index 571ed8e58..402448121 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_populated.png and b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_populated.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_scanning.png b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_scanning.png
index 23e31a131..445c976e5 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_scanning.png and b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/nearby_scanning.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/root_before_scan.png b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/root_before_scan.png
index 80507dad7..368a6af64 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/root_before_scan.png and b/tests/snapshots/v3/test_wifi_menu/test_nearby_loading_then_populated/root_before_scan.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_open_network_badge_in_nearby_list/nearby_with_open_badge.png b/tests/snapshots/v3/test_wifi_menu/test_open_network_badge_in_nearby_list/nearby_with_open_badge.png
index 86c2832de..324b6f5cf 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_open_network_badge_in_nearby_list/nearby_with_open_badge.png and b/tests/snapshots/v3/test_wifi_menu/test_open_network_badge_in_nearby_list/nearby_with_open_badge.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_open_network_connect/connected_open.png b/tests/snapshots/v3/test_wifi_menu/test_open_network_connect/connected_open.png
index 80507dad7..368a6af64 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_open_network_connect/connected_open.png and b/tests/snapshots/v3/test_wifi_menu/test_open_network_connect/connected_open.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_password_special_chars/psk_special_chars_dialog.png b/tests/snapshots/v3/test_wifi_menu/test_password_special_chars/psk_special_chars_dialog.png
index 53207328b..f84ffcda0 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_password_special_chars/psk_special_chars_dialog.png and b/tests/snapshots/v3/test_wifi_menu/test_password_special_chars/psk_special_chars_dialog.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/replace_psk_dialog.png b/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/replace_psk_dialog.png
index 2ee9fbf80..6103786e3 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/replace_psk_dialog.png and b/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/replace_psk_dialog.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/root_after_replace.png b/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/root_after_replace.png
index 997a808ee..e9cb82a99 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/root_after_replace.png and b/tests/snapshots/v3/test_wifi_menu/test_replace_password_flow/root_after_replace.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png b/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png
index 6a9359c94..f235a1d67 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png and b/tests/snapshots/v3/test_wifi_menu/test_saved_in_range_active/root_active_first.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/nearby_signal_levels.png b/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/nearby_signal_levels.png
index c554084a8..7405d995f 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/nearby_signal_levels.png and b/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/nearby_signal_levels.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/root_signal_levels.png b/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/root_signal_levels.png
index 80507dad7..368a6af64 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/root_signal_levels.png and b/tests/snapshots/v3/test_wifi_menu/test_signal_bar_levels/root_signal_levels.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu/test_wifi_unsupported/root_unsupported.png b/tests/snapshots/v3/test_wifi_menu/test_wifi_unsupported/root_unsupported.png
index 63b9a20b8..14ada624a 100644
Binary files a/tests/snapshots/v3/test_wifi_menu/test_wifi_unsupported/root_unsupported.png and b/tests/snapshots/v3/test_wifi_menu/test_wifi_unsupported/root_unsupported.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/initial_menu.png b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/initial_menu.png
index 80507dad7..368a6af64 100644
Binary files a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/initial_menu.png and b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/initial_menu.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/navigated_down.png b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/navigated_down.png
index fcddc1c0e..7c4a74dcf 100644
Binary files a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/navigated_down.png and b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/navigated_down.png differ
diff --git a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/password_dialog.png b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/password_dialog.png
index 251667103..794a12004 100644
Binary files a/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/password_dialog.png and b/tests/snapshots/v3/test_wifi_menu_navigation/test_wifi_menu_navigation/password_dialog.png differ
diff --git a/tests/test_bluetooth_manager.py b/tests/test_bluetooth_manager.py
new file mode 100644
index 000000000..4396df71f
--- /dev/null
+++ b/tests/test_bluetooth_manager.py
@@ -0,0 +1,238 @@
+"""Bluetooth ops/manager tests. The filter-predicate cases are pure functions
+with no fixture — they encode what a live scan actually returned on a Pi 5."""
+
+import asyncio
+from unittest.mock import patch
+
+import pytest
+from dbus_fast import DBusError
+
+from modalapi.bluetooth import DeviceKind, device_kind, is_interesting, parse_bluez_error
+from modalapi.bluetooth import manager as manager_mod
+from modalapi.bluetooth import ops
+from modalapi.bluetooth.manager import BluetoothManager, has_adapter
+
+MIDI_UUID = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700"
+HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb"
+
+
+# ----- filter predicate -----
+
+
+def test_midi_device_is_midi():
+ props = {"Name": "EV-1-WL", "UUIDs": [MIDI_UUID]}
+ assert is_interesting(props)
+ assert device_kind(props) is DeviceKind.MIDI
+
+
+def test_hid_over_gatt_is_input():
+ props = {"Name": "R400 Presenter", "UUIDs": [HOG_UUID]}
+ assert is_interesting(props)
+ assert device_kind(props) is DeviceKind.INPUT
+
+
+def test_nameless_beacon_is_filtered_out():
+ """BlueZ fills Alias with a MAC-derived string, so Alias is always truthy.
+ Testing Name is the only thing that keeps beacons out of the list."""
+ assert not is_interesting({"Alias": "D4-06-0F-EE-16-83", "RSSI": -80})
+
+
+def test_named_but_uninteresting_is_filtered_out():
+ assert not is_interesting({"Name": "Fitbit Charge", "UUIDs": ["0000180d-0000-1000-8000-00805f9b34fb"]})
+
+
+def test_paired_device_always_shows_even_without_a_name():
+ assert is_interesting({"Paired": True, "Alias": "AA-BB-CC-DD-EE-FF"})
+
+
+def test_peripheral_major_class_is_input():
+ assert device_kind({"Name": "Keyboard", "Class": 0x000540}) is DeviceKind.INPUT
+
+
+def test_appearance_hid_range_is_input():
+ assert device_kind({"Name": "Mouse", "Appearance": 0x03C2}) is DeviceKind.INPUT
+
+
+def test_midi_wins_over_hid_when_a_device_advertises_both():
+ assert device_kind({"Name": "Both", "UUIDs": [MIDI_UUID, HOG_UUID]}) is DeviceKind.MIDI
+
+
+# ----- error mapping -----
+
+
+@pytest.mark.parametrize(
+ "raw,expected",
+ [
+ ("org.bluez.Error.AuthenticationFailed: x", "pairing failed"),
+ ("org.bluez.Error.ConnectionAttemptFailed: br-connection-page-timeout", "couldn't connect — is it still in pairing mode?"),
+ ("org.bluez.Error.NotAvailable: no", "the device is no longer in range"),
+ ("something InProgress happened", "already connecting"),
+ ],
+)
+def test_parse_bluez_error(raw, expected):
+ assert parse_bluez_error(Exception(raw)) == expected
+
+
+# ----- capability probe -----
+
+
+@pytest.mark.parametrize(
+ "exec_start,capable",
+ [
+ ("{ path=/usr/libexec/bluetooth/bluetoothd ; argv[]=/usr/libexec/bluetooth/bluetoothd -E ; }", True),
+ ("{ path=/usr/libexec/bluetooth/bluetoothd ; argv[]=/usr/libexec/bluetooth/bluetoothd ; }", False),
+ ("argv[]=/usr/libexec/bluetooth/bluetoothd --experimental", True),
+ ("argv[]=/usr/libexec/bluetooth/bluetoothd -nE", True),
+ # A long option that merely contains E must not read as the short flag.
+ ("argv[]=/usr/libexec/bluetooth/bluetoothd --nodetach --EXPERIMENTAL-NOPE", False),
+ ],
+)
+def test_has_experimental_flag(exec_start, capable):
+ assert ops.has_experimental_flag(exec_start) is capable
+
+
+def test_bluetoothd_is_capable_false_when_systemctl_fails():
+ with patch.object(ops, "_run", return_value=(1, "not found")):
+ assert ops.bluetoothd_is_capable() is False
+
+
+# ----- adapter presence -----
+
+
+def test_has_adapter_true_when_hci_node_exists(tmp_path):
+ (tmp_path / "hci0").mkdir()
+ assert has_adapter(str(tmp_path)) is True
+
+
+def test_has_adapter_false_on_pi3_pi4(tmp_path):
+ """dtoverlay=pi3-disable-bt hands the UART to DIN MIDI, so the directory
+ exists but registers no hci device."""
+ assert has_adapter(str(tmp_path)) is False
+
+
+def test_has_adapter_false_when_directory_is_absent():
+ assert has_adapter("/nonexistent/sysfs/path") is False
+
+
+# ----- known-device store -----
+
+
+class _FakeSettings:
+ def __init__(self):
+ self.data = {}
+
+ def get_setting(self, name):
+ return self.data.get(name)
+
+ def set_setting(self, name, value):
+ self.data[name] = value
+
+
+@pytest.fixture
+def manager():
+ """A BluetoothManager with the startup probe stubbed out — no D-Bus, no
+ systemctl, no threads reaching the network."""
+ with patch.object(manager_mod.BluetoothManager, "_startup", lambda self: None):
+ mgr = BluetoothManager(settings=_FakeSettings())
+ yield mgr
+ mgr.shutdown()
+
+
+def _device(address="AA:BB:CC:DD:EE:FF", name="EV-1-WL", kind=DeviceKind.MIDI):
+ return {
+ "path": "/org/bluez/hci0/dev_x",
+ "address": address,
+ "name": name,
+ "kind": kind,
+ "paired": True,
+ "connected": True,
+ "trusted": True,
+ "rssi": -50,
+ }
+
+
+def test_remember_then_read_back(manager):
+ manager.remember(_device())
+ known = manager.known_devices()
+ assert [(k["address"], k["name"], k["kind"]) for k in known] == [("AA:BB:CC:DD:EE:FF", "EV-1-WL", "midi")]
+
+
+def test_remember_is_idempotent_for_the_same_device(manager):
+ manager.remember(_device())
+ manager.remember(_device())
+ assert len(manager.known_devices()) == 1
+
+
+def test_rotated_private_address_under_a_known_name_is_the_same_device(manager):
+ """BLE resolvable private addresses re-randomise; keying on MAC alone would
+ accumulate a duplicate row every time the device reappears."""
+ manager.remember(_device(address="74:9F:EF:44:A6:99"))
+ manager.remember(_device(address="55:4F:14:89:F7:5F"))
+ known = manager.known_devices()
+ assert len(known) == 1
+ assert known[0]["address"] == "55:4F:14:89:F7:5F"
+
+
+def test_forget_removes_the_entry(manager):
+ manager.remember(_device())
+ manager.forget("AA:BB:CC:DD:EE:FF", "EV-1-WL")
+ assert manager.known_devices() == []
+
+
+def test_known_devices_survives_a_garbage_setting(manager):
+ manager.settings.set_setting("bluetooth.known_devices", "not a list")
+ assert manager.known_devices() == []
+
+
+def test_unsupported_manager_reports_no_devices(manager):
+ """No adapter → the menu row never appears at all."""
+ manager._has_adapter = False
+ assert manager.supported is False
+ assert manager.devices() == []
+
+
+# ----- Busy retry -----
+
+
+class _FlakyAdapter:
+ """Answers Busy for the first `busy_times` calls, as a bluetoothd that is
+ still initialising does."""
+
+ def __init__(self, busy_times: int, error: str = "org.bluez.Error.Busy"):
+ self.busy_times = busy_times
+ self.calls = 0
+ self.error = error
+
+ async def set_adapter_property(self, name, value):
+ self.calls += 1
+ if self.calls <= self.busy_times:
+ raise DBusError(self.error, self.error)
+
+
+def _run_set_flag(adapter):
+ with patch.object(ops.asyncio, "sleep", new=_no_sleep):
+ return asyncio.run(ops._set_adapter_flag(adapter, "Powered"))
+
+
+async def _no_sleep(_seconds):
+ return None
+
+
+def test_busy_is_retried_until_it_succeeds():
+ adapter = _FlakyAdapter(busy_times=3)
+ _run_set_flag(adapter)
+ assert adapter.calls == 4
+
+
+def test_busy_gives_up_after_the_retry_cap():
+ adapter = _FlakyAdapter(busy_times=ops._BUSY_RETRIES + 1)
+ with pytest.raises(DBusError):
+ _run_set_flag(adapter)
+ assert adapter.calls == ops._BUSY_RETRIES + 1
+
+
+def test_non_busy_errors_are_not_retried():
+ adapter = _FlakyAdapter(busy_times=1, error="org.bluez.Error.NotReady")
+ with pytest.raises(DBusError):
+ _run_set_flag(adapter)
+ assert adapter.calls == 1
diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py
index 4c79a2e41..1d0cbfeab 100644
--- a/tests/test_lcd320x240.py
+++ b/tests/test_lcd320x240.py
@@ -107,6 +107,8 @@ def mock_handler():
# the Wired Connection row in every wifi-menu snapshot. Pin it off here;
# tests that exercise the ethernet flow can override per-test.
handler.ethernet_manager = None
+ handler.bluetooth_manager = None
+ handler.bluetooth_status = {}
return handler
diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py
index f3e77852c..513fcb088 100644
--- a/tests/v3/conftest.py
+++ b/tests/v3/conftest.py
@@ -11,6 +11,7 @@
import common.token as Token
from emulator.controls import MockAnalogControl
+from modalapi.bluetooth import BtDevice, DeviceKind, KnownDevice
from modalapi.wifi import SavedConnection, ScannedNetwork
from tests.conftest import FakeWebSocketBridge
from tests.integration.conftest import _v3_stack
@@ -344,6 +345,75 @@ def _run_inline(cmd, on_done):
return _set
+def make_bt_device(
+ name="EV-1-WL",
+ address="D4:06:0F:EE:16:83",
+ kind=DeviceKind.MIDI,
+ paired=False,
+ connected=False,
+ rssi=-52,
+) -> BtDevice:
+ return BtDevice(
+ path="/org/bluez/hci0/dev_" + address.replace(":", "_"),
+ address=address,
+ name=name,
+ kind=kind,
+ paired=paired,
+ connected=connected,
+ trusted=paired,
+ rssi=rssi,
+ )
+
+
+def make_bt_known(name="EV-1-WL", address="D4:06:0F:EE:16:83", kind=DeviceKind.MIDI) -> KnownDevice:
+ return KnownDevice(address=address, name=name, kind=kind.value, last_connected=1)
+
+
+@pytest.fixture
+def bluetooth_state(v3_system):
+ """Configure bluetooth_manager and bluetooth_status in one call.
+
+ Installs the same inline CommandQueue shim wifi_state uses: submit/
+ submit_scan run the command synchronously and invoke the callback
+ immediately, so no worker thread runs under pytest."""
+
+ def _set(devices=(), known=(), enabled=True, capable=True, supported=True, deferred=None):
+ mgr = v3_system.handler.bluetooth_manager
+ mgr.supported = supported
+ mgr.capable = capable
+ mgr.devices.return_value = list(devices)
+ mgr.known_devices.return_value = list(known)
+
+ def _run_inline(cmd, on_done):
+ if deferred is not None:
+ # Multi-frame sagas: the caller fires these by hand, a frame apart.
+ deferred.append((cmd, on_done))
+ return True
+ try:
+ result = cmd.run(mgr)
+ except Exception as e:
+ result = e
+ on_done(result)
+ return True
+
+ mgr.queue.submit.side_effect = _run_inline
+ mgr.queue.submit_scan.side_effect = _run_inline
+ mgr.queue.pending_op_count.return_value = 0
+
+ status = {
+ "supported": supported,
+ "capable": capable,
+ "enabled": enabled,
+ "powered": enabled,
+ "discovering": False,
+ "connected": [d["name"] for d in devices if d["connected"]],
+ }
+ v3_system.handler.bluetooth_status = status
+ return mgr
+
+ return _set
+
+
@pytest.fixture
def type_in_editor():
"""Type text into the active TextEditor / _PassphraseEditor via the LetterSelector.
diff --git a/tests/v3/test_bluetooth_menu.py b/tests/v3/test_bluetooth_menu.py
new file mode 100644
index 000000000..759d4b6de
--- /dev/null
+++ b/tests/v3/test_bluetooth_menu.py
@@ -0,0 +1,300 @@
+"""Bluetooth menu snapshot suite.
+
+Mirrors the wifi suite's categories, which are the ones that caught real bugs
+there: multi-frame sagas via a deferred-callback list, scan pacing, error
+kinds, and modal safety."""
+
+import pytest
+
+from modalapi.bluetooth import DeviceKind
+from tests.v3.conftest import make_bt_device, make_bt_known, make_saved, make_scanned
+from uilib.menu import Menu
+from uilib.misc import InputEvent
+
+
+def _open(v3_system):
+ """Open the LCD's own BluetoothMenu, not a fresh one: the handler's status
+ callback re-renders `lcd.bluetooth_menu`, so a private instance would never
+ see the repaints a status change drives."""
+ lcd = v3_system.handler._lcd
+ lcd.bluetooth_menu.open()
+ return lcd
+
+
+def _footer_labels(menu):
+ return [slot.text for slot in menu.footer if slot is not None]
+
+
+def _labels(menu):
+ from uilib.menu import _item_label, label_key
+
+ return [label_key(_item_label(i)) for i in menu.items]
+
+
+def _click_row(lcd, text):
+ """Move the cursor onto the row whose label contains `text`, then click."""
+ menu = lcd.pstack.current
+ assert isinstance(menu, Menu)
+ for idx, label in enumerate(_labels(menu)):
+ if text in label:
+ menu.sel_widget(menu.sel_children()[idx])
+ menu.input_event(InputEvent.CLICK)
+ return
+ raise AssertionError("no row containing %r in %r" % (text, _labels(menu)))
+
+
+# ----- root menu -----
+
+
+def test_root_menu_lists_paired_device(v3_system, bluetooth_state, snapshot):
+ bluetooth_state(
+ devices=[make_bt_device(paired=True, connected=True)],
+ known=[make_bt_known()],
+ )
+ _open(v3_system)
+ snapshot("root_connected")
+
+
+def test_root_menu_when_off_offers_only_power_on(v3_system, bluetooth_state, snapshot):
+ bluetooth_state(enabled=False)
+ lcd = _open(v3_system)
+ menu = lcd.pstack.current
+ assert "Turn Bluetooth on" in _labels(menu)
+ assert "Nearby devices..." not in _labels(menu)
+ snapshot("root_off")
+
+
+def test_incapable_image_offers_the_package_install(v3_system, bluetooth_state, snapshot):
+ """bluetoothd without -E registers no MIDI profile, so pairing would look
+ like it worked and produce no ALSA seq port. Say so instead."""
+ bluetooth_state(capable=False)
+ lcd = _open(v3_system)
+ menu = lcd.pstack.current
+ assert "Install Bluetooth support" in _labels(menu)
+ assert "Nearby devices..." not in _labels(menu)
+ snapshot("root_needs_package")
+
+
+def test_known_but_absent_device_says_what_to_do(v3_system, bluetooth_state, snapshot):
+ """A non-bonding device drops to unpaired on disconnect, so the row must
+ name the physical remedy rather than just reading 'Disconnected'."""
+ bluetooth_state(devices=[], known=[make_bt_known()])
+ lcd = _open(v3_system)
+ menu = lcd.pstack.current
+ assert any("press its button" in label for label in _labels(menu))
+ snapshot("root_known_absent")
+
+
+def test_hid_device_carries_an_input_badge(v3_system, bluetooth_state, snapshot):
+ bluetooth_state(
+ devices=[make_bt_device(name="R400 Presenter", kind=DeviceKind.INPUT, paired=True)],
+ known=[make_bt_known(name="R400 Presenter", kind=DeviceKind.INPUT)],
+ )
+ _open(v3_system)
+ snapshot("root_hid_badge")
+
+
+# ----- nearby -----
+
+
+def test_empty_nearby_list_names_pairing_mode(v3_system, bluetooth_state, snapshot):
+ """BLE-MIDI peripherals only advertise while discoverable — this string
+ prevents more confusion than anything else in the feature."""
+ bluetooth_state(devices=[])
+ lcd = _open(v3_system)
+ _click_row(lcd, "Nearby devices")
+ menu = lcd.pstack.current
+ assert any("Put it in pairing mode" in label for label in _labels(menu))
+ snapshot("nearby_empty")
+
+
+def test_nearby_lists_only_unpaired_devices(v3_system, bluetooth_state, snapshot):
+ bluetooth_state(
+ devices=[
+ make_bt_device(),
+ make_bt_device(name="R400 Presenter", address="C8:3B:44:10:02:9A", kind=DeviceKind.INPUT, rssi=-71),
+ make_bt_device(name="Already Paired", address="11:22:33:44:55:66", paired=True),
+ ],
+ known=[make_bt_known(name="Already Paired", address="11:22:33:44:55:66")],
+ )
+ lcd = _open(v3_system)
+ _click_row(lcd, "Nearby devices")
+ labels = _labels(lcd.pstack.current)
+ assert any("EV-1-WL" in label for label in labels)
+ assert not any("Already Paired" in label for label in labels)
+ snapshot("nearby_list")
+
+
+def test_opening_nearby_starts_discovery(v3_system, bluetooth_state):
+ mgr = bluetooth_state(devices=[])
+ lcd = _open(v3_system)
+ _click_row(lcd, "Nearby devices")
+ assert mgr.queue.submit_scan.called
+
+
+def test_tick_does_not_rescan_while_the_root_menu_is_open(v3_system, bluetooth_state):
+ """Discovery is held open only for the nearby list; leaving it running
+ under the root menu would burn radio for nothing."""
+ mgr = bluetooth_state(devices=[])
+ lcd = _open(v3_system)
+ mgr.queue.submit_scan.reset_mock()
+ lcd.bluetooth_menu.tick()
+ assert not mgr.queue.submit_scan.called
+
+
+def test_leaving_nearby_stops_discovery(v3_system, bluetooth_state):
+ mgr = bluetooth_state(devices=[])
+ lcd = _open(v3_system)
+ _click_row(lcd, "Nearby devices")
+ lcd.pstack.pop_panel(lcd.pstack.current)
+ mgr.queue.submit_scan.reset_mock()
+ lcd.bluetooth_menu.tick()
+ submitted = [c.args[0] for c in mgr.queue.submit_scan.call_args_list]
+ assert any(type(cmd).__name__ == "StopDiscoveryCmd" for cmd in submitted)
+
+
+# ----- pairing saga -----
+
+
+def test_pairing_shows_progress_then_settles(v3_system, bluetooth_state, snapshot):
+ """Multi-frame: the in-row 'Pairing…' text must appear while the command
+ is in flight, and clear when it lands."""
+ deferred: list = []
+ bluetooth_state(devices=[make_bt_device()], deferred=deferred)
+ lcd = _open(v3_system)
+ _click_row(lcd, "Nearby devices")
+ _click_row(lcd, "EV-1-WL")
+ snapshot("pairing_in_flight")
+
+ _, on_done = deferred.pop()
+ on_done(None)
+ snapshot("pairing_done")
+
+
+@pytest.mark.parametrize(
+ "error,expected",
+ [
+ (Exception("org.bluez.Error.AuthenticationFailed: no"), "pairing failed"),
+ (Exception("org.bluez.Error.ConnectionAttemptFailed: x"), "is it still in pairing mode?"),
+ (Exception("org.bluez.Error.NotAvailable: gone"), "no longer in range"),
+ ],
+)
+def test_pairing_failures_surface_a_dialog(v3_system, bluetooth_state, error, expected):
+ deferred: list = []
+ bluetooth_state(devices=[make_bt_device()], deferred=deferred)
+ lcd = _open(v3_system)
+ _click_row(lcd, "Nearby devices")
+ _click_row(lcd, "EV-1-WL")
+ _, on_done = deferred.pop()
+ on_done(error)
+ rendered = lcd.pstack.current
+ assert not isinstance(rendered, Menu), "a failure must raise a dialog over the menu"
+
+
+def test_pair_failure_snapshot(v3_system, bluetooth_state, snapshot):
+ deferred: list = []
+ bluetooth_state(devices=[make_bt_device()], deferred=deferred)
+ lcd = _open(v3_system)
+ _click_row(lcd, "Nearby devices")
+ _click_row(lcd, "EV-1-WL")
+ _, on_done = deferred.pop()
+ on_done(Exception("org.bluez.Error.AuthenticationFailed: no"))
+ snapshot("pair_failed_dialog")
+
+
+# ----- modal safety -----
+
+
+def test_status_change_does_not_close_an_open_dialog(v3_system, bluetooth_state):
+ """A PropertiesChanged burst mid-dialog must not yank it out from under
+ the user — bluez pushes these constantly while scanning."""
+ deferred: list = []
+ mgr = bluetooth_state(devices=[make_bt_device()], deferred=deferred)
+ lcd = _open(v3_system)
+ _click_row(lcd, "Nearby devices")
+ _click_row(lcd, "EV-1-WL")
+ _, on_done = deferred.pop()
+ on_done(Exception("org.bluez.Error.AuthenticationFailed: no"))
+ dialog = lcd.pstack.current
+ assert not isinstance(dialog, Menu)
+
+ mgr.devices.return_value = [make_bt_device(rssi=-40), make_bt_device(name="New Thing", address="AA:BB:CC:DD:EE:01")]
+ lcd.bluetooth_menu.notify_status_change()
+ assert lcd.pstack.current is dialog
+
+
+def test_rssi_jitter_does_not_rebuild_the_menu(v3_system, bluetooth_state):
+ """BT RSSI is noisier than wifi's; only a change in the drawn bar count
+ may cost the user their cursor position."""
+ mgr = bluetooth_state(devices=[make_bt_device(paired=True)], known=[make_bt_known()])
+ lcd = _open(v3_system)
+ menu = lcd.pstack.current
+ mgr.devices.return_value = [make_bt_device(paired=True, rssi=-53)]
+ lcd.bluetooth_menu.notify_status_change()
+ assert lcd.pstack.current is menu
+
+
+def test_bar_count_change_does_rebuild(v3_system, bluetooth_state):
+ mgr = bluetooth_state(devices=[make_bt_device(paired=True, rssi=-95)], known=[make_bt_known()])
+ lcd = _open(v3_system)
+ menu = lcd.pstack.current
+ mgr.devices.return_value = [make_bt_device(paired=True, rssi=-30)]
+ lcd.bluetooth_menu.notify_status_change()
+ assert lcd.pstack.current is not menu
+
+
+# ----- wifi menu integration -----
+
+
+def test_wifi_menu_hides_bluetooth_button_without_hardware(v3_system, bluetooth_state, wifi_state):
+ """Pi 3/4 give the BT UART to DIN MIDI. No adapter, no mention anywhere."""
+ wifi_state()
+ bluetooth_state(supported=False)
+ lcd = v3_system.handler._lcd
+ lcd.wifi_menu.open()
+ menu = lcd.pstack.current
+ assert not any("Bluetooth" in label for label in _footer_labels(menu))
+
+
+def test_wifi_menu_footer_counts_connected_devices(v3_system, bluetooth_state, wifi_state):
+ wifi_state()
+ bluetooth_state(devices=[make_bt_device(paired=True, connected=True)], known=[make_bt_known()])
+ lcd = v3_system.handler._lcd
+ lcd.wifi_menu.open()
+ labels = _footer_labels(lcd.pstack.current)
+ assert labels == ["Close", "Bluetooth (1)..."]
+
+
+def test_wifi_menu_footer_without_connection(v3_system, bluetooth_state, wifi_state, snapshot):
+ """Radio present, nothing connected — the button carries no count."""
+ wifi_state(
+ scanned=[make_scanned("HomeWifi", signal=78, in_use=True), make_scanned("StudioNet", signal=61)],
+ saved=[make_saved("HomeWifi"), make_saved("StudioNet")],
+ active="HomeWifi",
+ )
+ bluetooth_state(devices=[], known=[make_bt_known()])
+ lcd = v3_system.handler._lcd
+ lcd.wifi_menu.open()
+ labels = _footer_labels(lcd.pstack.current)
+ assert labels == ["Close", "Bluetooth..."]
+ snapshot("wifi_bt_none_connected")
+
+
+def test_wifi_menu_many_saved_with_bluetooth_connected(v3_system, bluetooth_state, wifi_state, snapshot):
+ """The layout under real load: several saved networks plus a live BT device."""
+ saved = [
+ make_saved("HomeWifi"),
+ make_saved("StudioNet"),
+ make_saved("CoffeeShop"),
+ make_saved("Backline Guest"),
+ ]
+ scanned = [
+ make_scanned("HomeWifi", signal=78, in_use=True),
+ make_scanned("StudioNet", signal=61),
+ make_scanned("CoffeeShop", signal=44),
+ ]
+ wifi_state(scanned=scanned, saved=saved, active="HomeWifi")
+ bluetooth_state(devices=[make_bt_device(paired=True, connected=True)], known=[make_bt_known()])
+ lcd = v3_system.handler._lcd
+ lcd.wifi_menu.open()
+ snapshot("wifi_many_saved_bt_connected")
diff --git a/ui/bluetooth_menu.py b/ui/bluetooth_menu.py
new file mode 100644
index 000000000..24ccc2aed
--- /dev/null
+++ b/ui/bluetooth_menu.py
@@ -0,0 +1,417 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+from typing import TYPE_CHECKING, Optional, Protocol, TypedDict, cast
+
+from modalapi.bluetooth import (
+ BluetoothManager,
+ BtDevice,
+ BtStatus,
+ ConnectCmd,
+ DeviceKind,
+ DisconnectCmd,
+ ForgetCmd,
+ InstallSupportCmd,
+ PairCmd,
+ PowerCmd,
+ StartDiscoveryCmd,
+ StopDiscoveryCmd,
+ parse_bluez_error,
+)
+from uilib import Config, MessageDialog, get_line_height
+from uilib.glyphs import PillGlyph, SignalBarsGlyph
+from uilib.menu import Menu, MenuItem
+from uilib.rich_text import IconSeg, Segment, Spacer, TextSeg
+
+if TYPE_CHECKING:
+ from pistomp.lcd320x240 import Lcd
+
+
+class _BluetoothHost(Protocol):
+ """The handler-side surface BluetoothMenu needs."""
+
+ bluetooth_manager: BluetoothManager
+ bluetooth_status: Optional[BtStatus]
+
+
+MENU_WIDTH = 288 # wider than the default; device names run long
+
+ACTIVE_GLYPH = "✔"
+SEP = "·"
+
+# BLE-MIDI peripherals only advertise while discoverable.
+EMPTY_NEARBY = ("No devices found.", "Put it in pairing mode.")
+PAIRING_HINT = "press its button"
+
+
+class BtRow(TypedDict):
+ address: str
+ name: str
+ kind: DeviceKind
+ paired: bool
+ connected: bool
+ present: bool # bluez currently holds an object for it
+ rssi: Optional[int]
+ device: Optional[BtDevice]
+
+
+def signal_bars_level(rssi: int) -> int:
+ """0..4-bar bucket for a dBm RSSI. BT RSSI is noisier than wifi's, so the
+ bucketing is what keeps jitter out of the row signature."""
+ return max(1, min(4, (rssi + 100) // 18))
+
+
+RowSig = tuple[str, str, bool, bool, bool, Optional[int], Optional[str]]
+
+
+def _rows_sig(rows: list[BtRow], busy: dict[str, str]) -> tuple[RowSig, ...]:
+ return tuple(
+ (
+ r["address"],
+ r["name"],
+ r["paired"],
+ r["connected"],
+ r["present"],
+ None if r["rssi"] is None else signal_bars_level(r["rssi"]),
+ busy.get(r["address"]),
+ )
+ for r in rows
+ )
+
+
+def _glyph_height() -> int:
+ return get_line_height(Config().get_font("default"))
+
+
+class BluetoothMenu:
+ """Pair, connect, and forget BLE-MIDI and HID devices; toggle the radio."""
+
+ def __init__(self, lcd: "Lcd") -> None:
+ self.lcd: "Lcd" = lcd
+ self._root_menu: Optional["Menu"] = None
+ self._nearby_menu: Optional["Menu"] = None
+ self._root_sig: tuple[RowSig, ...] = ()
+ self._nearby_sig: tuple[RowSig, ...] = ()
+ self._busy: dict[str, str] = {}
+ self._awaiting: Optional[str] = None # address to pair as soon as it appears
+ self._discovering: bool = False
+
+ @property
+ def _host(self) -> _BluetoothHost:
+ h = self.lcd.handler
+ assert h is not None, "BluetoothMenu requires lcd.handler to be set"
+ return cast(_BluetoothHost, h)
+
+ @property
+ def _manager(self) -> BluetoothManager:
+ return self._host.bluetooth_manager
+
+ @property
+ def _status(self) -> BtStatus:
+ return self._host.bluetooth_status or {}
+
+ @property
+ def _pstack(self):
+ return self.lcd.pstack
+
+ # ----- entry points -----
+
+ def open(self, event: object = None, widget: object = None) -> None:
+ self._render_root_menu()
+
+ def tick(self) -> None:
+ """Handler poll hook (2s). Discovery is held open only while the nearby
+ list is on screen — bluez purges unpaired device objects the moment it
+ stops, so leaving it running elsewhere would just burn radio."""
+ nearby_open = self._nearby_menu is not None and self._pstack.current is self._nearby_menu
+ if nearby_open and not self._discovering:
+ self._start_discovery()
+ elif not nearby_open and self._discovering:
+ self._stop_discovery()
+
+ def _start_discovery(self) -> None:
+ self._discovering = True
+ self._manager.queue.submit_scan(StartDiscoveryCmd(), self._on_discovery_change)
+
+ def _stop_discovery(self) -> None:
+ self._discovering = False
+ self._awaiting = None
+ self._manager.queue.submit_scan(StopDiscoveryCmd(), self._on_discovery_change)
+
+ def _on_discovery_change(self, result: object) -> None:
+ if isinstance(result, Exception):
+ self._discovering = False
+
+ # ----- rows -----
+
+ def _current_rows(self) -> tuple[list[BtRow], list[BtRow]]:
+ """Returns (root_rows, nearby_rows).
+
+ The root list is the union of our known-device store and bluez's paired
+ set: a non-bonding device drops to unpaired the moment it disconnects,
+ so bluez alone would silently lose it from the menu."""
+ devices = self._manager.devices()
+ by_address = {d["address"].upper(): d for d in devices}
+
+ root: list[BtRow] = []
+ claimed: set[str] = set()
+ for known in self._manager.known_devices():
+ device = by_address.get(known["address"].upper())
+ if device is None and known["name"]:
+ # A rotated resolvable private address under a known name is
+ # the same device, not a new one.
+ device = next((d for d in devices if d["name"] == known["name"]), None)
+ if device is not None:
+ claimed.add(device["address"].upper())
+ root.append(self._row(known["name"], known["address"], known["kind"], device))
+
+ for device in devices:
+ if device["address"].upper() in claimed or not device["paired"]:
+ continue
+ claimed.add(device["address"].upper())
+ root.append(self._row(device["name"], device["address"], device["kind"].value, device))
+
+ nearby = [
+ self._row(d["name"], d["address"], d["kind"].value, d)
+ for d in devices
+ if d["address"].upper() not in claimed and not d["paired"]
+ ]
+
+ root.sort(key=lambda r: (not r["connected"], not r["present"], r["name"].lower()))
+ nearby.sort(key=lambda r: -(r["rssi"] if r["rssi"] is not None else -999))
+ return root, nearby
+
+ @staticmethod
+ def _row(name: str, address: str, kind: str, device: Optional[BtDevice]) -> BtRow:
+ try:
+ device_kind = DeviceKind(kind)
+ except ValueError:
+ device_kind = DeviceKind.OTHER
+ return BtRow(
+ address=device["address"] if device is not None else address,
+ name=(device["name"] if device is not None and device["name"] else name) or address,
+ kind=device["kind"] if device is not None else device_kind,
+ paired=bool(device is not None and device["paired"]),
+ connected=bool(device is not None and device["connected"]),
+ present=device is not None,
+ rssi=device["rssi"] if device is not None else None,
+ device=device,
+ )
+
+ def _row_segments(self, row: BtRow, known: bool) -> list[Segment]:
+ h = _glyph_height()
+ busy = self._busy.get(row["address"])
+ label = row["name"]
+ if busy is None and known and not row["present"]:
+ # Say what to do, not just "Disconnected" — a non-bonding device
+ # needs the physical button before anything can reach it.
+ label = "%s %s %s" % (row["name"], SEP, PAIRING_HINT)
+ segs: list[Segment] = [TextSeg(label)]
+ if row["kind"] is not DeviceKind.OTHER:
+ segs.append(TextSeg(" "))
+ segs.append(IconSeg(PillGlyph("M" if row["kind"] is DeviceKind.MIDI else "I", height=h)))
+ if row["connected"]:
+ segs.append(TextSeg(" " + ACTIVE_GLYPH))
+ segs.append(Spacer())
+ if busy is not None:
+ segs.append(TextSeg(busy))
+ elif row["rssi"] is not None:
+ segs.append(IconSeg(SignalBarsGlyph(signal_bars_level(row["rssi"]), height=h)))
+ return segs
+
+ # ----- render -----
+
+ def _title(self) -> str:
+ connected = self._status.get("connected") or []
+ if connected:
+ return "Bluetooth %s %s" % (SEP, connected[0])
+ if not self._status.get("enabled"):
+ return "Bluetooth %s Off" % SEP
+ return "Bluetooth"
+
+ def _build_items(self, rows: list[BtRow]) -> list[MenuItem]:
+ items: list[MenuItem] = []
+ if not self._status.get("capable"):
+ items.append(("Needs a system update", None, None))
+ items.append(("Install Bluetooth support", self._install_support, None))
+ return items
+ if not self._status.get("enabled"):
+ items.append(("Turn Bluetooth on", self._toggle_power, None))
+ return items
+ items.extend(
+ (self._row_segments(r, known=True), self._on_device_tap, r, None, self._on_device_long_tap) for r in rows
+ )
+ items.append(("Nearby devices...", self._open_nearby_menu, None))
+ items.append(("Turn Bluetooth off", self._toggle_power, None))
+ return items
+
+ def _render_root_menu(self, default_label: Optional[str] = None) -> None:
+ rows, _ = self._current_rows()
+ self._root_sig = _rows_sig(rows, self._busy)
+ self._root_menu = self.lcd.draw_selection_menu(
+ self._build_items(rows), self._title(), dismiss_option=True, default_item=default_label, width=MENU_WIDTH
+ )
+
+ def _render_nearby_menu(self, default_label: Optional[str] = None) -> None:
+ _, nearby = self._current_rows()
+ if nearby:
+ items: list[MenuItem] = [(self._row_segments(r, known=False), self._on_nearby_tap, r) for r in nearby]
+ else:
+ items = [(line, None, None) for line in EMPTY_NEARBY]
+ self._nearby_sig = _rows_sig(nearby, self._busy)
+ self._nearby_menu = self.lcd.draw_selection_menu(
+ items, "Nearby Devices", dismiss_option=True, default_item=default_label, width=MENU_WIDTH
+ )
+
+ def notify_status_change(self) -> None:
+ """Rebuild in place, preserving the cursor. Refuses to touch anything
+ unless one of our menus is on top, so a rebuild can't yank a dialog
+ out from under the user."""
+ current = self._pstack.current
+ rows, nearby = self._current_rows()
+ if self._nearby_menu is not None and current is self._nearby_menu:
+ self._maybe_pair_awaited(nearby)
+ if _rows_sig(nearby, self._busy) != self._nearby_sig:
+ self._rerender_nearby()
+ elif self._root_menu is not None and current is self._root_menu:
+ self._maybe_pair_awaited(rows)
+ if _rows_sig(rows, self._busy) != self._root_sig:
+ self._rerender_root()
+
+ def _maybe_pair_awaited(self, rows: list[BtRow]) -> None:
+ """A known device the user tapped while it was out of range: pair it
+ the moment discovery turns it up, so their only job is the button."""
+ if self._awaiting is None:
+ return
+ for row in rows:
+ if row["address"].upper() == self._awaiting.upper() and row["present"]:
+ self._awaiting = None
+ self._submit_pair(row)
+ return
+
+ def _rerender_root(self) -> None:
+ assert self._root_menu is not None
+ keep = self._root_menu.selected_label()
+ old = self._root_menu
+ self._root_menu = None
+ self._pstack.pop_panel(old)
+ self._render_root_menu(default_label=keep)
+
+ def _rerender_nearby(self) -> None:
+ assert self._nearby_menu is not None
+ keep = self._nearby_menu.selected_label()
+ old = self._nearby_menu
+ self._nearby_menu = None
+ self._pstack.pop_panel(old)
+ self._render_nearby_menu(default_label=keep)
+
+ # ----- actions -----
+
+ def _toggle_power(self, _: object = None) -> None:
+ enable = not self._status.get("enabled")
+ self._manager.queue.submit(PowerCmd(enable), self._on_op_done)
+
+ def _install_support(self, _: object = None) -> None:
+ self._manager.queue.submit(InstallSupportCmd(), self._on_op_done)
+
+ def _open_nearby_menu(self, _: object = None) -> None:
+ self._render_nearby_menu()
+ self._start_discovery()
+
+ def _on_device_tap(self, row: BtRow) -> None:
+ if row["connected"]:
+ self._open_device_submenu(row)
+ return
+ if not row["present"]:
+ # Out of range: start looking and pair on sight.
+ self._awaiting = row["address"]
+ self._start_discovery()
+ self._mark_busy(row, "Waiting…")
+ return
+ if row["paired"]:
+ self._submit_connect(row)
+ else:
+ self._submit_pair(row)
+
+ def _on_nearby_tap(self, row: BtRow) -> None:
+ self._submit_pair(row)
+
+ def _on_device_long_tap(self, row: BtRow) -> None:
+ self._open_device_submenu(row)
+
+ def _open_device_submenu(self, row: BtRow) -> None:
+ items: list[MenuItem] = []
+ if row["connected"]:
+ items.append(("Disconnect", self._disconnect, row))
+ items.append(("Forget", self._forget, row))
+ self.lcd.draw_selection_menu(items, row["name"], dismiss_option=True)
+
+ # ----- command submission -----
+
+ def _device_of(self, row: BtRow) -> BtDevice:
+ device = row["device"]
+ assert device is not None, "callers gate on row['present']"
+ return device
+
+ def _mark_busy(self, row: BtRow, text: str) -> None:
+ self._busy[row["address"]] = text
+ self.notify_status_change()
+
+ def _clear_busy(self, address: str) -> None:
+ self._busy.pop(address, None)
+ self.notify_status_change()
+
+ def _submit_pair(self, row: BtRow) -> None:
+ self._mark_busy(row, "Pairing…")
+ address = row["address"]
+ self._manager.queue.submit(PairCmd(self._device_of(row)), lambda err: self._on_device_op_done(err, address))
+
+ def _submit_connect(self, row: BtRow) -> None:
+ self._mark_busy(row, "Connecting…")
+ address = row["address"]
+ self._manager.queue.submit(ConnectCmd(self._device_of(row)), lambda err: self._on_device_op_done(err, address))
+
+ def _disconnect(self, row: BtRow) -> None:
+ self._pstack.pop_panel(None)
+ self._manager.queue.submit(DisconnectCmd(self._device_of(row)), self._on_op_done)
+
+ def _forget(self, row: BtRow) -> None:
+ self._pstack.pop_panel(None)
+ device = row["device"] or BtDevice(
+ path="",
+ address=row["address"],
+ name=row["name"],
+ kind=row["kind"],
+ paired=row["paired"],
+ connected=row["connected"],
+ trusted=False,
+ rssi=None,
+ )
+ self._manager.queue.submit(ForgetCmd(device), self._on_op_done)
+
+ # ----- results -----
+
+ def _on_device_op_done(self, err: object, address: str) -> None:
+ self._clear_busy(address)
+ self._on_op_done(err)
+
+ def _on_op_done(self, err: object) -> None:
+ if isinstance(err, Exception):
+ self._show_error(parse_bluez_error(err))
+ elif isinstance(err, str):
+ self._show_error(parse_bluez_error(err))
+
+ def _show_error(self, message: str) -> None:
+ self._pstack.push_panel(MessageDialog(self._pstack, message, title="Bluetooth"))
diff --git a/ui/wifi_menu.py b/ui/wifi_menu.py
index 70ad4d00b..ce562114d 100644
--- a/ui/wifi_menu.py
+++ b/ui/wifi_menu.py
@@ -19,6 +19,7 @@
from common.fonts import font_path
import common.util as util
+from modalapi.bluetooth import BluetoothManager, BtStatus
from modalapi.ethernet import EthernetManager
from uilib.pygame_init import font as _make_font
from modalapi.wifi import (
@@ -48,7 +49,7 @@
get_line_height,
)
from uilib.glyphs import PillGlyph, SignalBarsGlyph, EthernetCableGlyph
-from uilib.menu import Menu, MenuItem, label_key
+from uilib.menu import FooterButton, FooterSlot, Menu, MenuItem, label_key
from uilib.rich_text import IconSeg, Segment, Spacer, TextSeg
if TYPE_CHECKING:
@@ -61,6 +62,8 @@ class _WifiHost(Protocol):
wifi_manager: WifiManager
wifi_status: Optional[WifiStatus]
ethernet_manager: Optional[EthernetManager]
+ bluetooth_manager: Optional[BluetoothManager]
+ bluetooth_status: Optional[BtStatus]
ACTIVE_GLYPH = "\u2714" # ✔
@@ -266,12 +269,12 @@ def _render_root_menu(self, default_label: Optional[str] = None) -> None:
wifi_status = self._wifi_status
hotspot_active = bool(util.DICT_GET(wifi_status, "hotspot_active"))
supported = util.DICT_GET(wifi_status, "wifi_supported") is not False
- active_name = util.DICT_GET(wifi_status, "connection")
rows, _ = self._current_rows()
- title = self._title(wifi_status, active_name)
items = self._build_items(rows, hotspot_active, supported)
self._root_sig = _rows_sig(rows)
- self._root_menu = self.lcd.draw_selection_menu(items, title, dismiss_option=True, default_item=default_label)
+ self._root_menu = self.lcd.draw_selection_menu(
+ items, self.TITLE, default_item=default_label, footer=self._footer()
+ )
def _render_nearby_menu(self, default_label: Optional[str] = None) -> None:
_, nearby = self._current_rows()
@@ -378,13 +381,7 @@ def _build_items(self, rows: list[Row], hotspot_active: bool, supported: bool =
items.append((hotspot_label, self.toggle_hotspot, None))
return items
- def _title(self, wifi_status: WifiStatus, active_name: Optional[str]) -> str:
- if util.DICT_GET(wifi_status, "hotspot_active"):
- return "WiFi " + SEP + " Hotspot"
- if active_name:
- ssid = util.DICT_GET(wifi_status, "ssid") or active_name
- return "WiFi %s %s" % (SEP, ssid)
- return "WiFi " + SEP + " Disconnected"
+ TITLE = "Wi-Fi and Devices"
def _row_segments(self, row: Row) -> list[Segment]:
label = row.get("display_name") or row["ssid"]
@@ -467,6 +464,26 @@ def _open_saved_submenu(self, row: Row, include_disconnect: bool = False) -> Non
def _open_ethernet_menu(self, _: object = None) -> None:
self.lcd.ethernet_menu.open()
+ def _footer(self) -> list[FooterSlot]:
+ """Close left, Bluetooth right, nothing between. Pi 3/4 give the BT UART
+ to DIN MIDI, so there is no adapter and no mention of it anywhere."""
+ bt = self._host.bluetooth_manager
+ if bt is None or not bt.supported:
+ return [None, FooterButton("Close", self._close), None]
+ count = len((self._host.bluetooth_status or {}).get("connected") or [])
+ label = "Bluetooth (%d)..." % count if count else "Bluetooth..."
+ return [
+ FooterButton("Close", self._close),
+ FooterButton(label, self._open_bluetooth_menu, span=2),
+ ]
+
+ def _close(self) -> None:
+ if self._root_menu is not None:
+ self._pstack.pop_panel(self._root_menu)
+
+ def _open_bluetooth_menu(self) -> None:
+ self.lcd.bluetooth_menu.open()
+
def _open_nearby_menu(self, _: object = None) -> None:
self._render_nearby_menu()
self._submit_scan()
diff --git a/uilib/menu.py b/uilib/menu.py
index 47f702022..f25990814 100644
--- a/uilib/menu.py
+++ b/uilib/menu.py
@@ -22,7 +22,23 @@
from uilib.glyphs import BadgeGlyph
from uilib.misc import InputEvent, TextHAlign, get_text_size, trace
from uilib.rich_text import RichTextWidget, Segment, TextSeg
-from uilib.text import TextWidget
+from uilib.text import Button, TextWidget
+
+
+DEFAULT_WIDTH = 240
+
+# Must match plugins/chrome.py.
+FOOTER_GAP = 2
+FOOTER_H = 28
+
+@dataclass(frozen=True)
+class FooterButton:
+ text: str
+ action: Callable[[], None]
+ span: int = 1 # grid columns to occupy
+
+
+FooterSlot = FooterButton | None # None is an empty grid column
@dataclass(frozen=True)
@@ -84,15 +100,17 @@ class Menu(Dialog):
`items` is a list of `MenuItem` tuples; the first element is the label.
"""
def __init__(self, items: list[MenuItem], font=None,
- max_width: int | None = None, max_height: int | None = None,
+ width: int | None = None, max_height: int | None = None,
text_halign: TextHAlign = TextHAlign.CENTRE,
auto_dismiss: bool = True, dismiss_option: bool = False,
- default_item: str | None = None, **kwargs) -> None:
+ default_item: str | None = None,
+ footer: Sequence[FooterSlot] | None = None, **kwargs) -> None:
self.max_height = max_height
- self.max_width = max_width
+ self.width = width
self.items: list[MenuItem] = items
self.auto_dismiss = auto_dismiss
- if auto_dismiss is False or dismiss_option is True:
+ self.footer: list[FooterSlot] = list(footer) if footer else []
+ if not any(self.footer) and (auto_dismiss is False or dismiss_option is True):
# without auto_dismiss provide a back arrow to close menu
self.items.append(('\u2b05', self._dismiss, None))
if font is None:
@@ -101,6 +119,9 @@ def __init__(self, items: list[MenuItem], font=None,
self.item_h: int = 0
self.text_halign = text_halign
self.default_item = default_item
+ # Typed mirror of the `data` attribute stashed on each row widget, so
+ # readers don't have to getattr their way back to the source item.
+ self._row_items: dict[object, MenuItem] = {}
super(Menu, self).__init__(width=0, height=0, **kwargs)
# Create item widgets
@@ -111,8 +132,41 @@ def __init__(self, items: list[MenuItem], font=None,
self.sel_widget(w)
h = h + self.item_h
+ self._build_footer(h)
self.refresh()
+ def _build_footer(self, y: int) -> None:
+ """Lay the footer out as an even grid. Buttons enter the selection list
+ last, so a rotate off the final item lands on them."""
+ if not any(self.footer):
+ return
+ columns = sum(1 if slot is None else slot.span for slot in self.footer)
+ col_w = (self.box.width - FOOTER_GAP * (columns + 1)) // columns
+ font = Config().get_font('small')
+ _, text_h = get_text_size('Close', font)
+ v_margin = max(0, (FOOTER_H - text_h) // 2)
+ col = 0
+ for slot in self.footer:
+ if slot is None:
+ col = col + 1
+ continue
+ b = Button(
+ box=Box.xywh(
+ FOOTER_GAP * (col + 1) + col_w * col,
+ y + FOOTER_GAP,
+ col_w * slot.span + FOOTER_GAP * (slot.span - 1),
+ FOOTER_H,
+ ),
+ text=slot.text,
+ font=font,
+ v_margin=v_margin,
+ outline_radius=4,
+ parent=self,
+ action=(lambda _e, _d, a=slot.action: a()),
+ )
+ self.add_sel_widget(b)
+ col = col + slot.span
+
def _make_row_widget(self, item: MenuItem, b: Box) -> TextWidget | RichTextWidget:
t = _item_label(item)
if isinstance(t, (str, BadgedLabel)):
@@ -131,9 +185,16 @@ def _make_row_widget(self, item: MenuItem, b: Box) -> TextWidget | RichTextWidge
parent=self, action=self._item_action)
# Stash the source item on the widget for `_item_action` to recover.
setattr(w, 'data', item)
+ self._row_items[w] = item
self.add_sel_widget(w)
return w
+ def selected_label(self) -> str | None:
+ """Label key of the row under the cursor. Menus that rebuild in place
+ use it to restore the selection across the rebuild."""
+ item = self._row_items.get(self.sel_ref)
+ return None if item is None else label_key(_item_label(item))
+
def _scroll_delta(self, box: Box, movex: int, movey: int, orig_box: Box):
# Vertical movement only, pixel-precise (no page-snap, no y0==0 reset)
return 0, movey
@@ -169,7 +230,7 @@ def _adjust_box(self):
# items. But we could just pile them on top of each other and move
# them once attached.
#
- w = 240
+ w = self.width if self.width is not None else DEFAULT_WIDTH
v_margin = 0
# Row height = max across all items so a tall rich row (e.g. a glyph
# bigger than the text line) doesn't get clipped. Strings measure via
@@ -189,10 +250,9 @@ def _adjust_box(self):
item_h = th
self.item_h = item_h
h = item_h * len(self.items)
- mw = self.max_width
+ if self.footer:
+ h = h + FOOTER_H + FOOTER_GAP * 2
mh = self.max_height
- if mw is not None and w > mw:
- w = 240
if mh is not None and h > mh:
# Content taller than viewport: enable JIT paint with a tall backing image
self.virtual = True
diff --git a/uv.lock b/uv.lock
index 09aacbb19..712c76990 100644
--- a/uv.lock
+++ b/uv.lock
@@ -462,6 +462,50 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
]
+[[package]]
+name = "dbus-fast"
+version = "5.0.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/1d/f6020228675338b7184ce5491e4535fb64c7badc0197151b9776f345a1a1/dbus_fast-5.0.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0926e4cf49989b4ec8233e8fd462eb35a640fcfe81bb75d91675dd47489022b", size = 693158, upload-time = "2026-06-05T18:55:49.874Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/84/dfb014de75a3a854dccaae1cce8f840e4312e3efc781768eedd60d25d9ef/dbus_fast-5.0.22-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846f9a6602b4383f989201f7851459fb225a8912cd24b38e63894748545c3040", size = 838836, upload-time = "2026-06-05T18:55:51.51Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c2/be41bcc678e97092d44ba22d09ce687f76c955b3367a7e6863377b1cfea5/dbus_fast-5.0.22-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886b43446b6fdc3986befbbb88db1365b14e49dd0a7edf84c2c67ac66c7160a4", size = 883163, upload-time = "2026-06-05T18:55:53Z" },
+ { url = "https://files.pythonhosted.org/packages/17/cf/336c08f88fdd813a39fc1603a10a15aec67115e08a895e7c9840df54d4d7/dbus_fast-5.0.22-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a699fca957acc845ddb12b47f741dba23ce147fdb93583e0c7e7bad3e9b2355", size = 886852, upload-time = "2026-06-05T18:55:54.434Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f1/7c1aa53f25252a2317f21ad6e8eaa125246d2585ea4611f3f10a6feaeeb1/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a5b05fd4973862042e5bee2c5e8c5a15297e0b33a975bf25b44becf7bcb3618", size = 846497, upload-time = "2026-06-05T18:55:56.193Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/32/a981ef2305f1bf41e538e02fa0cd69614f9043fd00ca965bf3044416c79b/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6c4dae5292a7924ec062815c34b49043d8386cd22e165f9fb4012de00997cdf1", size = 882275, upload-time = "2026-06-05T18:55:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/a0/78800a172f4ca32e19a70a36e175f54831f33a497c9644f3a3fa4dce01ea/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7d90a52be79acbaef257f3a81d5b9b9dec40f1bad29429ac5c7802684fb9b84", size = 890566, upload-time = "2026-06-05T18:55:59.407Z" },
+ { url = "https://files.pythonhosted.org/packages/52/81/ffc155f700c45191673e7f7620a28cbbbf5f116ff74a99f765895baa6f9c/dbus_fast-5.0.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f72b77be63f7bb24cf42936ad10994d40f43fed691f857f7854b5882d6a5227c", size = 690171, upload-time = "2026-06-05T18:56:01.151Z" },
+ { url = "https://files.pythonhosted.org/packages/24/06/233b0bc13919474f70320bf389cb81ce02811956d6cf86c45e84679b63c3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca", size = 799464, upload-time = "2026-06-05T18:56:02.617Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e9/77bc23a6f5aebfb8f2c34489795e8517aed7eca31738438e1a4c4a4891d3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", size = 852687, upload-time = "2026-06-05T18:56:04.433Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/04/56769d0936d1273d1801ef574ec426ccb3f61f4b0a7a0eeb9eb2b8ccafa5/dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c", size = 833814, upload-time = "2026-06-05T18:56:05.87Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ca/964f0d39a3be03b12a98f39519d34ad95b74360c7e3adf4cd4907dc25fd6/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b013437b66dc22b8d9aca5e0b0d46bf1980208a143409469fe482d9684a2a717", size = 806891, upload-time = "2026-06-05T18:56:07.623Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/25/57fe6ab509ad9da2e190498fa9c37f868e38ac521d940a9edb9ba6b6c657/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:855f15b7f7805171da2b82de1c317d01cfbb9fb8ac61fcc1e8dec54d8c69fab7", size = 830867, upload-time = "2026-06-05T18:56:09.473Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" },
+ { url = "https://files.pythonhosted.org/packages/25/1d/ebd02ae707328286c24582ac9189e4ed9c344399bc3b6a4b74e9687088eb/dbus_fast-5.0.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26e26b409e1e6edf5e2a4df8d192625fb38876b074fb5c7d0a5b15c5792e549d", size = 685921, upload-time = "2026-06-05T18:56:12.674Z" },
+ { url = "https://files.pythonhosted.org/packages/76/5a/fa81a6685c763ea488ad93228cb6e036adc9af6a560f4c31643691f4cfd8/dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5", size = 798472, upload-time = "2026-06-05T18:56:14.141Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/34/6b272e6df60be1aa4d575aa30220175a52c002a649c951d9950bfa3a72d6/dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb", size = 850312, upload-time = "2026-06-05T18:56:16.36Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/5c/9045c3595ddcd4069e6b5d051df06bf11a2b022592f721c9acea1e0e4d22/dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08", size = 828366, upload-time = "2026-06-05T18:56:17.786Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/78/ca6881442b8fa29edbe6d99bec4b535b0b2e2f423075d015ff5b719c4e2c/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979", size = 806036, upload-time = "2026-06-05T18:56:19.501Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c6/458728eb1caa26171e6a8ae1d0d99bd29aaeac67ad7824bbd95d7f854a41/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec", size = 828353, upload-time = "2026-06-05T18:56:21.346Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/1d/830b1569264780210d44898e5b0d95cffe2830b952c2ee21ea481274cd81/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84", size = 857743, upload-time = "2026-06-05T18:56:22.9Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b6/171f8e92775254e2f42afe9e7501a57ace47f32de2969cb694a375dc9dce/dbus_fast-5.0.22-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:511655d915692f55b8c5f5a535acf80cca9c6d1a35384db7fcfdabaae05dd837", size = 692986, upload-time = "2026-06-05T18:56:24.661Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" },
+ { url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" },
+ { url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/dd/61086156a1c2d8ffd04d61a232debbaff8ca9fc1cf598476999e1f06164d/dbus_fast-5.0.22-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69a077c296eaab8c30160e861b5514c33d99d67d41d17dbf02e89aae44543b11", size = 1353993, upload-time = "2026-06-05T18:56:36.849Z" },
+ { url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" },
+ { url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" },
+ { url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" },
+]
+
[[package]]
name = "fonttools"
version = "4.61.1"
@@ -854,6 +898,7 @@ name = "pi-stomp"
version = "3.0.0"
source = { editable = "." }
dependencies = [
+ { name = "dbus-fast" },
{ name = "gpiozero", marker = "sys_platform == 'linux'" },
{ name = "jack-client" },
{ name = "jsonschema" },
@@ -899,6 +944,7 @@ requires-dist = [
{ name = "adafruit-circuitpython-mcp3xxx", marker = "extra == 'hardware'", specifier = ">=1.4" },
{ name = "adafruit-circuitpython-neopixel", marker = "extra == 'hardware'", specifier = ">=6.3" },
{ name = "adafruit-circuitpython-rgb-display", marker = "extra == 'hardware'", specifier = "==3.14.3" },
+ { name = "dbus-fast", specifier = ">=2.21" },
{ name = "gfxhat", marker = "sys_platform == 'linux' and extra == 'hardware'", specifier = ">=0.0.1" },
{ name = "gpiozero", marker = "sys_platform == 'linux'", specifier = ">=2.0" },
{ name = "jack-client", specifier = ">=0.5.5" },