Skip to content

feat(menu): pikr Bluetooth device picker - #14

Open
junjitree wants to merge 2 commits into
mxaddict:mainfrom
junjitree:feat/menu-bluetooth
Open

junjitree wants to merge 2 commits into
mxaddict:mainfrom
junjitree:feat/menu-bluetooth

Conversation

@junjitree

Copy link
Copy Markdown
Contributor

krypt menu bluetooth is the odd one out on the bar. It launches bluetui in a
floating alacritty, so it is not a picker, it stacks a new window per click
(no lock, no debounce), and it needed a config file of its own just to close on
Esc. .menu-wifi already solved the same shape of problem — list, pick,
connect, forget — in pikr. This ports Bluetooth to that model.

How it works

Reads are one busctl GetManagedObjects snapshot per refresh, shaped by jq;
bash only splits on tab. Connect, disconnect, forget and power are busctl
calls, and each is verified by re-reading the property rather than trusting the
exit status — "already connected" and "in progress" are errors whose outcome is
success. Only scanning and pairing need bluetoothctl, because both need a
process that stays alive: discovery belongs to the D-Bus client that asked for
it, and an agent lives only as long as its process.

Devices and actions are separate lists. The actions are keys, named in the
prompt, using bluetui's letters where pikr leaves them free — ^S scan, ^O
power; bluetui's u for unpair is pikr's clear-line, so forget is Right, as
in .menu-wifi. A pikr without --kb-custom has no keys to name, so there the
device list carries a row through to an actions list instead; the script probes
pikr --help rather than assuming.

Pairing

Worth calling out, because it was not obvious. bluetoothctl --agent X pair in
its one-shot form asks bluez to pair before its own agent is registered, so
bluez falls back to NoInputNoOutput and negotiates No Bonding — a key
that is never persisted. AirPods Pro reported Paired: yes, played audio, and
dropped to Paired: no seconds later, repeatably. An HCI trace showed our
Pair Device carrying Capability: NoInputNoOutput where bluetui's carried
KeyboardDisplay. The agent session is now driven over a named pipe, and the
pairing waits for bluez to confirm the default agent before asking.

Anything needing a passkey or a PIN still hands off to bluetui with a
notification; bluetui stays in deps.toml as that fallback.

Verification

Stub harness (fake busctl, bluetoothctl, pikr, notify-send on PATH
with canned D-Bus JSON): 137 assertions, 0 failing, and every guard was
broken once to confirm its test goes red. shellcheck -x clean at default
severity with zero directives; mode 100755.

On real hardware (AirPods Pro 2, Keychron K8): pairing, bonding, forgetting and
reconnecting, confirmed both in /var/lib/bluetooth and in an HCI trace.
The power toggle and the forget-list path are covered by the harness but have
not been run on a live adapter.

Deliberate trade-off

With a pikr that can bind the keys, a mouse reaches connect and disconnect
and nothing else — pikr makes only a row clickable, so a mouse route to the
actions means either action rows in the device list or a second binding on the
bar icon, and both were rejected here. This is a known departure from "mouse
and keyboard, both work, always"; the reasoning and what would have to give to
revisit it are written up in docs/backlog.md, along with the rest of what is
not covered.

`krypt menu bluetooth` opened bluetui in a floating terminal: not a
picker, a new window per click, and unlike the pikr menus it needed a
config file just to close on Esc. `.menu-wifi` already solved the same
shape of task in pikr, so Bluetooth now follows it — same debounce,
toggle and lock, same rows-file pick mapping, same degradation on a
pikr without --kb-custom or --loading.

Known devices list at once with no scan; discovery runs only from a
"Scan for new devices" row. Reads are one busctl GetManagedObjects
snapshot shaped by jq, and every change is confirmed by re-reading the
property rather than trusting an exit status. Every action has a row as
well as a key, so the menu is whole for a mouse and for stock pikr.

Pairing covers Just Works only. A device that needs a passkey or PIN is
handed to bluetui, which stays the fallback; docs/backlog.md records
that and what has not been run against real hardware.
Devices and actions shared one list, which read as a list of things to
connect to with three odd entries in it. The actions are now keys named
in the prompt, following bluetui's letters where pikr leaves them free,
and a pikr that cannot bind them gets a row through to a list of its
own instead. A mouse on a pikr that can bind them reaches connect and
disconnect only; that trade and its alternatives are in the backlog.

Pairing never stored a key. bluetoothctl's one-shot `pair` asks bluez
to pair before its own agent is registered, so bluez fell back to
NoInputNoOutput and negotiated No Bonding, which is by definition never
persisted: AirPods Pro reported Paired: yes, played audio, and dropped
back to Paired: no seconds later. An HCI trace showed our Pair Device
carrying Capability: NoInputNoOutput where bluetui's carried
KeyboardDisplay. The agent session is now driven over a named pipe and
the pairing waits for bluez to accept it as the default agent first.

A device that is connected but not paired -- forgotten here while it
still holds its own key, so it reconnects unasked -- offered Disconnect
as its only action, and reconnected immediately after, with no way out
of the picker. It offers pairing now, and the link it opened is dropped
first so the pairing is ours to initiate rather than one we inherit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant