#+TITLE: Bluetooth Panel — CLI-Driven, Net-Panel Kin #+AUTHOR: Craig Jennings #+DATE: 2026-07-02 #+TODO: TODO | DONE #+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED * IMPLEMENTED Status :PROPERTIES: :ID: 1271a845-4463-4831-9902-990eda6b2265 :END: - [2026-07-02 Thu] IMPLEMENTED — all five phases shipped the same day (dotfiles eb2230f / 76b2c05 / e372de3 / 2a026b1; archsetup d8d8c53): engine, panel, bar module + blueman retirement, bt-priv + package swap, install wiring proven by VM assertions. 43 dotfiles suites green, both AT-SPI smokes green, panels verified live; the phase 4-5 VM assertions run on the next VM pass. - [2026-07-02 Thu] DOING — spec-response decomposed the five phases into build sub-tasks under the todo.org parent (:SPEC_ID: bound); build started same day per Craig ("4 first, then 1" — bugs then bluetooth). - [2026-07-02 Thu] READY — spec-review passed the gate: all four decisions resolved, phases decomposable, CLI verbs verified against bluez 5.86. Two non-blocking findings recorded and dispositioned in the same pass (donor-pattern answers). - [2026-07-02 Thu] DRAFT — initial spec from Craig's request: a bluetooth module driving a CLI underneath, consistent with the net panel, minimal interface, full functionality, diagnostics section, visual mockups. * Metadata | Field | Value | |--------+---------------------------------------------------------------------------------| | Status | implemented | |--------+---------------------------------------------------------------------------------| | Owner | Craig Jennings | |--------+---------------------------------------------------------------------------------| | Repo | dotfiles (bt module); archsetup (packages, sudoers, keybind defaults) | |--------+---------------------------------------------------------------------------------| | Kin | net panel (architecture donor), desktop-settings panel (same donor, shared css) | |--------+---------------------------------------------------------------------------------| * Problem Bluetooth on both daily drivers runs through blueman: a tray applet plus a GTK3 manager window (Super+Shift+B). It's the odd one out on the desktop — a foreign visual style next to the dupre-themed panels, a tray icon where every other indicator is a first-class waybar module, and no diagnostics story at all. When the BT mouse fails to reconnect at boot (a recurring gotcha — touchpad-auto exists because of it) or headphones pair but route no audio, the fix is a terminal séance: bluetoothctl, rfkill, systemctl, wpctl, in whatever order folklore suggests. The net panel proved the shape that fixes this: a minimal layer-shell popup over a GTK-free engine that drives a CLI, with a diagnostics tab that names the failure and offers the repair. Bluetooth is the same problem with a smaller surface: one adapter, a handful of devices, a short list of well-known failure modes. * Goals 1. Visibility: adapter power state and every known device with live state (connected, battery, signal) in one glance — panel and bar module agree. 2. Control: power, scan, pair, connect, disconnect, forget — full functionality from the panel, zero terminals (the net panel's V2 contract). 3. Diagnostics: a doctor that walks the known failure chain (adapter → rfkill → service → power → device → audio profile), names the broken link in evidence rows, and offers tiered repairs. 4. Consistency: same stack, same window shape, same interaction grammar, same palette as the net panel. A user who knows one panel knows both. Audio-profile switching is in scope for v1 (Craig, 2026-07-02 — "bitten by this too many times to count"): the doctor's audio-profile step carries a one-click repair, not just a diagnosis, and connected audio devices surface their active profile (details in the doctor chain below). Non-goals (this iteration): OBEX file transfer, multi-adapter support (both machines have one controller), BLE sensor/GATT browsing. * Design sketch ** Architecture — the net panel's stack, verbatim - GTK4 + gtk4-layer-shell, Blueprint .blp compiled to committed .ui (=make ui=), PyGObject at runtime. - Humble-object split: GTK-free =PanelModel= presenter (unit-tested like net's), thin composite-widget pages, =bg(work, done)= worker-thread helper for every slow call. - Engine: a new =bt= package in dotfiles (=bluetooth/src/bt/=, sibling of =net/=), CLI entry =bt= with =bt status= / =bt panel= / =bt doctor= — the same cmd/cli layout as net. - Layer-shell OVERLAY popup anchored TOP+RIGHT, 380x520, Esc closes, focus-out auto-hides, single-instance toggle via a =bt-panel= wrapper. Dupre palette css shared with the net panel (the factored css asset the desktop-settings spec calls for — three consumers now, so the factoring happens in this project's phase 1 if settings hasn't landed it). - Testing: engine TDD with fake binaries on a temp PATH (fake-bluetoothctl, fake-rfkill, fake-systemctl, fake-wpctl); PanelModel unit suite; one gated AT-SPI smoke (=make test-panel= pattern). ** CLI backing — bluetoothctl one-shot verbs bluez 5.86 (installed) supports everything non-interactive: - Adapter: =bluetoothctl show= (powered, discoverable, pairable), =bluetoothctl power on|off=. - Device lists: =bluetoothctl devices Paired|Connected|Trusted= — the Paired view is a merge of Paired + Connected states; =bluetoothctl info = per row fills caption detail (battery percentage rides bluez's built-in Battery1 profile and appears in info output; RSSI appears during discovery). - Scan: =bluetoothctl --timeout N scan on= (bounded discovery burst), then =devices= diffed against Paired for the Nearby list. The panel scans in 8s bursts with a live "Scanning…" state rather than an unbounded scan. - Connect/disconnect/forget: =bluetoothctl connect|disconnect|remove =. - Pairing: the one interactive corner. =bluetoothctl pair = can demand a passkey confirmation. The engine drives bluetoothctl's line protocol over a pty with a bounded state machine (expect "Confirm passkey", reply yes/no); a passkey prompt surfaces as a panel dialog showing the six digits, mirroring the net panel's password dialog. NoInputNoOutput devices (mice, most headphones) sail through without the dialog. - rfkill: the user is in the =rfkill= group, so block/unblock is unprivileged (=rfkill unblock bluetooth=). - Privileged path: exactly one verb needs root — =systemctl restart bluetooth= — so =bt-priv= is a one-verb closed helper with its own NOPASSWD sudoers rule placed by archsetup, cloning net-priv's regex-validated pattern rather than widening net-priv's scope. ** Panel anatomy Two tabs. Devices is the panel; Diagnostics is the escape hatch. Devices tab, Paired sub-view (the default — daily use is reconnecting known devices, not discovering new ones): #+begin_example ╭──────────────────────────────────────────────╮ │ [ Devices ] [ Diagnostics ] │ ← top switcher │ │ │ Bluetooth ●──○ hci0 on │ ← adapter row: power switch │ ──────────────────────────────────────────── │ │ [ Paired ] [ Nearby ] │ ← sub-view switcher │ ┌──────────────────────────────────────────┐ │ │ │ 󰍽 MX Master 3 │ │ │ │ Connected · battery 80% │ │ │ │ 󰋋 WH-1000XM4 │ │ │ │ Paired, not connected │ │ │ │ 󰌌 K380 Keyboard │ │ │ │ Paired, not connected │ │ │ │ │ │ │ └──────────────────────────────────────────┘ │ │ [ Disconnect ] [ Forget ] │ ← acts on selected row ╰──────────────────────────────────────────────╯ #+end_example The primary button is one control with a state-following label: "Connect" when the selection is disconnected (suggested-action styling), "Disconnect" when connected. Row-activate (Enter / double-click) connects — never disconnects — matching the net panel's asymmetry. Captions carry the human state line; the MAC lives in the row tooltip, not the visible caption. Devices tab, Nearby sub-view: #+begin_example ╭──────────────────────────────────────────────╮ │ [ Devices ] [ Diagnostics ] │ │ │ │ Bluetooth ●──○ hci0 on │ │ ──────────────────────────────────────────── │ │ [ Paired ] [ Nearby ] │ │ ┌──────────────────────────────────────────┐ │ │ │ Scanning… (6s) │ │ ← overlay state label │ │ 󰋋 JBL Flip 6 −58 dBm │ │ │ │ 󰄜 Pixel 9 −71 dBm │ │ │ │ 󰂱 (unnamed) 74:A5:… −83 dBm │ │ │ └──────────────────────────────────────────┘ │ │ [ Pair ] [ Rescan ] [ Discoverable ⊙ ] │ ╰──────────────────────────────────────────────╯ #+end_example Pair does the whole intended thing — pair, then trust, then connect — because pairing a device means "use it now and reconnect on its own later" (decision below). Discoverable is a toggle for the inbound case (pairing a phone TO the laptop), off by default, auto-off with bluez's discoverable-timeout. Rows sort by RSSI, strongest first; named devices above unnamed ones. Diagnostics tab (mirrors the net panel's shape: one big verb + streaming evidence rows + tiered repairs behind confirmation): #+begin_example ╭──────────────────────────────────────────────╮ │ [ Devices ] [ Diagnostics ] │ │ │ │ [ Get Bluetooth Working ] [ Advanced ▸]│ │ ┌──────────────────────────────────────────┐ │ │ │ ✓ Adapter present (hci0) │ │ │ │ ✓ Not blocked (rfkill clear) │ │ │ │ ✓ bluetooth.service active │ │ │ │ ✓ Adapter powered │ │ │ │ ✗ MX Master 3: paired but unreachable │ │ │ │ … Re-pair suggested — see below │ │ │ │ │ │ │ │ Fix: [ Reconnect ] [ Re-pair device ] │ │ │ └──────────────────────────────────────────┘ │ │ power-cycle · restart service · unblock │ ← tiered repairs (confirm) ╰──────────────────────────────────────────────╯ #+end_example The doctor chain, in order, each an evidence row: 1. Adapter present — =bluetoothctl list= / rfkill has an hci entry. Absent → hardware/driver verdict, no repair offered. 2. rfkill state — soft-blocked names the likely cause when the airplane-mode state file says airplane is on ("Blocked by airplane mode — turn airplane mode off"), otherwise offers Unblock (no root needed, rfkill group). 3. bluetooth.service — inactive/failed → offer restart (the one bt-priv verb), evidence quotes the last journal line. 4. Adapter powered — off → offer power on (and note if a boot-time policy keeps turning it off). 5. Per-device reachability — paired-but-connect-fails distinguishes "device off/out of range" (RSSI absent in a scan burst) from "bond corrupt" (connect error string), and only the latter suggests the re-pair repair (remove + pair + trust + connect, confirmed first — it's the destructive tier). 6. Audio profile (audio devices only) — device connected but no wpctl sink/source, or the card stuck in HSP/HFP when A2DP is expected: evidence names the active profile and offers the repair inline — "Switch to A2DP" drives =wpctl set-profile = (profile inventory from =pw-dump= — ground truth 2026-07-02: wpctl can't enumerate a card's profiles, and the card's =bluez5.profile= prop reads "off" mid-stream; the card's Profile param and the sink node's =api.bluez5.profile= are authoritative), verifies the sink came back in the expected profile, and reports fixed or no-change. In v1 per Craig (2026-07-02): this failure mode has bitten repeatedly, so it gets the one-click fix, not just a diagnosis. Connected audio-device row captions also show the profile when it's the degraded one ("Connected · mic mode (HSP)") so the state is visible before the doctor runs. Repairs confirm with the net panel's future-tense scope copy ("This will restart the Bluetooth service. Connected devices will drop and reconnect."), run on the worker thread, verify after (re-read state, report "fixed" or "no change"), and never chain silently. ** Bar module =custom/bluetooth= replacing the blueman-applet tray icon: the panel's glanceable layer, one glyph, state-following like =custom/net=: #+begin_example 󰂲 off / blocked (dim; red slash variant when rfkill-blocked) 󰂯 on, nothing connected (dim) 󰂱 connected (white; tooltip lists devices + battery) #+end_example Tooltip carries device names, battery percentages, and the keybind hints (the module-tooltip convention shipped 2026-07-02). Click opens the panel (=bt-panel= toggle wrapper); the existing Super+Shift+B bind moves from blueman-manager to =bt panel=. Low-battery on a connected device (<15%) adds a red percentage to the glyph text — the mouse dying mid-meeting is the one state worth surfacing unprompted. ** UX conformance notes Named against the heuristics the panel family follows (Nielsen's ten, plus the rulesets patterns catalog): - Visibility of status: live captions, scan countdown, elapsed ticker on long ops, verify-after-repair rows. - Match to the real world: device-kind glyphs + plain state lines; MACs demoted to tooltips; "Forget" not "Remove bond". - User control: Esc closes, Rescan is idempotent, scan bursts are bounded, repairs confirm, running ops show a Stop where stoppable. - Consistency: interaction grammar is the net panel's — same switcher layout, same primary-button contract, same confirm copy shape. - Error prevention: Forget and Re-pair confirm; power-off while devices are connected states the consequence in the confirm body. - Recognition over recall: every action is a visible button; no context menus, no hidden gestures (transient-state-buttons pattern). - Minimalism: two tabs, one primary action per view, detail behind tooltips and the Advanced reveal. - Help users recover: the doctor's evidence rows name the broken link and carry the repair inline (default-most-common-friction-proportional: the likely fix is one click, the destructive one is confirmed). Tension found with the net panel while writing this (filed as todo.org tasks per Craig's instruction, 2026-07-02): transient error toasts auto-dismiss in 4s, and the V2 spec's keyboard-navigation claims (tab-between-sections, arrow rows, type-to-filter) aren't verifiably implemented. Both filed against the net panel rather than cloned here; this panel adopts whatever resolution those tasks land on. * Decisions (Craig) [4/4] ** DONE Pair implies trust + connect? CLOSED: [2026-07-02 Thu] Decided (Craig, 2026-07-02): yes — one Pair verb does pair → trust → connect. A device that shouldn't auto-reconnect gets untrusted later; a per-device auto-reconnect toggle can ride a later pass. ** DONE Retire blueman entirely? CLOSED: [2026-07-02 Thu] Decided (Craig, 2026-07-02): drop it outright, no bake-in period — the package leaves archsetup and both machines once phase 2 lands, bluetoothctl stays as the terminal fallback. Craig's framing: any issue after retirement is a signal the doctor needs another check or the panel has a real bug, and it gets fixed there rather than papered over by keeping blueman around. ** DONE Battery in the row caption or tooltip only? CLOSED: [2026-07-02 Thu] Approved (Craig, 2026-07-02): caption when the device reports it ("Connected · battery 80%"), tooltip otherwise. ** DONE Scan burst length and auto-rescan? CLOSED: [2026-07-02 Thu] Approved (Craig, 2026-07-02): 8s bursts, no auto-repeat — Rescan stays explicit, matching the net panel's Available view. * Review findings [2/2] ** DONE Empty-state and no-adapter presentation copy undefined :nonblocking: CLOSED: [2026-07-02 Thu] The mockups show populated lists; the spec didn't say what an empty Paired list, an empty post-scan Nearby list, or a machine with no adapter shows in the panel and on the bar glyph. Dispositioned same pass: clone the donor — the net panel's in-box overlay message pattern (=show_loading= / placeholder label) carries the copy. Paired empty: "No paired devices — switch to Nearby to pair one." Nearby post-scan empty: "Nothing found — Rescan, or make the device discoverable." No adapter: adapter row reads "No Bluetooth adapter", Devices controls disable, Diagnostics stays usable (the doctor's step 1 names the hardware/driver verdict); bar glyph shows the off/blocked state. Non-blocking; recorded so the implementer doesn't invent copy mid-build. ** DONE Logging/redaction carry-over unstated :nonblocking: CLOSED: [2026-07-02 Thu] The spec says "the net panel's stack, verbatim" but didn't name whether the engine adopts net's =eventlog= (structured op log) and =redact= (sensitive-field scrubbing) modules. Dispositioned same pass: yes, both carry over — every mutating verb (pair/connect/forget/repair) logs an eventlog entry, and MACs are the redaction surface (device names stay, MACs redact in copied reports, mirroring net's report redaction). Non-blocking; it's the donor default made explicit. * Implementation phases 1. Engine =bt= package: adapter/device/scan probes over fake-bluetoothctl, status + doctor chain (rfkill, service, powered, reachability, audio profile probe + A2DP switch repair over fake-wpctl) — pure TDD, no GTK. =bt status= and =bt doctor= work from a terminal. Shared dupre css factored to the common asset if the settings panel hasn't already done it. 2. Panel: PanelModel presenter + Blueprint pages (Devices with Paired/Nearby, Diagnostics), worker-thread wiring, pairing-dialog state machine, bt-panel toggle wrapper, AT-SPI smoke. Super+Shift+B rebind. 3. Bar module =custom/bluetooth= (glyph states, tooltip, low-battery surface, refresh signal), waybar config + suite coverage; blueman retirement per the decision. 4. bt-priv one-verb helper + sudoers rule in archsetup; package-list swap (blueman out per decision, bluez-utils stays); VM test assertions. 5. archsetup keybind/config defaults so a fresh install lands the panel wired (waybar module present, bind set, sudoers placed). * Review and iteration history ** 2026-07-02 Thu @ 15:19:58 -0400 — Claude Code (archsetup) — phase 5 builder, spec closed - *What changed or was recommended:* Phase 5 shipped and the spec flipped to IMPLEMENTED. No new install code was needed — the waybar module, the =Super+Shift+B= bind, and the shared panel css all ride the dotfiles hyprland tier that a fresh install already clones and stows, and sudoers is covered by the blanket grant. The phase's substance is proof: =test_desktop.py= gained hyprland-gated assertions for the four stowed bt bins, the =custom/bluetooth= waybar entry, the =bt-panel= keybind, and the stowed =panel.css=. - *Why:* Final phase of the DOING decomposition; with it the todo parent closed and the lifecycle keyword flipped with a history line. - *Artifacts:* archsetup =scripts/testing/tests/test_desktop.py=; todo.org parent DONE + dated phase 5 / test-surface entries; this spec's Status heading. ** 2026-07-02 Thu @ 15:16:51 -0400 — Claude Code (archsetup) — phase 4 builder - *What changed or was recommended:* Phase 4 shipped. Dotfiles =2a026b1=: the stowed =bt-priv= shim (one verb, verified against the fake-systemctl) and the sxhkd =Super+Shift+B= bind repointed from blueman-manager to =st -e bluetoothctl= (terminal fallback per the retirement decision — the panel is Wayland-only). archsetup: blueman dropped from the =desktop_environment= package loop; VM assertions added (bluez/bluez-utils present, blueman absent). blueman also removed live from velox. - *Why:* Build order per the DOING decomposition. The spec's "sudoers rule" item resolved as net-priv's did: archsetup already grants the primary user blanket =NOPASSWD: ALL= (archsetup:1089), so a narrow bt-priv rule would be dead config — no new sudoers needed, and phase 5's "sudoers placed" is satisfied by the existing grant. - *Artifacts:* dotfiles =hyprland/.local/bin/bt-priv=, =common/.config/sxhkd/sxhkdrc=; archsetup =archsetup= (bluetooth loop), =scripts/testing/tests/test_packages.py=; dated phase 4 entry under the todo.org parent. ** 2026-07-02 Thu @ 15:06:00 -0400 — Claude Code (archsetup) — phase 3 builder - *What changed or was recommended:* Phase 3 shipped (dotfiles =e372de3=): the =custom/bluetooth= bar module (state-following glyph, low-battery red percentage, device+battery tooltip with the keybind hint, signal 10 with the panel poking it after each reload) and the blueman retirement from the Hyprland session (exec-once + windowrules removed, applet killed live). The phase 2 deferred items also closed this pass: both AT-SPI smokes green (the bt smoke's primary-button assertion fixed for the state-following label, =c1a8219=), both panels eyeballed correct in dupre, and the net-panel keyboard claims verified live (archsetup =e80df2b= — false claims struck from the net spec). - *Why:* Build order per the DOING decomposition; the Zoom meeting ended, unblocking the visual work. Phases 4-5 (bt-priv/sudoers/packages, install defaults — archsetup side) remain. - *Artifacts:* dotfiles =bluetooth/src/bt/indicator.py=, =waybar-bt=, waybar config + three css files; dated phase 3 entry under the todo.org parent. ** 2026-07-02 Thu @ 14:15:27 -0400 — Claude Code (archsetup) — phase 2 builder - *What changed or was recommended:* Phase 2 shipped (dotfiles =76b2c05=): the GTK panel — PanelModel/viewmodel presenter pair (69 tests), Blueprint pages, pairing pty state machine with default-deny passkey confirms, manage.py op envelopes shared by CLI and panel (power + discoverable verbs added), =bt-panel= toggle, Super+Shift+B rebind. The shared dupre css factoring landed as planned: net's inline =_CSS= became =themes/dupre/panel.css= with =dupre-*= classes, both panels consume it. 43 suites green. The AT-SPI smoke (=make test-panel-bt=) is written but not yet run live — a Zoom meeting occupied the compositor; it runs when the meeting ends, along with a visual check of both panels. - *Why:* Build order per the DOING decomposition; phases 3-5 (bar module, bt-priv/sudoers, install defaults) remain. - *Artifacts:* dotfiles =bluetooth/src/bt/{panel,viewmodel,pairing,manage, gui,pages}.py=, =ui/*.blp=, =tests/bt/test_btpanel.py=, the panel smoke; dated phase 2 entry under the todo.org parent. ** 2026-07-02 Thu @ 13:31:00 -0400 — Claude Code (archsetup) — phase 1 builder - *What changed or was recommended:* Phase 1 shipped (dotfiles =eb2230f=): the =bt= engine package, 101 tests over fakes, live-verified read-only on velox. Two spec corrections from ground truth: profile inventory comes from =pw-dump= (wpctl can't enumerate profiles), and the active profile reads from the card's Profile param / sink's =api.bluez5.profile= (the card's =bluez5.profile= prop is unreliable). The shared-css factoring moved into phase 2 — net's css is an inline string in its =gui.py=, so extracting it belongs with the first second consumer rather than as a standalone poke at the working net panel. - *Why:* Build order per the DOING decomposition; corrections keep the spec honest for the phase 2 implementer. - *Artifacts:* dotfiles =bluetooth/src/bt/=, =tests/bt/=, the stowed =bt= shim; dated phase 1 entry under the todo.org parent. ** 2026-07-02 Thu @ 13:10:00 -0400 — Claude Code (archsetup) — reviewer + responder - *What changed or was recommended:* Ran the spec-review gate: passed. All four decisions were already DONE (cookie added to the heading); the five phases are each a clean single-session stop; CLI verbs are verified against installed bluez 5.86. Two non-blocking findings recorded and dispositioned in the same fused pass (empty-state / no-adapter copy, eventlog + redaction carry-over) — both resolve to "clone the net-panel donor," now stated explicitly. Flipped DRAFT → READY → DOING and decomposed the phases into build sub-tasks under the todo.org parent with :SPEC_ID: bound. - *Why:* Craig queued the build ("4 first, then 1", 2026-07-02) after resolving all decisions the same morning; the gate held nothing back, so review and response fused to keep the speedrun moving. - *Artifacts:* Findings in =* Review findings [2/2]= above; build parent in todo.org ("Bluetooth panel + bar module"); net-panel toast fix the UX-conformance note references landed as dotfiles =0f017d4=.