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" },