diff --git a/CHANGELOG.md b/CHANGELOG.md index 5441fd88c..6bfc2c940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] ### Fixed +- Long titles scrolled slightly past their last pixel before bouncing back; the scroll window now matches the drawn text area exactly +- Bluetooth: nearby devices switched off mid-scan no longer linger as unresponsive "ghost" rows; they leave the list when their last advertisement goes stale +- Bluetooth: a known device that is off or out of range now shows just its name, like a saved wifi network, instead of the non-applicable "press its button" hint - The parameter dialog on the LCD sometimes did not change values due to a race condition with respect to MOD-UI's `last.json`. pi-Stomp then did not send parameter changes to MOD-UI until you selected a different pedalboard. Parameters on a knob or an encoder continued to work, because they send MIDI CC. - The LCD showed a bypass that MOD-UI did not receive, if you tapped it while a pedalboard loaded. The LCD now keeps the last value that MOD-UI confirmed. - A parameter change from a plugin menu could be lost if the connection to MOD-UI was busy. pi-Stomp now sends the value again on the next cycle. diff --git a/common/command_queue.py b/common/command_queue.py new file mode 100644 index 000000000..a7864da5a --- /dev/null +++ b/common/command_queue.py @@ -0,0 +1,127 @@ +# 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 + +from common.util import TEARDOWN_JOIN_S + +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, join: bool = True) -> None: + self._cmd_queue.put(_SHUTDOWN_SENTINEL) + if join: + self._worker.join(timeout=TEARDOWN_JOIN_S) diff --git a/emulator/modhandler.py b/emulator/modhandler.py index 7dbf410fc..12cdca68a 100644 --- a/emulator/modhandler.py +++ b/emulator/modhandler.py @@ -33,7 +33,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): @@ -57,6 +63,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 c31faf395..1c1322ca3 100644 --- a/emulator/stubs.py +++ b/emulator/stubs.py @@ -19,6 +19,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. """ @@ -27,10 +28,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 @@ -255,6 +258,193 @@ 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._enabled: bool = True + self._capable: bool = True + self._discovering: bool = False + self._known: list[KnownDevice] = [] + self._devices: dict[str, BtDevice] = {} + self._off: set[str] = set() # addresses whose device stopped advertising + self._off_at: float = 0.0 # monotonic time it went dark + self.queue: CommandQueue = CommandQueue(self) + + STALE_AFTER_S = 15.0 # mirrors BluezClient._STALE_AFTER_S + + def power_cycle(self, address: Optional[str] = None) -> None: + """Simulate switching a device off mid-scan: it stops advertising, so + it must vanish from the nearby list even though discovery keeps + running (exactly what a real bluez never signals).""" + live = [a for a, d in self._devices.items() if not d["paired"]] + if address is None: + address = live[0] if live else None + if address is not None: + self._off.add(address) + self._off_at = time.monotonic() + self.request_refresh() + + def _revive(self, address: Optional[str] = None) -> None: + """Device powered back on: advertise again.""" + if address is None: + self._off.clear() + else: + self._off.discard(address) + self.request_refresh() + + # ----- 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: + """Inherited poll() recomputes unconditionally; nothing to arm.""" + return + + def shutdown(self) -> None: + try: + self.queue.shutdown() + except Exception: + pass + + def devices(self) -> list[BtDevice]: + now = time.monotonic() + if self._off and now - self._off_at > self.STALE_AFTER_S: + # Evict the ghosts, but keep the off set itself: the device is + # still powered off, so a later scan must not resurrect it. + self._devices = {a: d for a, d in self._devices.items() if a not in self._off} + self._off_at = now + 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 start_discovery(self) -> None: + self._discovering = True + for device in self._NEARBY: + if device["address"] in self._off: + continue # powered off: nothing advertises, bluez has no object + 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/emulator/window.py b/emulator/window.py index e823e2eb9..1f08d0a6e 100644 --- a/emulator/window.py +++ b/emulator/window.py @@ -41,8 +41,8 @@ from uilib.pygame_init import font as _make_font -_FONTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "fonts") -_FONT_MONO = os.path.join(_FONTS_DIR, "DejaVuSansMono.ttf") +_FONTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "fonts") +_FONT_MONO = os.path.join(_FONTS_DIR, "DejaVuSansMono.ttf") _FONT_MONO_BOLD = os.path.join(_FONTS_DIR, "DejaVuSansMono-Bold.ttf") @@ -56,22 +56,23 @@ def render(self, text, antialias, color): surf, _ = self._ft.render(text, color) return surf + # ---- dimensions (per-instance; these module-level values are defaults) ------ -CTRL_W = 300 -_TARGET_H = 480 # desired display area height — scale is computed to match +CTRL_W = 300 +_TARGET_H = 480 # desired display area height — scale is computed to match # ---- colours ---------------------------------------------------------------- -BG = (30, 30, 30) -PANEL_BG = (45, 45, 45) -BTN_IDLE = (80, 80, 80) -BTN_HOVER = (120, 120, 120) -BTN_ACTIVE = (200, 200, 200) -FS_ON = (0, 200, 80) -FS_OFF = (80, 80, 80) -TEXT_COLOR = (220, 220, 220) -DIM_TEXT = (130, 130, 130) -SLIDER_BG = (60, 60, 60) -SLIDER_FG = (0, 160, 200) +BG = (30, 30, 30) +PANEL_BG = (45, 45, 45) +BTN_IDLE = (80, 80, 80) +BTN_HOVER = (120, 120, 120) +BTN_ACTIVE = (200, 200, 200) +FS_ON = (0, 200, 80) +FS_OFF = (80, 80, 80) +TEXT_COLOR = (220, 220, 220) +DIM_TEXT = (130, 130, 130) +SLIDER_BG = (60, 60, 60) +SLIDER_FG = (0, 160, 200) class _Label: @@ -89,10 +90,10 @@ class _Btn: """Simple clickable rectangle.""" def __init__(self, rect, label, action, font): - self.rect = pygame.Rect(rect) - self.label = label + self.rect = pygame.Rect(rect) + self.label = label self.action = action - self.font = font + self.font = font self._hover = False def draw(self, surf, active=False): @@ -111,7 +112,6 @@ def handle_event(self, event): class EmulatorWindow: - def __init__(self, hardware): self.hw = hardware self.running = True @@ -127,15 +127,15 @@ def __init__(self, hardware): self.screen = pygame.display.set_mode((self.win_w, self.win_h)) pygame.key.set_repeat(300, 50) - version_label = getattr(hardware, 'VERSION_LABEL', '') + version_label = getattr(hardware, "VERSION_LABEL", "") title = "pi-Stomp Emulator (%s)" % version_label if version_label else "pi-Stomp Emulator" pygame.display.set_caption(title) - self.font_sm = _FTFont(_FONT_MONO, 13) + self.font_sm = _FTFont(_FONT_MONO, 13) self.font_med = _FTFont(_FONT_MONO_BOLD, 15) self.font_hdr = _FTFont(_FONT_MONO_BOLD, 14) - self._exp_value = 64 # 0-127 MIDI value for expression pedal + self._exp_value = 64 # 0-127 MIDI value for expression pedal self._exp_dragging = False self._buttons: list[_Btn] = [] @@ -149,7 +149,7 @@ def __init__(self, hardware): def _build_ui(self): y = 15 - _bw, _bh = 60, 30 # default button size + _bw, _bh = 60, 30 # default button size # --- Footswitches ---------------------------------------------------- num_fs = len(self.hw.footswitches) @@ -157,10 +157,7 @@ def _build_ui(self): for i, fs in enumerate(self.hw.footswitches): x = self.ctrl_x + 5 + i * fs_spacing idx = i - btn = _Btn((x, y, 56, 46), - "FS%d" % (i + 1), - lambda fs=fs: fs.press(), - self.font_med) + btn = _Btn((x, y, 56, 46), "FS%d" % (i + 1), lambda fs=fs: fs.press(), self.font_med) self._buttons.append(btn) self._fs_btns.append((btn, idx)) @@ -174,15 +171,14 @@ def _build_ui(self): enc_y += 8 self._exp_slider_y = enc_y + 10 - self._exp_slider_rect = pygame.Rect( - self.ctrl_x + 5, self._exp_slider_y + 16, CTRL_W - 20, 12) + self._exp_slider_rect = pygame.Rect(self.ctrl_x + 5, self._exp_slider_y + 16, CTRL_W - 20, 12) def _enc_label(self, enc): - if hasattr(enc, 'midi_CC') and enc.midi_CC is not None: + if hasattr(enc, "midi_CC") and enc.midi_CC is not None: return "Enc %s (CC%d)" % (enc.id, enc.midi_CC) - if getattr(enc, 'type', None) == 'VOLUME': + if getattr(enc, "type", None) == "VOLUME": return "Vol (enc %s)" % enc.id - if getattr(enc, 'label', None) is not None: + if getattr(enc, "label", None) is not None: return enc.label return "Nav" @@ -191,28 +187,21 @@ def _add_encoder_row(self, enc, label, y): y += 15 bw, bh = 38, 28 - has_press = getattr(enc, 'press_callback', None) is not None + has_press = getattr(enc, "press_callback", None) is not None - left_x = self.ctrl_x + 5 - mid_x = left_x + bw + 4 + left_x = self.ctrl_x + 5 + mid_x = left_x + bw + 4 right_x = mid_x + (bw + 4 if has_press else 0) - self._buttons.append(_Btn( - (left_x, y, bw, bh), "◄", - lambda e=enc: e.step(-1), self.font_med)) + self._buttons.append(_Btn((left_x, y, bw, bh), "◄", lambda e=enc: e.step(-1), self.font_med)) if has_press: - self._buttons.append(_Btn( - (mid_x, y, bw, bh), "●", - lambda e=enc: e.press(switchstate.Value.RELEASED), - self.font_med)) - self._buttons.append(_Btn( - (right_x, y, bw, bh), "►", - lambda e=enc: e.step(1), self.font_med)) + self._buttons.append( + _Btn((mid_x, y, bw, bh), "●", lambda e=enc: e.press(switchstate.Value.RELEASED), self.font_med) + ) + self._buttons.append(_Btn((right_x, y, bw, bh), "►", lambda e=enc: e.step(1), self.font_med)) else: - self._buttons.append(_Btn( - (mid_x, y, bw, bh), "►", - lambda e=enc: e.step(1), self.font_med)) + self._buttons.append(_Btn((mid_x, y, bw, bh), "►", lambda e=enc: e.step(1), self.font_med)) return y + bh + 2 @@ -279,14 +268,16 @@ def render(self): num_fs = len(self.hw.footswitches) if num_fs: hints.append("1-%d footswitches" % num_fs) - tweak = getattr(self.hw, 'tweak_encoders', []) - vol = getattr(self.hw, 'volume_encoder', None) + tweak = getattr(self.hw, "tweak_encoders", []) + vol = getattr(self.hw, "volume_encoder", None) if len(tweak) >= 1: hints.append("Q/W enc1 E=press") if len(tweak) >= 2: hints[-1] += " A/S enc2 D=press" if vol is not None: hints.append("Z/X vol enc") + if getattr(getattr(self.hw, "handler", None), "bluetooth_manager", None) is not None: + hints.append("b/shift-b bluetooth device off/on") if self.hw.analog_controls: hints.append("↑↓ expr pedal Esc=quit") else: @@ -310,8 +301,7 @@ def _draw_exp_slider(self): pygame.draw.rect(self.screen, SLIDER_BG, r, border_radius=4) fill_w = int(r.width * self._exp_value / 127) if fill_w > 0: - pygame.draw.rect(self.screen, SLIDER_FG, - (r.x, r.y, fill_w, r.height), border_radius=4) + pygame.draw.rect(self.screen, SLIDER_FG, (r.x, r.y, fill_w, r.height), border_radius=4) tx = r.x + fill_w pygame.draw.circle(self.screen, TEXT_COLOR, (tx, r.centery), 7) @@ -320,13 +310,24 @@ def _draw_exp_slider(self): # ------------------------------------------------------------------------- def _handle_key(self, key, mod): - nav = getattr(self.hw, 'nav_encoder', None) - tweak = getattr(self.hw, 'tweak_encoders', []) - vol = getattr(self.hw, 'volume_encoder', None) + nav = getattr(self.hw, "nav_encoder", None) + tweak = getattr(self.hw, "tweak_encoders", []) + vol = getattr(self.hw, "volume_encoder", None) + bt = getattr(getattr(self.hw, "handler", None), "bluetooth_manager", None) if key == pygame.K_ESCAPE: raise KeyboardInterrupt + # Bluetooth: simulate switching a nearby device off (b) / back on + # (shift-b). Exercises the ghost-eviction path: a real bluez keeps + # the Device1 object for the whole scan with its last RSSI, never + # signalling the device has gone dark. + elif key == pygame.K_b and bt is not None: + if mod & pygame.KMOD_SHIFT: + bt._revive() + else: + bt.power_cycle() + # Nav encoder elif key == pygame.K_LEFT and nav: nav.step(-1) diff --git a/modalapi/bluetooth/__init__.py b/modalapi/bluetooth/__init__.py new file mode 100644 index 000000000..b113066ad --- /dev/null +++ b/modalapi/bluetooth/__init__.py @@ -0,0 +1,44 @@ +# 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, + 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", + "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..ac4d037dd --- /dev/null +++ b/modalapi/bluetooth/bluez.py @@ -0,0 +1,353 @@ +# 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, 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 +# bluez keeps an unpaired Device1 object for as long as discovery runs, even +# after the device has gone dark; staleness is the only signal we get. +_STALE_AFTER_S = 15.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: + # path -> monotonic time of the last advertisement seen from it + self._seen: dict[str, float] = {} + 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._lifecycle = threading.Lock() + self._started = False + + # ----- lifecycle ----- + + def start(self) -> bool: + """Bring up the loop thread and connect. Blocks until the first + GetManagedObjects lands (or setup fails). Returns available().""" + with self._lifecycle: + if self._started: + return self.available + self._started = True + 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() + self._seen.clear() + self._adapter_path = None + self._adapter_props = {} + now = time.monotonic() + 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]) + self._seen[path] = now + + def stop(self, join: bool = True) -> None: + """Tear down completely so start() can bring up a fresh connection.""" + with self._lifecycle: + loop = self._loop + if loop is not None: + try: + loop.call_soon_threadsafe(loop.stop) + except RuntimeError: + pass # already closed + if join and 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._seen.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 + 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]) + self._seen[path] = time.monotonic() + 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: + self._seen.pop(path, None) + self._devices.pop(path, 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) + # RSSI/TxPower arrive on every advertisement; any other + # property change says nothing about radio presence, so + # only these refresh the heartbeat. + if "RSSI" in props or "TxPower" in props: + self._seen[path] = time.monotonic() + elif iface == ADAPTER_IFACE and path == self._adapter_path: + self._adapter_props.update(props) + 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, now: Optional[float] = None) -> list[BtDevice]: + """Every device bluez currently knows, filtered to MIDI/HID/paired. + + Unpaired, unconnected device objects whose last advertisement is + older than _STALE_AFTER_S are withheld: bluez keeps such objects for + the whole of a discovery run even after the device has gone dark, + and nothing else ever signals their disappearance. Paired devices + persist in /var/lib/bluetooth and don't advertise when idle, so + staleness is never held against them.""" + if now is None: + now = time.monotonic() + with self._lock: + items = list(self._devices.items()) + seen = dict(self._seen) + out: list[BtDevice] = [] + for path, props in items: + if not is_interesting(props): + continue + if not (props.get("Paired") or props.get("Connected")): + if now - seen.get(path, now) > _STALE_AFTER_S: + 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..fc2c7d492 --- /dev/null +++ b/modalapi/bluetooth/commands.py @@ -0,0 +1,104 @@ +# 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 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:start" + + +@dataclass +class StopDiscoveryCmd(Command[None]): + def run(self, mgr: "BluetoothManager") -> None: + mgr.stop_discovery() + + def key(self) -> str: + return "discovery:stop" + + +@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..9fe42e5a4 --- /dev/null +++ b/modalapi/bluetooth/manager.py @@ -0,0 +1,277 @@ +# 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, which the client folds into its device table. poll() + still runs every 2s — ghost eviction is time-driven, so the device list + has to change with no signal from bluez at all — and dedupes on a + content signature so unchanged polls publish nothing.""" + + 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._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(): + self.client.call(ops.power_on(self.client)) + self._probed = True + + def request_refresh(self) -> None: + """CommandQueue nudges this after a write op; poll() recomputes + unconditionally, so there is nothing to arm.""" + return + + @property + def supported(self) -> bool: + """Hardware present, judged by an hci node in sysfs rather than board + model — Pi 3/4 do register one, since config.txt gives Bluetooth the + mini UART rather than disabling it. The node exists whether or not + bluetoothd runs, which is what lets the menu offer to turn it 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. + + Always recomputed: ghost eviction is time-driven, so the device list + has to change with no signal from bluez at all.""" + self.queue.poll() + 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(join=False) + except Exception: + pass + self.client.stop(join=False) + + # ----- 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]: + """Menu on/off owns the radio, not just the daemon. Down: power off + over D-Bus while bluetoothd still answers, drop the client, stop the + unit, re-apply the rfkill block. Up: unblock, start, power on.""" + if not enabled: + if self.client.available: + try: + self.client.call(ops.power_off(self.client)) + except Exception as e: + # Best-effort; the rfkill block below is the guarantee. + logging.warning("Bluetooth power-off failed: %s", e) + # Drop the connection before the daemon goes away, so nothing is + # left holding object paths that stop existing. + self.client.stop() + else: + ops.rfkill_set_blocked(False) + 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: + if self.client.start(): + self.client.call(ops.power_on(self.client)) + else: + err = ops.rfkill_set_blocked(True) + if err is not None: + logging.warning("Bluetooth rfkill block failed: %s", err) + return None + + 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..ff1be45e5 --- /dev/null +++ b/modalapi/bluetooth/ops.py @@ -0,0 +1,236 @@ +# 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 + +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 rfkill_set_blocked(blocked: bool) -> Optional[str]: + """Mirror of pistomp-bluetooth's drop-in soft-unblock, both directions, so + "off" means off even if bluetoothd exited without powering down. Writes + sysfs because the image ships no rfkill(8); trailing `true` tolerates a + board with no bluetooth rfkill node.""" + script = 'for f in /sys/class/rfkill/*; do [ "$(cat $f/type)" = bluetooth ] && echo %d > $f/soft; done; true' % ( + 1 if blocked else 0 + ) + rc, out = _run(["sh", "-c", script], timeout=10, sudo=True) + return None if rc == 0 else out + + +# ----- adapter ----- + + +async def _set_adapter_flag(client: BluezClient, name: str, value: bool = True) -> 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", value)) + 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 power_off(client: BluezClient) -> None: + """Take the radio down. Must run while bluetoothd is still alive: the + adapter's D-Bus object disappears with the daemon. Discoverable and + Pairable go first so nothing can pair in the gap; neither is fatal.""" + for name in ("Discoverable", "Pairable"): + try: + await _set_adapter_flag(client, name, False) + except DBusError as e: + logging.warning("Bluetooth: clearing %s failed: %s", name, e) + await _set_adapter_flag(client, "Powered", False) + + +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 a18ffcee1..8f3e9ea41 100644 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -67,6 +67,7 @@ from modalapi.pedalboard import BPM_SYMBOL, BPB_SYMBOL, ROLLING_SYMBOL import modalapi.usb as usb 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 @@ -168,6 +169,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" @@ -210,6 +212,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() @@ -268,6 +273,7 @@ def cleanup(self): self.ws_bridge.stop() logging.info("WebSocket bridge stopped") self.ethernet_manager.shutdown() + self.bluetooth_manager.shutdown() def _rest_get(self, url: str) -> Response | None: try: @@ -593,6 +599,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 @@ -614,6 +625,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 cdc0f0544..5aada0f10 100644 --- a/modalapi/wifi/__init__.py +++ b/modalapi/wifi/__init__.py @@ -15,9 +15,9 @@ # You should have received a copy of the GNU Affero 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 09da43b09..4c086f069 100644 --- a/modalapi/wifi/commands.py +++ b/modalapi/wifi/commands.py @@ -15,31 +15,15 @@ # You should have received a copy of the GNU Affero 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 -from common.util import TEARDOWN_JOIN_S 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]]): @@ -127,80 +111,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=TEARDOWN_JOIN_S) diff --git a/modalapi/wifi/manager.py b/modalapi/wifi/manager.py index 9683c473e..b4a96ea69 100644 --- a/modalapi/wifi/manager.py +++ b/modalapi/wifi/manager.py @@ -20,10 +20,10 @@ import threading from typing import Callable, Optional +from common.command_queue import CommandQueue from common.util import TEARDOWN_JOIN_S 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 3ac03ac97..1bfa90f89 100755 --- a/modalapistomp.py +++ b/modalapistomp.py @@ -220,6 +220,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 5936a477f..ed319599e 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -29,6 +29,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.footswitch_menu import FootswitchMenu from ui.wifi_menu import WifiMenu @@ -226,6 +227,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) # # Main @@ -405,7 +407,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: @@ -542,7 +544,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 @@ -566,7 +577,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 bd4611c28..3f08cad7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "gpiozero>=2.0; sys_platform == 'linux'", "pygame-ce>=2.5.7", "qrcode>=8.0", + "dbus-fast>=5.0", "msgspec>=0.21.1", ] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3d2cb5444..e5ab17aff 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -57,12 +57,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_says_so_and_offers_nothing/root_needs_package.png b/tests/snapshots/v3/test_bluetooth_menu/test_incapable_image_says_so_and_offers_nothing/root_needs_package.png new file mode 100644 index 000000000..2152130fe Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_incapable_image_says_so_and_offers_nothing/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_known_but_absent_device_shows_just_its_name/root_known_absent.png b/tests/snapshots/v3/test_bluetooth_menu/test_known_but_absent_device_shows_just_its_name/root_known_absent.png new file mode 100644 index 000000000..d709386d4 Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_known_but_absent_device_shows_just_its_name/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..367408730 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_power_toggle_shows_wait_then_settles/root_on.png b/tests/snapshots/v3/test_bluetooth_menu/test_power_toggle_shows_wait_then_settles/root_on.png new file mode 100644 index 000000000..2bdcbfa3a Binary files /dev/null and b/tests/snapshots/v3/test_bluetooth_menu/test_power_toggle_shows_wait_then_settles/root_on.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..ee868742f --- /dev/null +++ b/tests/test_bluetooth_manager.py @@ -0,0 +1,442 @@ +"""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 +import time +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 bluez +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_when_no_hci_node_is_registered(tmp_path): + """An empty sysfs dir — a board whose controller never attached. Not the + Pi 3/4 case: they give Bluetooth the mini UART and do register hci0.""" + 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 + +# ----- ghost eviction: stale unpaired devices must vanish ----- + + +def _msg(interface, member, body, path=""): + """A bare dbus_fast Message shaped like a bluez signal.""" + from dbus_fast import Message, MessageType + + return Message( + message_type=MessageType.SIGNAL, + interface=interface, + member=member, + path=path, + body=body, + ) + + +def _client_with_ghost(): + """A BluezClient whose adapter is up and whose device table holds one + unpaired device. The device object stays in the table — that's what + bluez does mid-discovery after the device has gone dark — but the + snapshot must stop listing it once it is stale.""" + client = bluez.BluezClient() + with client._lock: + client._adapter_path = "/org/bluez/hci0" + client._devices["/org/bluez/hci0/dev_X"] = { + "Address": "AA:BB:CC:DD:EE:FF", + "Name": "Ghost Pedal", + "UUIDs": [MIDI_UUID], + "Paired": False, + "Connected": False, + } + return client + + +def test_stale_unpaired_device_is_withheld_from_snapshot(): + client = _client_with_ghost() + client._seen["/org/bluez/hci0/dev_X"] = time.monotonic() - bluez._STALE_AFTER_S - 1 + assert client.snapshot() == [] + + +def test_fresh_unpaired_device_stays_in_snapshot(): + client = _client_with_ghost() + client._seen["/org/bluez/hci0/dev_X"] = time.monotonic() + assert [d["name"] for d in client.snapshot()] == ["Ghost Pedal"] + + +def test_paired_device_never_goes_stale(): + """A bonded device idle with discovery off doesn't advertise either — + staleness must not mark it absent and cost the user their connect row.""" + client = _client_with_ghost() + with client._lock: + client._devices["/org/bluez/hci0/dev_X"]["Paired"] = True + client._seen["/org/bluez/hci0/dev_X"] = time.monotonic() - bluez._STALE_AFTER_S * 10 + assert [d["name"] for d in client.snapshot()] == ["Ghost Pedal"] + + +def test_connected_device_never_goes_stale(): + client = _client_with_ghost() + with client._lock: + client._devices["/org/bluez/hci0/dev_X"]["Connected"] = True + client._seen["/org/bluez/hci0/dev_X"] = time.monotonic() - bluez._STALE_AFTER_S * 10 + assert [d["name"] for d in client.snapshot()] == ["Ghost Pedal"] + + +def test_rssi_change_refreshes_the_heartbeat(): + """PropertiesChanged carrying RSSI is the advertisement heartbeat; the + same signal without RSSI (e.g. a ServicesResolved flip) must not.""" + client = _client_with_ghost() + path = "/org/bluez/hci0/dev_X" + old = time.monotonic() - bluez._STALE_AFTER_S - 1 + client._seen[path] = old + client._on_signal(_msg("org.freedesktop.DBus.Properties", "PropertiesChanged", ["org.bluez.Device1", {"RSSI": -50}], path=path)) + assert client._seen[path] > old + assert [d["name"] for d in client.snapshot()] == ["Ghost Pedal"] + + +def test_non_rssi_change_does_not_refresh_the_heartbeat(): + client = _client_with_ghost() + path = "/org/bluez/hci0/dev_X" + client._seen[path] = time.monotonic() - bluez._STALE_AFTER_S - 1 + client._on_signal( + _msg("org.freedesktop.DBus.Properties", "PropertiesChanged", ["org.bluez.Device1", {"ServicesResolved": True}], path=path) + ) + assert client.snapshot() == [] + + +def test_interfaces_added_marks_device_fresh(): + client = _client_with_ghost() + client._seen["/org/bluez/hci0/dev_X"] = time.monotonic() - bluez._STALE_AFTER_S - 1 + client._on_signal( + _msg( + "org.freedesktop.DBus.ObjectManager", + "InterfacesAdded", + ["/org/bluez/hci0/dev_X", {"org.bluez.Device1": {"Name": "Ghost Pedal", "UUIDs": [MIDI_UUID]}}], + path="/org/bluez/hci0", + ) + ) + assert [d["name"] for d in client.snapshot()] == ["Ghost Pedal"] + + +def test_interfaces_removed_clears_the_heartbeat(): + client = _client_with_ghost() + path = "/org/bluez/hci0/dev_X" + client._seen[path] = time.monotonic() + client._on_signal( + _msg("org.freedesktop.DBus.ObjectManager", "InterfacesRemoved", [path, ["org.bluez.Device1"]], path="/org/bluez/hci0") + ) + assert client.snapshot() == [] + assert path not in client._seen + + +# ----- 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 + + +# ----- enable/disable owns the radio, not just the daemon ----- + + +class _FakeClient: + """Records the order of everything set_enabled does to the adapter.""" + + def __init__(self, log, available=True): + self._log = log + self.available = available + self.powered = True + + def call(self, coro): + # Log the verb the manager handed us, so on/off cases read differently. + name = getattr(coro, "__name__", "adapter_call") + coro.close() # never awaited in this fake + self._log.append(name) + + def stop(self, join=True): + self._log.append("client.stop") + + def start(self, on_change=None): + self._log.append("client.start") + return True + + +def _disable_with_fakes(manager, client_available=True): + log = [] + manager.client = _FakeClient(log, available=client_available) + with ( + patch.object(ops, "power_off", lambda c: _named_coro("power_off")), + patch.object(ops, "disable_service", lambda: log.append("disable_service")), + patch.object(ops, "rfkill_set_blocked", lambda b: log.append("rfkill_block" if b else "rfkill_unblock")), + ): + assert manager.set_enabled(False) is None + return log + + +def _named_coro(name): + """A throwaway coroutine whose __name__ the fake client logs.""" + + async def _c(): + return None + + _c.__name__ = name + return _c() + + +def test_disable_powers_the_radio_off_before_the_daemon_goes_away(manager): + """The adapter's D-Bus object disappears with bluetoothd, so a power-off + issued after stop/disable silently does nothing and the radio stays up.""" + log = _disable_with_fakes(manager) + assert log == ["power_off", "client.stop", "disable_service", "rfkill_block"] + + +def test_disable_still_blocks_the_radio_when_dbus_is_unavailable(manager): + """No bluez connection means no D-Bus power-off; the rfkill block is the + guarantee.""" + log = _disable_with_fakes(manager, client_available=False) + assert log == ["client.stop", "disable_service", "rfkill_block"] + + +def test_disable_reports_the_error_and_does_not_block_when_the_unit_fails(manager): + log = [] + manager.client = _FakeClient(log) + with ( + patch.object(ops, "power_off", lambda c: _named_coro("power_off")), + patch.object(ops, "disable_service", lambda: "Failed to disable unit"), + patch.object(ops, "rfkill_set_blocked", lambda b: log.append("rfkill")), + ): + assert manager.set_enabled(False) == "Failed to disable unit" + assert "rfkill" not in log + assert manager._enabled is False + + +def test_enable_unblocks_the_radio_before_starting_the_unit(manager): + """The adapter boots soft-blocked; starting bluetoothd against a blocked + radio leaves it off-blocked.""" + log = [] + manager.client = _FakeClient(log) + with ( + patch.object(ops, "power_on", lambda c: _named_coro("power_on")), + patch.object(ops, "enable_service", lambda: log.append("enable_service")), + patch.object(ops, "rfkill_set_blocked", lambda b: log.append("rfkill_block" if b else "rfkill_unblock")), + ): + assert manager.set_enabled(True) is None + assert log == ["rfkill_unblock", "enable_service", "client.start", "power_on"] + assert "rfkill_block" not in log diff --git a/tests/test_handler_cleanup.py b/tests/test_handler_cleanup.py index 1068516c7..d45183313 100644 --- a/tests/test_handler_cleanup.py +++ b/tests/test_handler_cleanup.py @@ -16,6 +16,7 @@ def test_cleanup_closes_external_midi(self): h._hardware = None h.external_midi = MagicMock() h.ethernet_manager = MagicMock() + h.bluetooth_manager = MagicMock() h.ws_bridge = MagicMock() h.cleanup() h.external_midi.close.assert_called_once() diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py index 8b559da52..ee6f70f2b 100644 --- a/tests/test_lcd320x240.py +++ b/tests/test_lcd320x240.py @@ -111,6 +111,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 c59df59fd..900ebbb26 100644 --- a/tests/v3/conftest.py +++ b/tests/v3/conftest.py @@ -12,6 +12,7 @@ import common.token as Token from pistomp.controller import ControlType 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 @@ -345,6 +346,77 @@ 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 + _devices = list(devices) + _known = list(known) + mgr.devices.return_value = _devices + mgr.known_devices.return_value = _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..d0feec1f9 --- /dev/null +++ b/tests/v3/test_bluetooth_menu.py @@ -0,0 +1,376 @@ +"""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_power_toggle_shows_wait_then_settles(v3_system, bluetooth_state, snapshot): + """The toggle takes tens of seconds; the menu must say so instead of + ignoring the click, then return to normal when it lands.""" + deferred: list = [] + bluetooth_state(enabled=False, deferred=deferred) + lcd = _open(v3_system) + _click_row(lcd, "Turn Bluetooth on") + menu = lcd.pstack.current + labels = _labels(menu) + assert "Turning Bluetooth on…" in labels + assert "Turn Bluetooth on" not in labels, "the toggle row must be replaced while in flight" + + # Second tap while waiting: no second PowerCmd is submitted — the queue + # dedupes by key, and the wait row carries no action anyway. + assert sum(type(c).__name__ == "PowerCmd" for c, _ in deferred) == 1 + + _, on_done = deferred.pop() + on_done(None) + # Success publishes fresh status; the test harness has no poll loop, so + # drive the publish the way poll() would. + v3_system.handler._on_bluetooth_status_change( + {"supported": True, "capable": True, "enabled": True, "powered": True, "discovering": False, "connected": []} + ) + menu = lcd.pstack.current + assert "Turning Bluetooth on…" not in _labels(menu) + assert "Nearby devices..." in _labels(menu) + snapshot("root_on") + + +def test_power_toggle_failure_clears_wait_and_shows_error(v3_system, bluetooth_state): + deferred: list = [] + bluetooth_state(enabled=False, deferred=deferred) + lcd = _open(v3_system) + _click_row(lcd, "Turn Bluetooth on") + assert "Turning Bluetooth on…" in _labels(lcd.pstack.current) + + _, on_done = deferred.pop() + on_done("sudo: a password is required") + menu = lcd.pstack.current + assert not isinstance(menu, Menu), "a failure must raise a dialog over the menu" + text = " ".join( + w.text for w in getattr(menu, "sel_list", []) + getattr(menu, "widgets", []) if hasattr(w, "text") + ) + assert "password" in text + + +def test_incapable_image_says_so_and_offers_nothing(v3_system, bluetooth_state, snapshot): + bluetooth_state(capable=False) + lcd = _open(v3_system) + labels = _labels(lcd.pstack.current) + assert "Please install pistomp-bluetooth" in labels + assert not any("Install" in label for label in labels) + assert "Nearby devices..." not in labels + snapshot("root_needs_package") + + +def test_known_but_absent_device_shows_just_its_name(v3_system, bluetooth_state, snapshot): + """A known device bluez can't see shows the plain name, like a saved + wifi network that's out of range — no prescriptive hint. Tapping the + row is what starts the search-and-pair flow.""" + bluetooth_state(devices=[], known=[make_bt_known()]) + lcd = _open(v3_system) + menu = lcd.pstack.current + labels = [label.strip() for label in _labels(menu)] + assert "EV-1-WL" in labels + assert not any("press its" in label for label in labels) + 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) + + +def test_ghost_device_vanishes_from_nearby_once_stale(v3_system, bluetooth_state): + """A device switched off mid-scan: bluez keeps its object with the last + RSSI and never signals the disappearance, so the manager's staleness + eviction is the only thing that drops the row. What the user must see: + it disappears from the nearby list instead of sitting there as a + tap-target for a doomed Pair().""" + mgr = bluetooth_state(devices=[make_bt_device()]) + lcd = _open(v3_system) + _click_row(lcd, "Nearby devices") + assert any("EV-1-WL" in label for label in _labels(lcd.pstack.current)) + + # The device went dark: the next poll's device list no longer has it. + mgr.devices.return_value = [] + lcd.bluetooth_menu.notify_status_change() + + labels = _labels(lcd.pstack.current) + assert not any("EV-1-WL" in label for label in labels), "stale ghost must leave the nearby list" + assert any("Put it in pairing mode" in label for label in labels) + + +# ----- 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 success drops back to the root list showing the device.""" + deferred: list = [] + known: list = [] + mgr = bluetooth_state(devices=[make_bt_device()], known=known, deferred=deferred) + lcd = _open(v3_system) + _click_row(lcd, "Nearby devices") + _click_row(lcd, "EV-1-WL") + snapshot("pairing_in_flight") + + # The pair landed: bluez now holds it paired, and the manager remembered it. + device = mgr.devices.return_value[0] + device["paired"] = True + device["connected"] = True + known.append(make_bt_known()) + _, on_done = deferred.pop() + on_done(None) + menu = lcd.pstack.current + assert _labels(menu)[0].startswith("EV-1-WL"), "pairing success must land on the root list" + 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..0c512adf0 --- /dev/null +++ b/ui/bluetooth_menu.py @@ -0,0 +1,454 @@ +# 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, + 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, row_label +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 = "·" + +# bluetoothd without -E registers no BLE-MIDI profile +NEEDS_UPDATE = ("Please install pistomp-bluetooth", "from Updates and Recovery") + +# BLE-MIDI peripherals only advertise while discoverable. +EMPTY_NEARBY = ("No devices found.", "Put it in pairing mode.") + +POWER_WAITING: dict[Optional[bool], str] = { + True: "Turning Bluetooth on…", + False: "Turning Bluetooth off…", +} + + +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[Optional[bool] | 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 + self._power_pending: Optional[bool] = None # enable value of the in-flight PowerCmd + + @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"] + 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.extend((line, None, None) for line in NEEDS_UPDATE) + return items + wait = POWER_WAITING.get(self._power_pending) + if wait is not None: + # The radio toggle can take tens of seconds (rfkill, systemd, + # bluez bring-up). Show it as the only thing happening rather + # than a menu that ignores the last click. + items.append((row_label(wait, enabled=False), None, 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 = (self._power_pending,) + _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 (self._power_pending,) + _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") + if self._power_pending is not None: + # Already mid-toggle: the queue would dedupe it anyway, but the + # user should not be left waiting on a click that did nothing. + return + if not self._manager.queue.submit(PowerCmd(enable), self._on_power_done): + return # an identical command is still in flight + self._power_pending = enable + self.notify_status_change() + + 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_power_done(self, err: object) -> None: + # Clear first: the status publish later in this same poll tick is what + # repaints the menu, and it only rerenders once the pending flag (part + # of the root signature) has dropped. + self._power_pending = None + if err is None: + return + self.notify_status_change() + self._on_op_done(err) + + def _on_device_op_done(self, err: object, address: str) -> None: + self._clear_busy(address) + if err is None and self._nearby_menu is not None and self._pstack.current is self._nearby_menu: + # Pairing succeeded from the nearby list: drop back to the root so + # the result is visible in the device list, not in the nearby row + # that no longer exists once the device is paired. + self._pop_nearby_to_root() + self._on_op_done(err) + + def _pop_nearby_to_root(self) -> None: + nearby = self._nearby_menu + self._nearby_menu = None + self._nearby_sig = () + self._pstack.pop_panel(nearby) + self._stop_discovery() + # Rebuild the root menu beneath, not a second one stacked on top. + self._rerender_root() + + 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 4be2d6b95..01dd84d0a 100644 --- a/ui/wifi_menu.py +++ b/ui/wifi_menu.py @@ -21,6 +21,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 ( @@ -50,7 +51,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: @@ -63,6 +64,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" # ✔ @@ -268,12 +271,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() @@ -380,13 +383,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"] @@ -469,6 +466,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. A board with no adapter + gets no mention of Bluetooth 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 ffc317ff2..7b73f8863 100644 --- a/uilib/menu.py +++ b/uilib/menu.py @@ -24,7 +24,24 @@ 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) @@ -101,24 +118,37 @@ 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, - text_halign: TextHAlign = TextHAlign.CENTRE, - auto_dismiss: bool = True, dismiss_option: bool = False, - default_item: str | None = None, **kwargs) -> None: + + def __init__( + self, + items: list[MenuItem], + font=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, + 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)) + self.items.append(("\u2b05", self._dismiss, None)) if font is None: - font = Config().get_font('default') + font = Config().get_font("default") self.font = font 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 @@ -129,37 +159,84 @@ 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) disabled = isinstance(t, DisabledLabel) if isinstance(t, (str, BadgedLabel, DisabledLabel)): text = t if isinstance(t, str) else t.text if _item_selected(item): - text = '\u2714 ' + text + text = "\u2714 " + text badge = BadgeGlyph(t.char) if isinstance(t, BadgedLabel) and t.char is not None else None # A disabled row is not `selectable`, thus `_get_margins` drops the # selection-rectangle inset and lifts the text. Pass the inset that the # other rows compute, to keep one baseline down the menu. inset = self.sel_width if disabled else None w: TextWidget | RichTextWidget = TextWidget( - box=b, text_halign=self.text_halign, font=self.font, - text=text, badge=badge, parent=self, action=self._item_action, - h_margin=inset, v_margin=inset, - fgnd_color=DISABLED_FG if disabled else self.fgnd_color) + box=b, + text_halign=self.text_halign, + font=self.font, + text=text, + badge=badge, + parent=self, + action=self._item_action, + h_margin=inset, + v_margin=inset, + fgnd_color=DISABLED_FG if disabled else self.fgnd_color, + ) else: # Rich rows ignore `selected` for now — the checkmark prefix # only makes sense on string labels. - w = RichTextWidget(box=b, segments=t, font=self.font, - h_margin=5, v_margin=1, - parent=self, action=self._item_action) + w = RichTextWidget( + box=b, segments=t, font=self.font, h_margin=5, v_margin=1, parent=self, action=self._item_action + ) # Stash the source item on the widget for `_item_action` to recover. - setattr(w, 'data', item) + setattr(w, "data", item) + self._row_items[w] = item if not disabled: 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 @@ -195,12 +272,12 @@ 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 # get_text_size; rich rows measure each segment. - _, line_h = get_text_size('', self.font) + _, line_h = get_text_size("", self.font) item_h = line_h for i in self.items: t = _item_label(i) @@ -215,14 +292,13 @@ 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 self._content_height = h h = mh - self.box = Box.xywh(0,0,w,h) - super(Menu,self)._adjust_box() + self.box = Box.xywh(0, 0, w, h) + super(Menu, self)._adjust_box() diff --git a/uv.lock b/uv.lock index 924c5b133..d7e4848d0 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" @@ -902,6 +946,7 @@ name = "pi-stomp" version = "3.3.1" source = { editable = "." } dependencies = [ + { name = "dbus-fast" }, { name = "gpiozero", marker = "sys_platform == 'linux'" }, { name = "jack-client" }, { name = "msgspec" }, @@ -948,6 +993,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 = ">=5.0" }, { 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" },