Skip to main content

max / alloy

Add the alloy bluetooth verb: five state fields, said out loud bluez keeps paired, bonded, trusted, blocked and connected as independent facts, and every graphical Bluetooth panel collapses them into one Connected toggle that then lies. The field deciding whether a device reconnects on its own is Trusted, and pairing does not set it. Both mice on this machine report paired, bonded, untrusted, which is what "Bluetooth keeps forgetting my mouse" actually is. So the view shows all five raw, then says what the combination means and which key changes it. Nothing acts on its own: a connected headset is offered to the default sink on a key rather than seizing it. Fronts bluetoothctl rather than bluez's D-Bus, correcting what STACK.md and the preset both said. D-Bus buys live discovery and agent control, and costs an async runtime plus the log pane's argv promise. Pairing and scanning suspend onto the terminal instead, so a passkey prompt has somewhere to ask; everything else refreshes when asked. rfkill is read alongside the adapter so a hardware block is named rather than met with a power key that cannot work. Hidden on the server profile, where the overlay disables bluetooth.service. HEADLESS_HIDES carries a reason per verb now, since that one is not the missing-compositor reason the other two have.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 17:36 UTC
Signed with PGP, not checked
Commit: f581c896f9dd8500d153f8526ed7d06580cff867
Parent: adf00a2
8 files changed, +635 insertions, -28 deletions
@@ -17,6 +17,7 @@
17 17 ```
18 18 alloy net # network: nmcli front, connect and radio [shipped]
19 19 alloy audio # audio: pactl front, streams and devices [shipped]
20 + alloy bluetooth # bluetooth: bluetoothctl front, all five state fields [shipped]
20 21 alloy mesh # mesh VPN: tailscale front (alias: tail) [shipped]
21 22 alloy display # outputs: swaymsg front, scale and enable [shipped]
22 23 alloy pkg install # what is layered, and where a package belongs
M docs/STACK.md +7 -1
@@ -475,7 +475,13 @@
475 475
476 476 **Kept on because Alloy is laptop-first**, and on a laptop wireless headphones and a wireless mouse are ordinary. The audio half needs nothing further: PipeWire's bluez5 SPA plugins ship in the image, LC3 and AAC included, so a paired headset plays.
477 477
478 - **What is missing is a surface, not a daemon.** Pairing today means `bluetoothctl`, an interactive prompt rather than a view, and it works. The intended answer is an `alloy bluetooth` console view over bluez's D-Bus, in the shape `alloy display` and `alloy pkg` already use.
478 + **The surface that was missing is `alloy bluetooth`**, shipped 2026-08-05, in the shape `alloy display` and `alloy pkg` already use.
479 +
480 + It fronts `bluetoothctl` rather than talking to bluez's D-Bus, which is a correction to what this section said before. D-Bus buys live discovery updates and control over the pairing agent, and it costs an async runtime in a binary that has none plus the console's central promise, which is that the log pane shows the argv that ran. A D-Bus call has no argv to show. Pairing and scanning suspend onto the terminal instead, so `bluetoothctl`'s own agent has somewhere to ask about a passkey and the user watches real discovery output; everything else refreshes when asked. Manual refresh over background polling is the general preference, not a concession here.
481 +
482 + **What the verb is for is legibility, not convenience.** `bluetoothctl` already pairs. The problem it does not solve is that bluez keeps five independent facts per device (paired, bonded, trusted, blocked, connected) and every graphical Bluetooth panel collapses them into one Connected toggle that then lies. The field that decides whether a device ever reconnects on its own is Trusted, and pairing does not set it. Measured on fw13 the day the verb was written: both of this machine's mice reported `Paired: yes, Bonded: yes, Trusted: no`, which is the exact state a user reads as Bluetooth having forgotten the mouse. The view shows all five raw, then says in a sentence what the combination means and which key changes it. It does nothing on its own, for the same reason: Bluetooth is worst where it tries to be magic, and a headset that seizes the default sink is the case in point, so `alloy bluetooth` offers that handoff on a key and does not take it.
483 +
484 + The verb is hidden on the `server` profile, and not for the reason `display` and `status` are. It needs no compositor and would run; the server overlay disables `bluetooth.service`, so it would front a daemon that is off by design.
479 485
480 486 Rejected: **bluetuith**, the obvious ready-made TUI, which is in no Fedora repository and would mean adopting another COPR for a view this project builds itself anyway. **blueman** and **GNOME Settings**, both GTK, both off-thesis for the same reason system-config-printer is.
481 487
@@ -15,6 +15,7 @@
15 15 |---|---|
16 16 | `alloy net` | Interfaces, connections, the wifi radio |
17 17 | `alloy audio` | Streams, outputs and inputs, and which is routed where |
18 + | `alloy bluetooth` | Devices, and what state each one is actually in |
18 19 | `alloy display` | Outputs sway is driving: scale, rotation, on or off |
19 20 | `alloy mesh` | Mesh VPN peers and exit node (alias: `alloy tail`) |
20 21 | `alloy sync` | Synchronized folders, devices, and pending invitations |
@@ -82,7 +83,6 @@
82 83
83 84 - `alloy theme <name>` for swapping the runtime theme without logging out.
84 85 Today the theme is chosen in `alloy settings` and applied at the next login.
85 - - A Bluetooth view. Pairing is `bluetoothctl` for now (chapter 13).
86 86 - MagicDNS lookup and share/unshare in `alloy mesh`.
87 87 - Editing an existing folder's share list in `alloy sync`.
88 88 - Multi-output mode picking in `alloy display`. Scale and enable work; the mode
@@ -103,13 +103,37 @@
103 103 bluez is installed and running. PipeWire's Bluetooth plugins are in the image,
104 104 LC3 and AAC included, so a paired headset plays.
105 105
106 - What is missing is a view, not a daemon. Pairing today is the interactive
107 - prompt:
106 + alloy bluetooth
108 107
109 - bluetoothctl
110 - # scan on, pair <MAC>, trust <MAC>, connect <MAC>
108 + Put the device in pairing mode, press `s` to scan, move to the `nearby` tab,
109 + and press `p`. The terminal is handed to `bluetoothctl` while it scans and while
110 + it pairs, so if the device wants a passkey confirmed you answer it there.
111 111
112 - An `alloy bluetooth` view is intended and not built.
112 + The screen is built around a distinction Bluetooth panels usually hide. bluez
113 + keeps five separate facts about every device:
114 +
115 + | Field | What it means |
116 + |---|---|
117 + | paired | Keys have been exchanged |
118 + | bonded | Those keys were written down, so they survive a reboot |
119 + | trusted | bluez will accept a connection this device starts |
120 + | blocked | bluez refuses the device outright |
121 + | connected | A link is up right now |
122 +
123 + They move independently, and the one that catches people is trusted. Pairing
124 + does not set it. A device that is paired and untrusted works when you connect it
125 + by hand and will not come back on its own, which is what "Bluetooth keeps
126 + forgetting my mouse" usually is. Press `t` on it.
127 +
128 + The detail block at the bottom shows all five for whatever is selected, along
129 + with a sentence saying what that combination means and which key changes it, and
130 + the `bluetoothctl info` command it read them from.
131 +
132 + Nothing on the screen happens on its own. A connected headset does not take over
133 + your audio; press `d` if you want it to be the default sink.
134 +
135 + If the adapter is down, `w` powers it on. If a hardware switch is blocking the
136 + radio, the screen says so rather than offering a key that cannot work.
113 137
114 138 ## USB storage
115 139
@@ -8,6 +8,7 @@
8 8
9 9 mod audio;
10 10 mod bind;
11 + mod bluetooth;
11 12 mod cli;
12 13 mod credits;
13 14 mod disk;
@@ -83,6 +84,11 @@
83 84 Net,
84 85 /// Audio outputs and inputs
85 86 Audio,
87 + /// Bluetooth devices, and what state each one is actually in
88 + Bluetooth {
89 + #[command(subcommand)]
90 + surface: Option<BluetoothSurface>,
91 + },
86 92 /// Displays sway is driving: scale, rotation, and which are on
87 93 Display,
88 94 // `tail` stays as an alias: docs/CONSOLE.md named the verb that way, and
@@ -199,6 +205,19 @@
199 205 All,
200 206 }
201 207
208 + /// Which list of Bluetooth devices to open on.
209 + ///
210 + /// Nouns, like [`DiskSurface`]'s. The split is whether bluez holds keys for the
211 + /// device, which is the only division that survives contact with the state
212 + /// machine: everything else about a device can change without moving it.
213 + #[derive(Subcommand)]
214 + enum BluetoothSurface {
215 + /// Devices this machine is paired with
216 + Known,
217 + /// Devices in range that are not paired
218 + Nearby,
219 + }
220 +
202 221 /// Nouns, like [`PkgSurface`]'s: each heads a list of what is already there
203 222 /// rather than naming an action.
204 223 #[derive(Subcommand)]
@@ -242,6 +261,7 @@
242 261 let verb = match &cli.command {
243 262 Command::Display => "display",
244 263 Command::Status { .. } => "status",
264 + Command::Bluetooth { .. } => "bluetooth",
245 265 _ => "",
246 266 };
247 267 if profile::is_unavailable(verb, profile) {
@@ -297,6 +317,17 @@
297 317 let mut view = audio::AudioView::new(&mut log);
298 318 shell::run(&theme, &mut view, &mut log)
299 319 }
320 + Command::Bluetooth { surface } => {
321 + let tab = match surface {
322 + Some(BluetoothSurface::Nearby) => bluetooth::Tab::Nearby,
323 + // Bare `alloy bluetooth` lands on known: someone opening the
324 + // verb usually has a device that is already paired and is not
325 + // behaving, which is the list it is on.
326 + Some(BluetoothSurface::Known) | None => bluetooth::Tab::Known,
327 + };
328 + let mut view = bluetooth::BluetoothView::new(tab, &mut log);
329 + shell::run(&theme, &mut view, &mut log)
330 + }
300 331 Command::Display => {
301 332 let mut view = display::DisplayView::new(&mut log);
302 333 shell::run(&theme, &mut view, &mut log)
@@ -43,12 +43,35 @@
43 43 /// edit it after the build.
44 44 pub(crate) const MARKER: &str = "/usr/lib/alloy/profile";
45 45
46 - /// The verbs a `server` image does not offer.
46 + /// The verbs a `server` image does not offer, each with why.
47 47 ///
48 - /// Named here rather than derived from anything, because the list is a
49 - /// judgment about what needs a compositor and there is nothing in the argument
50 - /// parser that knows that.
51 - pub(crate) const HEADLESS_HIDES: [&str; 2] = ["display", "status"];
48 + /// Named here rather than derived from anything, because the list is a set of
49 + /// judgments and there is nothing in the argument parser that knows them.
50 + ///
51 + /// The reason travels with the verb because there is more than one reason. The
52 + /// first two need a compositor: `display` drives swaymsg and `status --bar`
53 + /// writes swaybar's protocol, and a server image has neither. `bluetooth` is
54 + /// here on different grounds. It needs no compositor and would run fine, but
55 + /// the Containerfile's server overlay disables `bluetooth.service`, so the verb
56 + /// would open onto a daemon that is off by design and stay that way whatever
57 + /// the user pressed. Collapsing both into "needs a graphical session" would
58 + /// send someone looking for a compositor they do not need.
59 + pub(crate) const HEADLESS_HIDES: [(&str, &str); 3] = [
60 + ("display", "it needs a graphical session"),
61 + ("status", "it needs a graphical session"),
62 + (
63 + "bluetooth",
64 + "bluetooth.service is disabled on this profile, so there is nothing for it to front",
65 + ),
66 + ];
67 +
68 + /// Why this verb is not offered here, or `None` if it is.
69 + fn reason(verb: &str) -> Option<&'static str> {
70 + HEADLESS_HIDES
71 + .iter()
72 + .find(|(name, _)| *name == verb)
73 + .map(|(_, why)| *why)
74 + }
52 75
53 76 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54 77 pub(crate) enum Profile {
@@ -106,7 +129,7 @@
106 129 if profile.graphical() {
107 130 return command;
108 131 }
109 - for verb in HEADLESS_HIDES {
132 + for (verb, _) in HEADLESS_HIDES {
110 133 command = command.mut_subcommand(verb, |sub| sub.hide(true));
111 134 }
112 135 command
@@ -117,18 +140,20 @@
117 140 /// Paired with [`hide_unavailable`] so the two cannot drift: hiding a verb
118 141 /// from `--help` while still running it is the failure this exists to prevent.
119 142 pub(crate) fn is_unavailable(verb: &str, profile: Profile) -> bool {
120 - !profile.graphical() && HEADLESS_HIDES.contains(&verb)
143 + !profile.graphical() && reason(verb).is_some()
121 144 }
122 145
123 146 /// What to print when a verb is invoked on a profile that does not carry it.
124 147 ///
125 - /// Names the profile rather than the symptom. "no compositor" invites the user
126 - /// to go looking for one; "this is a server image" is the fact that explains
127 - /// every other verb they are about to find missing.
148 + /// Names the profile as well as the reason. "no compositor" invites the user to
149 + /// go looking for one; "this is a server image" is the fact that explains every
150 + /// other verb they are about to find missing. The verb-specific half comes from
151 + /// [`HEADLESS_HIDES`] so the two cannot disagree.
128 152 pub(crate) fn unavailable(verb: &str) -> String {
153 + let why = reason(verb).unwrap_or("it is not offered on this profile");
129 154 format!(
130 - "`alloy {verb}` needs a graphical session, and this is a server image \
131 - ({MARKER} says server). The verb is not offered on this profile."
155 + "`alloy {verb}` is not offered here: {why}, and this is a server image \
156 + ({MARKER} says server)."
132 157 )
133 158 }
134 159
@@ -174,12 +199,26 @@
174 199 assert!(message.contains(MARKER));
175 200 }
176 201
202 + /// Two verbs are hidden for two different reasons, and saying the wrong one
203 + /// sends a user after a fix that cannot help. `bluetooth` needs no
204 + /// compositor; its daemon is off.
205 + #[test]
206 + fn each_hidden_verb_gives_its_own_reason() {
207 + assert!(unavailable("display").contains("graphical session"));
208 + assert!(unavailable("bluetooth").contains("bluetooth.service is disabled"));
209 + assert!(
210 + !unavailable("bluetooth").contains("graphical"),
211 + "bluetooth is not hidden for wanting a compositor"
212 + );
213 + }
214 +
177 215 /// A stand-in for the real CLI, so this tests the mapping rather than the
178 216 /// verb roster. Both the hidden verbs and one that must survive.
179 217 fn command() -> clap::Command {
180 218 clap::Command::new("alloy")
181 219 .subcommand(clap::Command::new("display"))
182 220 .subcommand(clap::Command::new("status"))
221 + .subcommand(clap::Command::new("bluetooth"))
183 222 .subcommand(clap::Command::new("net"))
184 223 }
185 224
@@ -193,12 +232,15 @@
193 232
194 233 #[test]
195 234 fn a_client_is_offered_everything() {
196 - assert_eq!(visible(Profile::Client), ["display", "status", "net"]);
235 + assert_eq!(
236 + visible(Profile::Client),
237 + ["display", "status", "bluetooth", "net"]
238 + );
197 239 }
198 240
199 241 /// The ruling: hidden entirely, not reported as absent.
200 242 #[test]
201 - fn a_server_is_not_offered_the_graphical_verbs() {
243 + fn a_server_is_not_offered_the_verbs_it_cannot_carry() {
202 244 assert_eq!(visible(Profile::Server), ["net"]);
203 245 }
204 246
@@ -209,7 +251,7 @@
209 251 fn what_is_hidden_is_exactly_what_is_refused() {
210 252 for profile in [Profile::Client, Profile::Server] {
211 253 let shown = visible(profile);
212 - for verb in ["display", "status", "net"] {
254 + for verb in ["display", "status", "bluetooth", "net"] {
213 255 assert_eq!(
214 256 !shown.iter().any(|s| s == verb),
215 257 is_unavailable(verb, profile),
@@ -104,12 +104,15 @@
104 104 # including the LC3 and AAC codecs, so a paired headset plays without further
105 105 # packages.
106 106 #
107 - # What is missing is a surface, not a daemon: pairing today means `bluetoothctl`,
108 - # which is an interactive prompt rather than a view, and Alloy's answer is an
109 - # `alloy bluetooth` console view over bluez's D-Bus in the shape `display` and
110 - # `pkg` already use. bluetuith was the obvious ready-made TUI and is not in the
111 - # Fedora repositories, so taking it would mean another COPR; not worth it for a
112 - # view this project builds itself anyway.
107 + # The surface that was missing is `alloy bluetooth`, shipped 2026-08-05. It
108 + # fronts `bluetoothctl` rather than bluez's D-Bus, which is a correction to what
109 + # this comment said before: the console's promise is that the log pane shows the
110 + # argv that ran, and a D-Bus call has no argv to show. bluetuith was the obvious
111 + # ready-made TUI and is not in the Fedora repositories, so taking it would mean
112 + # another COPR; not worth it for a view this project builds itself anyway.
113 + #
114 + # The verb is not offered on the server profile, where the Containerfile's
115 + # overlay disables this unit.
113 116 #
114 117 # The exposure is a daemon holding an adapter that can be discoverable. bluez
115 118 # does not make it discoverable on its own — that is a `bluetoothctl` command a
@@ -1,0 +1,2196 @@
1 + //! Bluetooth devices and what state they are actually in: the `alloy bluetooth` verb.
2 + //!
3 + //! docs/STACK.md and `etc/systemd/system-preset/50-alloy.preset` both already
4 + //! say bluez is kept and that what is missing is a surface. This is the
5 + //! surface.
6 + //!
7 + //! # Why this verb exists
8 + //!
9 + //! Not because pairing is hard. `bluetoothctl` pairs fine. It exists because
10 + //! every Bluetooth interface anyone has used collapses five independent facts
11 + //! into one Connected toggle, and then that toggle lies. The state bluez keeps
12 + //! per device is:
13 + //!
14 + //! - **Paired**: keys have been exchanged.
15 + //! - **Bonded**: those keys were written down, so they survive a reboot.
16 + //! - **Trusted**: bluez will accept a connection this device starts, without
17 + //! a human agreeing to it first.
18 + //! - **Blocked**: bluez refuses the device outright.
19 + //! - **Connected**: a link is up right now.
20 + //!
21 + //! They are independent. The one that decides whether a device ever comes back
22 + //! on its own is Trusted, and pairing does not set it. Measured on fw13
23 + //! 2026-08-05: both of this machine's mice report `Paired: yes, Bonded: yes,
24 + //! Trusted: no`, which is the exact combination that reads to a user as
25 + //! "Bluetooth keeps forgetting my mouse". No graphical panel on any operating
26 + //! system shows that field.
27 + //!
28 + //! So this screen shows all five, raw, and then says in a sentence what the
29 + //! combination means and which key changes it. [`Standing`] is that mapping and
30 + //! is the substance of the verb. Everything else here is plumbing to feed it.
31 + //!
32 + //! # Nothing is automatic
33 + //!
34 + //! Nothing on this screen acts without a keypress, and that follows from the
35 + //! same premise. Bluetooth is unpleasant precisely where it tries to be magic:
36 + //! a device that connects itself, moves the audio out from under what is
37 + //! playing, and then does not do it the next time is worse than one that waits
38 + //! to be told. The console offers `d` to hand a connected headset to the
39 + //! default sink; it does not take it.
40 + //!
41 + //! # Not powered has two causes
42 + //!
43 + //! `Powered: no` from bluez and a radio killed by rfkill look identical from
44 + //! the adapter, and they have different fixes: the first is `power on`, the
45 + //! second is a laptop function key the console cannot press. Reading rfkill
46 + //! alongside `show` is the difference between "press w" and a `power on` that
47 + //! fails with a D-Bus error nobody can act on. See [`Radio`].
48 + //!
49 + //! # Scanning suspends rather than polling
50 + //!
51 + //! Discovery is a session held open for as long as a client holds it, so a
52 + //! one-shot `scan on` that exits discovers nothing. The console hands the
53 + //! terminal to `bluetoothctl --timeout` instead, the user watches real
54 + //! discovery output for [`SCAN_SECONDS`], and the list is re-read on the way
55 + //! back. Pairing suspends for the same reason plus a better one: a device that
56 + //! wants a passkey confirmed needs a terminal to ask through, and a captured
57 + //! child has none.
58 + //!
59 + //! <!-- wiki: alloy-console -->
60 +
61 + use anyhow::{Context, Result};
62 + use serde::Deserialize;
63 +
64 + use alloy_tui::keys::Action;
65 + use alloy_tui::{
66 + AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, Theme, binding,
67 + hint, text, unavailable,
68 + };
69 + use ratatui::Frame;
70 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
71 + use ratatui::layout::{Constraint, Layout, Rect};
72 + use ratatui::text::{Line, Span};
73 + use ratatui::widgets::{Paragraph, Wrap};
74 +
75 + use crate::cli::{CommandLog, Invocation};
76 + use crate::shell::{Confirm, Flow, View, block_title, truncate};
77 +
78 + /// How long a scan runs before the terminal comes back.
79 + ///
80 + /// Long enough for a headset in pairing mode to advertise, short enough that a
81 + /// user who pressed the key by accident is not stuck watching output. Discovery
82 + /// stops when bluetoothctl exits, so this is also how long the adapter is
83 + /// discovering at all.
84 + const SCAN_SECONDS: u32 = 15;
85 +
86 + // ---- the model ----
87 +
88 + /// The controller, from `bluetoothctl show`.
89 + #[derive(Debug, Clone, PartialEq, Eq)]
90 + pub(crate) struct Adapter {
91 + pub address: String,
92 + /// The controller's name, which is what a phone scanning for this machine
93 + /// sees. `Alias` rather than `Name`: alias is the settable one and is what
94 + /// bluez advertises when the two differ.
95 + pub alias: String,
96 + pub powered: bool,
97 + pub discoverable: bool,
98 + pub pairable: bool,
99 + pub discovering: bool,
100 + }
101 +
102 + /// rfkill's verdict on the Bluetooth radio.
103 + ///
104 + /// Separate from [`Adapter::powered`] because they answer different questions
105 + /// and only one of them the console can act on. A soft block is software and
106 + /// `rfkill unblock bluetooth` clears it. A hard block is a physical switch or a
107 + /// firmware key, and no command clears it, so saying "press w" there would be
108 + /// advice that cannot work.
109 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
110 + pub(crate) enum Radio {
111 + Unblocked,
112 + Soft,
113 + Hard,
114 + /// rfkill is absent, or lists no Bluetooth line. Reported as unknown rather
115 + /// than assumed unblocked: a missing answer is not a negative one.
116 + Unknown,
117 + }
118 +
119 + impl Radio {
120 + /// Why the adapter is down, phrased to be the whole of what the user needs
121 + /// to do next. `None` where the radio is not the reason.
122 + pub(crate) const fn blocker(self) -> Option<&'static str> {
123 + match self {
124 + // Named as a switch rather than as rfkill: the fix is a key on the
125 + // keyboard, and the tool that reported it is not the tool that
126 + // fixes it.
127 + Self::Hard => {
128 + Some("the radio is blocked by a hardware switch, which no command can clear")
129 + }
130 + Self::Soft => Some("the radio is soft-blocked (rfkill unblock bluetooth)"),
131 + Self::Unblocked | Self::Unknown => None,
132 + }
133 + }
134 + }
135 +
136 + /// One device bluez knows about, with every state field it keeps.
137 + #[derive(Debug, Clone, PartialEq, Eq)]
138 + pub(crate) struct Device {
139 + pub address: String,
140 + pub name: String,
141 + /// `public` or `random`. Kept because a random address is why a device can
142 + /// appear twice under two addresses, which is otherwise unexplainable.
143 + pub address_type: Option<String>,
144 + /// bluez's `Icon`: `audio-headset`, `input-mouse`, `phone`. Absent on
145 + /// plenty of BLE devices, which is itself worth showing.
146 + pub icon: Option<String>,
147 + pub paired: bool,
148 + pub bonded: bool,
149 + pub trusted: bool,
150 + pub blocked: bool,
151 + pub connected: bool,
152 + /// `Battery Percentage`, which bluez only publishes for a connected device
153 + /// that reports it.
154 + pub battery: Option<u8>,
155 + }
156 +
157 + /// What the five booleans add up to, and the whole point of the verb.
158 + ///
159 + /// Ordered by what decides the answer rather than by severity: blocked beats
160 + /// everything because nothing else on the screen works while it holds, and
161 + /// connected is reported before paired because it is the more immediate fact.
162 + /// Trusted is never folded away, in either direction, since it is the field the
163 + /// user came here without knowing about.
164 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
165 + pub(crate) enum Standing {
166 + /// bluez refuses this device outright.
167 + Blocked,
168 + /// Connected and trusted. What people mean when they say paired.
169 + Connected,
170 + /// Connected right now, and it will not come back on its own.
171 + ConnectedUntrusted,
172 + /// Keys exchanged and trusted, no link up at the moment.
173 + Trusted,
174 + /// Keys exchanged, not trusted, not connected. The state that reads as
175 + /// Bluetooth having forgotten the device.
176 + Paired,
177 + /// An address bluez has seen and holds no keys for.
178 + Seen,
179 + }
180 +
181 + impl Standing {
182 + /// Read the five fields.
183 + pub(crate) const fn of(device: &Device) -> Self {
184 + if device.blocked {
185 + Self::Blocked
186 + } else if device.connected {
187 + if device.trusted {
188 + Self::Connected
189 + } else {
190 + Self::ConnectedUntrusted
191 + }
192 + } else if device.paired {
193 + if device.trusted {
194 + Self::Trusted
195 + } else {
196 + Self::Paired
197 + }
198 + } else {
199 + Self::Seen
200 + }
201 + }
202 +
203 + /// The row label. Long enough to carry the trust half, because a row that
204 + /// says only "connected" is the lie this verb exists to stop telling.
205 + pub(crate) const fn label(self) -> &'static str {
206 + match self {
207 + Self::Blocked => "blocked",
208 + Self::Connected => "connected",
209 + Self::ConnectedUntrusted => "connected, untrusted",
210 + Self::Trusted => "trusted, not connected",
211 + Self::Paired => "paired, untrusted",
212 + Self::Seen => "not paired",
213 + }
214 + }
215 +
216 + pub(crate) const fn severity(self) -> Severity {
217 + match self {
218 + Self::Blocked => Severity::Error,
219 + Self::Connected => Severity::Healthy,
220 + // Warn rather than Healthy: it works now and will stop working
221 + // later, which is the case a color should catch.
222 + Self::ConnectedUntrusted | Self::Paired => Severity::Warn,
223 + Self::Trusted | Self::Seen => Severity::Info,
224 + }
225 + }
226 +
227 + /// What this combination means, and which key changes it.
228 + ///
229 + /// The text a user actually came for. Written as plain sentences rather
230 + /// than as field documentation, because the reader is someone whose mouse
231 + /// stopped working and not someone reading the bluez API.
232 + pub(crate) const fn explain(self) -> &'static str {
233 + match self {
234 + Self::Blocked => {
235 + "bluez is refusing this device. Blocked means it will not connect and will not \
236 + pair, and no other key on this screen will change that while it holds. Press b \
237 + to unblock it."
238 + }
239 + Self::Connected => {
240 + "Connected, and trusted, so bluez accepts it back on its own whenever it is in \
241 + range. This is the state most people mean by paired. Press c to disconnect."
242 + }
243 + Self::ConnectedUntrusted => {
244 + "Connected right now, and not trusted. bluez will not accept a connection it did \
245 + not ask for, so once this device goes out of range or the machine reboots, it \
246 + will not come back on its own. Press t to trust it."
247 + }
248 + Self::Trusted => {
249 + "Paired and trusted, so bluez accepts it whenever it is in range. No link is up \
250 + at the moment, which usually means the device is off or out of range. Press c \
251 + to connect it now."
252 + }
253 + Self::Paired => {
254 + "Paired, so the keys are exchanged, and not trusted, so bluez will not accept it \
255 + back on its own. This is the state that reads as Bluetooth having forgotten the \
256 + device. Press t to trust it, or c to connect it this once."
257 + }
258 + Self::Seen => {
259 + "bluez has seen this address and holds no keys for it. Press p to pair, which \
260 + exchanges keys and moves it to the known list."
261 + }
262 + }
263 + }
264 + }
265 +
266 + impl Device {
267 + pub(crate) const fn standing(&self) -> Standing {
268 + Standing::of(self)
269 + }
270 +
271 + /// A short word for the row, from bluez's icon.
272 + ///
273 + /// The icon is a freedesktop name (`audio-headset`, `input-mouse`), which
274 + /// is a fine thing to key off and a poor thing to show. Anything
275 + /// unrecognized is passed through rather than mapped to "device": the raw
276 + /// name is more informative than a shrug.
277 + pub(crate) fn kind(&self) -> &str {
278 + match self.icon.as_deref() {
279 + None => "unknown",
280 + Some("audio-headset" | "audio-headphones") => "headset",
281 + Some("audio-card") => "speaker",
282 + Some("input-mouse") => "mouse",
283 + Some("input-keyboard") => "keyboard",
284 + Some("input-gaming") => "gamepad",
285 + Some("input-tablet") => "tablet",
286 + Some(other) => other,
287 + }
288 + }
289 +
290 + /// Whether this is something audio would come out of.
291 + ///
292 + /// Gates the default-sink handoff. bluez's icon is the only classification
293 + /// available before connecting, and it is right often enough; a device that
294 + /// is wrong about it still gets the key, and PipeWire's answer (no matching
295 + /// sink) is the honest refusal.
296 + pub(crate) fn is_audio(&self) -> bool {
297 + self.icon
298 + .as_deref()
299 + .is_some_and(|icon| icon.starts_with("audio"))
300 + }
301 +
302 + /// The extra fact worth saying when the headline does not cover it.
303 + ///
304 + /// Paired without bonded means the keys were never written down, so the
305 + /// pairing does not survive a reboot. It is rare and it is invisible in
306 + /// every other tool, which is exactly the combination that earns a line.
307 + pub(crate) const fn caveat(&self) -> Option<&'static str> {
308 + if self.paired && !self.bonded {
309 + Some(
310 + "Paired but not bonded: the keys were not written to disk, so this pairing does \
311 + not survive a reboot.",
312 + )
313 + } else {
314 + None
315 + }
316 + }
317 +
318 + /// The PipeWire sink name prefix this device's audio would arrive under.
319 + ///
320 + /// bluez-to-PipeWire naming, measured rather than guessed: a device at
321 + /// `D2:0F:A1:0C:48:3F` becomes `bluez_output.D2_0F_A1_0C_48_3F.1`, with a
322 + /// trailing profile index that is not predictable. So this is a prefix and
323 + /// the caller matches on it.
324 + fn sink_prefix(&self) -> String {
325 + format!("bluez_output.{}", self.address.replace(':', "_"))
326 + }
327 + }
328 +
329 + // ---- backends ----
330 +
331 + /// Backends build argv and run nothing. The view executes through the log,
332 + /// which is what makes docs/CONSOLE.md's coverage promise structural rather
333 + /// than a habit.
334 + pub(crate) trait Backend {
335 + fn name(&self) -> &'static str;
336 +
337 + /// The controller. An error here is the whole screen's error: with no
338 + /// adapter there is nothing to list.
339 + fn adapter(&self, log: &mut CommandLog) -> Result<Adapter>;
340 +
341 + /// Every address bluez knows, as `(address, name)`. The state fields come
342 + /// from [`Backend::info`].
343 + fn devices(&self, log: &mut CommandLog) -> Result<Vec<(String, String)>>;
344 +
345 + /// Everything bluez keeps about one device.
346 + fn info(&self, address: &str, log: &mut CommandLog) -> Result<Device>;
347 +
348 + /// The radio, read without logging. rfkill is the console's own
349 + /// bookkeeping: the user asked about Bluetooth, not about rfkill, and the
350 + /// answer only ever appears as a phrase in the header.
351 + fn radio(&self) -> Radio {
352 + Radio::Unknown
353 + }
354 +
355 + fn pair(&self, _device: &Device) -> Option<Invocation> {
356 + None
357 + }
358 +
359 + fn connect(&self, _device: &Device) -> Option<Invocation> {
360 + None
361 + }
362 +
363 + fn disconnect(&self, _device: &Device) -> Option<Invocation> {
364 + None
365 + }
366 +
367 + fn trust(&self, _device: &Device, _trusted: bool) -> Option<Invocation> {
368 + None
369 + }
370 +
371 + fn block(&self, _device: &Device, _blocked: bool) -> Option<Invocation> {
372 + None
373 + }
374 +
375 + /// Forget the device: drops the keys as well as the entry.
376 + fn remove(&self, _device: &Device) -> Option<Invocation> {
377 + None
378 + }
379 +
380 + fn power(&self, _on: bool) -> Option<Invocation> {
381 + None
382 + }
383 +
384 + /// A bounded discovery run, handed the terminal rather than captured.
385 + fn scan(&self) -> Option<Invocation> {
386 + None
387 + }
388 +
389 + /// List PipeWire's sinks, for finding the one a connected headset landed on.
390 + fn sinks(&self) -> Option<Invocation> {
391 + None
392 + }
393 +
394 + fn set_default_sink(&self, _sink: &str) -> Option<Invocation> {
395 + None
396 + }
397 + }
398 +
399 + /// Pick a backend: the real one when `bluetoothctl` answers, the mock
400 + /// otherwise.
401 + ///
402 + /// A `--version` probe rather than a `which` check, matching every other verb.
403 + /// Note that this probes the client and not the daemon: bluetoothctl answers
404 + /// `--version` with bluetoothd down. That is deliberate, because the daemon
405 + /// being down is a state the screen should report rather than one that should
406 + /// drop it to mock devices, and [`Backend::adapter`] is where it surfaces.
407 + pub(crate) fn detect() -> Box<dyn Backend> {
408 + if Invocation::new("bluetoothctl").arg("--version").probe() {
409 + Box::new(BluetoothCtl {
410 + pactl: Invocation::new("pactl").arg("--version").probe(),
411 + })
412 + } else {
413 + Box::new(Mock)
414 + }
415 + }
416 +
417 + pub(crate) struct BluetoothCtl {
418 + /// Whether the audio handoff is offered. Read once at construction, like
419 + /// `disk`'s udisks flag.
420 + pactl: bool,
421 + }
422 +
423 + impl BluetoothCtl {
424 + /// The pairing agent capability.
425 + ///
426 + /// `NoInputNoOutput` would suppress the passkey prompt entirely and pair
427 + /// silently, which is the wrong trade here: a device asking a human to
428 + /// confirm a number is the one moment Bluetooth's security model is
429 + /// visible, and suppressing it to save a keystroke is how pairing becomes
430 + /// magic. `KeyboardDisplay` says the terminal can both show and answer,
431 + /// which is true, because pairing suspends onto a real one.
432 + const AGENT: &'static str = "KeyboardDisplay";
433 +
434 + fn ctl() -> Invocation {
435 + Invocation::new("bluetoothctl")
436 + }
437 + }
438 +
439 + impl Backend for BluetoothCtl {
440 + fn name(&self) -> &'static str {
441 + "bluetoothctl"
442 + }
443 +
444 + fn adapter(&self, log: &mut CommandLog) -> Result<Adapter> {
445 + parse_show(&Self::ctl().arg("show").run(log)?)
446 + }
447 +
448 + fn devices(&self, log: &mut CommandLog) -> Result<Vec<(String, String)>> {
449 + Ok(parse_device_list(&Self::ctl().arg("devices").run(log)?))
450 + }
451 +
452 + fn info(&self, address: &str, log: &mut CommandLog) -> Result<Device> {
453 + parse_info(&Self::ctl().args(["info", address]).run(log)?)
454 + }
455 +
456 + fn radio(&self) -> Radio {
457 + // `--noheadings --output` rather than the default listing or `--json`:
458 + // util-linux 2.39 accepts `--json` and ignores it, printing the human
459 + // format, so a JSON parser here would fail on the machine it was
460 + // written for. Measured on fw13, 2026-08-05.
461 + Invocation::new("rfkill")
462 + .args([
463 + "--noheadings",
464 + "--output",
465 + "TYPE,SOFT,HARD",
466 + "list",
467 + "bluetooth",
468 + ])
469 + .capture_quiet()
470 + .map_or(Radio::Unknown, |raw| parse_rfkill(&raw))
471 + }
472 +
473 + fn pair(&self, device: &Device) -> Option<Invocation> {
474 + Some(Self::ctl().args(["--agent", Self::AGENT, "pair", &device.address]))
475 + }
476 +
477 + fn connect(&self, device: &Device) -> Option<Invocation> {
478 + Some(Self::ctl().args(["connect", &device.address]))
479 + }
480 +
481 + fn disconnect(&self, device: &Device) -> Option<Invocation> {
482 + Some(Self::ctl().args(["disconnect", &device.address]))
483 + }
484 +
485 + fn trust(&self, device: &Device, trusted: bool) -> Option<Invocation> {
486 + let verb = if trusted { "trust" } else { "untrust" };
487 + Some(Self::ctl().args([verb, &device.address]))
488 + }
489 +
490 + fn block(&self, device: &Device, blocked: bool) -> Option<Invocation> {
491 + let verb = if blocked { "block" } else { "unblock" };
492 + Some(Self::ctl().args([verb, &device.address]))
493 + }
494 +
495 + fn remove(&self, device: &Device) -> Option<Invocation> {
496 + Some(Self::ctl().args(["remove", &device.address]))
497 + }
498 +
499 + fn power(&self, on: bool) -> Option<Invocation> {
500 + Some(Self::ctl().args(["power", if on { "on" } else { "off" }]))
Lines truncated