Krolyxon
Commit a21a864

Branches

Refs →

budctla21a864

budctl-gui talks to the buds (GUI step 3)

worker.rs owns the session on a thread of its own, since budctl-core
blocks and a write with its read-back can take seconds. The window
sends commands (read, noise control, switch) and gets one event back
per command, in order. The session opens when a command needs it,
answers the buds' own requests while it waits, and closes 10 s after
the last command, so the control channel is free for other apps.

The window reads the buds at start, again on "Refresh" or r, and when
it gets focus after 30 s. A write shows "Saving..." until the buds'
read-back arrives and takes no second write meanwhile. Failures say
why: a panel before the first reading (not connected, busy, error), a
banner after it, both with "Try again". The EQ row names custom presets
by their stored name.

New flags: --demo (the made-up buds, without Bluetooth), --device
<MAC> and --debug, as in the CLI.

Core: Session::serve() answers what the buds sent without waiting; the
T310 pumps its link with a deadline that has already passed. Status
and EqPresets derive Clone, for iced messages.

No new dependencies.
Author
krolyxon
Date
2026-10-27 23:35 +0530
Parent
92a131a
Commit
a21a864c7f8d2b237273cb7d79c4440aebc8be42

+747 −78

9 files changed

  1. M CLAUDE.md +2 −1
  2. M crates/budctl-core/src/devices/mod.rs +8 −0
  3. M crates/budctl-core/src/devices/realme_t310/mod.rs +22 −0
  4. M crates/budctl-core/src/status.rs +2 −2
  5. M crates/budctl-gui/src/demo.rs +12 −2
  6. M crates/budctl-gui/src/keys.rs +6 −1
  7. M crates/budctl-gui/src/main.rs +485 −61
  8. M crates/budctl-gui/src/overview.rs +27 −11
  9. A crates/budctl-gui/src/worker.rs +183 −0
M CLAUDE.md View
@@ -66,7 +66,7 @@ Many realme / OPPO / OnePlus buds share one protocol family. `devices/realme_t31
6666 ## Connection behavior
6767
6868 - The control channel is often single-client. If an app on the phone holds it, connecting from Linux may fail. Report that plainly ("control channel busy — is another app connected?") instead of retrying in a loop.
69−- CLI commands connect, act and disconnect. The GUI must also release the channel when idle rather than hold it open.
69+- CLI commands connect, act and disconnect. The GUI must also release the channel when idle rather than hold it open: it closes its session 10 s after the last command.
7070 - Timeouts on every read. Buds that don't answer should produce a clear error, never a hang.
7171 - While a session waits for anything, it keeps answering the buds' own requests (e.g. the time request). Never `thread::sleep` in core; clippy bans it.
7272
@@ -124,6 +124,7 @@ Exit codes:
124124 - `h`/`l` pick the previous or next choice, or turn a switch off or on, and `l` opens a row's page. Enter and Space toggle, step round the choices, or open.
125125 - `J`/`K`, `Ctrl+Tab`, `gt`/`gT` and `1`–`5` change pages. `Esc` hides the cursor, `q` quits.
126126 - The cursor shows only after a key press, and the first key only shows it, so nothing changes where it cannot be seen. A click hides it.
127+- **Buds:** `worker.rs` owns the session, on a thread of its own, and answers each command with one event, in order. It opens the session when a command needs it, answers the buds' own requests while it waits (`Session::serve`), and closes it 10 s after the last command. A write shows "Saving…" until the buds' read-back arrives, and takes no second write meanwhile. A failure says why, with "Try again". `--demo` shows made-up buds; `--debug` hex-dumps frames like the CLI.
127128 - **Logic:** keys and clicks both become messages for one `update` function, which stays testable without a display. `iced_test` renders pages to PNG, so the look can be checked without the owner.
128129
129130 ## Working with Claude
M crates/budctl-core/src/devices/mod.rs View
@@ -31,6 +31,14 @@ pub trait Device: Sync {
3131
3232 /// An open control link to one pair of buds. Dropping it closes the link.
3333 pub trait Session: Send {
34+ /// Answer what the buds sent since the last call, without waiting. Some buds drop the link
35+ /// when their own requests (the time request, for one) go unanswered, so a front end that
36+ /// keeps a session open between commands calls this every so often. An error means the link
37+ /// is gone.
38+ fn serve(&mut self) -> Result<()> {
39+ Ok(())
40+ }
41+
3442 /// Battery, noise control, EQ, switches and whatever else the model can read.
3543 fn status(&mut self) -> Result<Status>;
3644
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -199,6 +199,11 @@ impl T310Session {
199199 }
200200
201201 impl Session for T310Session {
202+ /// Spec: realme_t310.md#buds-requests
203+ fn serve(&mut self) -> Result<()> {
204+ self.link.pump_until(Instant::now())
205+ }
206+
202207 fn status(&mut self) -> Result<Status> {
203208 let mut reads = Reads::default();
204209 let switches = self.read(
@@ -610,6 +615,23 @@ mod tests {
610615 ]
611616 }
612617
618+ #[test]
619+ fn serve_answers_the_buds_at_once() {
620+ let mut steps = handshake();
621+ // The time request, at Unix time 1750000000. Spec: realme_t310.md#buds-requests
622+ steps.extend([rx(0x0500, 0x02, ""), tx(0x8500, 0x02, "00 80 E1 4E 68")]);
623+ let script = Script::new(steps);
624+ let mut session = open(&script).unwrap();
625+ session.serve().unwrap();
626+ script.assert_done();
627+
628+ let mut steps = handshake();
629+ steps.push(Step::Close);
630+ let script = Script::new(steps);
631+ let mut session = open(&script).unwrap();
632+ assert!(matches!(session.serve(), Err(Error::NotConnected(_))));
633+ }
634+
613635 #[test]
614636 fn full_status() {
615637 let mut steps = handshake();
M crates/budctl-core/src/status.rs View
@@ -6,7 +6,7 @@ use serde::{Serialize, Serializer};
66 /// Bumped whenever the JSON shape changes incompatibly.
77 pub const JSON_SCHEMA: u32 = 1;
88
9−#[derive(Debug, Default, Serialize)]
9+#[derive(Debug, Default, Clone, Serialize)]
1010 pub struct Status {
1111 pub battery: Battery,
1212 pub noise_control: Option<NoiseControl>,
@@ -114,7 +114,7 @@ pub struct Gesture {
114114 }
115115
116116 /// The EQ presets the buds have, and which one is in use.
117−#[derive(Debug, Default, Serialize)]
117+#[derive(Debug, Default, Clone, Serialize)]
118118 pub struct EqPresets {
119119 /// Name of the preset in use, as in [`Status::eq`].
120120 pub current: Option<String>,
M crates/budctl-gui/src/demo.rs View
@@ -1,5 +1,5 @@
1−//! Demo data: roughly what the owner's T310 reported at the last checkpoint. The GUI shows it
2−//! until it talks to real buds.
1+//! The buds the window shows: made-up ones for `--demo` (roughly what the owner's T310 reported
2+//! at the last checkpoint), or an empty pair until the real buds answer.
33
44 use budctl_core::status::{Battery, Charge, NoiseControl, Status, Switch};
55
@@ -44,3 +44,13 @@ pub fn buds() -> Buds {
4444 eq_name: "Custom1".to_owned(),
4545 }
4646 }
47+
48+/// What the window shows before the real buds answer.
49+pub fn unknown() -> Buds {
50+ Buds {
51+ name: "Your buds".to_owned(),
52+ connected: false,
53+ status: Status::default(),
54+ eq_name: String::new(),
55+ }
56+}
M crates/budctl-gui/src/keys.rs View
@@ -23,6 +23,8 @@ pub enum Action {
2323 PreviousPage,
2424 /// A page by its place in the sidebar, from 0.
2525 Page(usize),
26+ /// Read the buds again.
27+ Refresh,
2628 /// Show or hide the list of keys.
2729 Help,
2830 /// Close the list of keys, else hide the cursor.
@@ -31,7 +33,7 @@ pub enum Action {
3133 }
3234
3335 /// The keys, as the list of keys shows them.
34−pub const HELP: [(&str, &str); 10] = [
36+pub const HELP: [(&str, &str); 11] = [
3537 ("j k ↓ ↑", "move the cursor"),
3638 ("h l ← →", "previous / next choice, off / on"),
3739 ("Enter Space", "toggle, next choice, or open"),
@@ -39,6 +41,7 @@ pub const HELP: [(&str, &str); 10] = [
3941 ("J K Ctrl+Tab", "next / previous page"),
4042 ("gt gT", "next / previous page"),
4143 ("1 … 5", "go to a page"),
44+ ("r", "read the buds again"),
4245 ("Esc", "hide the cursor"),
4346 ("?", "these keys"),
4447 ("q", "quit"),
@@ -93,6 +96,7 @@ impl Keys {
9396 "J" => Some(Action::NextPage),
9497 "K" => Some(Action::PreviousPage),
9598 " " => Some(Action::Activate),
99+ "r" => Some(Action::Refresh),
96100 "?" => Some(Action::Help),
97101 "q" => Some(Action::Quit),
98102 digit => match digit.parse::<usize>() {
@@ -148,6 +152,7 @@ mod tests {
148152 ("C-j", Some(Action::NextPage)),
149153 ("3", Some(Action::Page(2))),
150154 ("0", None),
155+ ("r", Some(Action::Refresh)),
151156 ("?", Some(Action::Help)),
152157 ("Esc", Some(Action::Escape)),
153158 ("q", Some(Action::Quit)),
M crates/budctl-gui/src/main.rs View
@@ -1,8 +1,8 @@
11 //! `budctl-gui`: control wireless earbuds from Linux, in a window.
22 //!
3−//! A thin layer over budctl-core, like the CLI. For now it shows demo data: the look comes first,
4−//! the live connection later (CLAUDE.md, GUI). The mouse comes first; vim-like keys work on top
5−//! (`keys.rs`).
3+//! A thin layer over budctl-core, like the CLI: `worker.rs` talks to the buds on a thread of its
4+//! own, and the window shows what they report. The mouse comes first; vim-like keys work on top
5+//! (`keys.rs`). `--demo` shows made-up buds instead.
66
77 mod demo;
88 mod icons;
@@ -10,11 +10,17 @@ mod keys;
1010 mod overview;
1111 mod theme;
1212 mod ui;
13+mod worker;
1314
15+use std::collections::VecDeque;
1416 use std::path::PathBuf;
1517 use std::process::ExitCode;
18+use std::sync::mpsc::Sender;
19+use std::time::{Duration, Instant};
1620
17−use budctl_core::status::{AncLevel, NoiseControl, NoiseMode};
21+use budctl_core::Kind;
22+use budctl_core::bt::BdAddr;
23+use budctl_core::status::{AncLevel, EqPresets, NoiseControl, NoiseMode, Switch};
1824 use iced::keyboard::{self, Key, Modifiers};
1925 use iced::widget::operation::{self, RelativeOffset};
2026 use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text};
@@ -23,9 +29,13 @@ use iced::{Alignment, Border, Color, Element, Font, Length, Size, Subscription,
2329 use icons::Icon;
2430 use keys::Action;
2531 use theme::Look;
32+use worker::{Command, Event, Reading};
2633
27−const USAGE: &str = "usage: budctl-gui [--theme <palette.json>]
34+const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>] [--demo] [--debug]
2835
36+--device picks the buds when more than one supported pair is connected.
37+--demo shows made-up buds, without Bluetooth.
38+--debug hex-dumps every frame sent and received on stderr, as budctl --debug does.
2939 Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in.
3040 The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).
3141 Press ? in the window for the keys.";
@@ -33,9 +43,13 @@ Press ? in the window for the keys.";
3343 /// The page's scrolling area, so the cursor can be kept in view.
3444 const SCROLL: &str = "page";
3545
46+/// When the window gets focus and the last reading is older than this, the buds are read again:
47+/// the phone may have changed something meanwhile.
48+const STALE: Duration = Duration::from_secs(30);
49+
3650 fn main() -> ExitCode {
37− let theme_file = match parse_args(std::env::args().skip(1)) {
38− Ok(Args::Run { theme }) => theme.or_else(theme::default_file),
51+ let options = match parse_args(std::env::args().skip(1)) {
52+ Ok(Args::Run(options)) => options,
3953 Ok(Args::Help) => {
4054 println!("{USAGE}");
4155 return ExitCode::SUCCESS;
@@ -45,8 +59,13 @@ fn main() -> ExitCode {
4559 return ExitCode::from(1);
4660 }
4761 };
62+ if options.debug {
63+ budctl_core::debug::enable();
64+ }
65+ let theme_file = options.theme.clone().or_else(theme::default_file);
4866 let font = theme::system_font();
49− let result = iced::application(move || App::new(theme_file.clone(), font), App::update, App::view)
67+ let boot = move || App::new(theme_file.clone(), font, options.device, options.demo);
68+ let result = iced::application(boot, App::update, App::view)
5069 .title("budctl")
5170 .theme(App::theme)
5271 .subscription(App::subscription)
@@ -70,21 +89,35 @@ fn main() -> ExitCode {
7089 }
7190 }
7291
92+#[derive(Default)]
93+struct Options {
94+ theme: Option<PathBuf>,
95+ device: Option<BdAddr>,
96+ demo: bool,
97+ debug: bool,
98+}
99+
73100 enum Args {
74− Run { theme: Option<PathBuf> },
101+ Run(Options),
75102 Help,
76103 }
77104
78105 fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
79− let mut theme = None;
106+ let mut options = Options::default();
80107 while let Some(arg) = args.next() {
81108 match arg.as_str() {
82− "--theme" => theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)),
109+ "--theme" => options.theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)),
110+ "--device" => {
111+ let address = args.next().ok_or("--device needs an address")?;
112+ options.device = Some(address.parse().map_err(|e| format!("--device: {e}"))?);
113+ }
114+ "--demo" => options.demo = true,
115+ "--debug" => options.debug = true,
83116 "-h" | "--help" => return Ok(Args::Help),
84117 other => return Err(format!("unknown argument {other:?}")),
85118 }
86119 }
87− Ok(Args::Run { theme })
120+ Ok(Args::Run(options))
88121 }
89122
90123 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -134,7 +167,7 @@ impl Page {
134167 }
135168 }
136169
137−/// What the keyboard cursor can rest on.
170+/// What the keyboard cursor can rest on, and what a write changes.
138171 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
139172 enum Item {
140173 NoiseMode,
@@ -145,14 +178,29 @@ enum Item {
145178 Open(Page),
146179 }
147180
181+/// Where the connection to the buds stands.
182+#[derive(Debug, Clone, PartialEq)]
183+enum Link {
184+ /// Made-up buds (`--demo`): nothing talks to Bluetooth.
185+ Demo,
186+ /// Looking for the buds, or reading them for the first time.
187+ Connecting,
188+ /// The buds answered the last command.
189+ Live,
190+ /// The last command failed.
191+ Failed { kind: Kind, message: String },
192+}
193+
148194 #[derive(Debug, Clone)]
149195 enum Message {
150196 Open(Page),
151197 Noise(NoiseMode),
152198 /// A switch's key, and whether to turn it on.
153199 Switch(&'static str, bool),
154− Connect(bool),
155− /// The window got focus: the palette file may have changed meanwhile.
200+ /// Read the buds again.
201+ Refresh,
202+ Worker(Event),
203+ /// The window got focus: the palette file may have changed meanwhile, and so may the buds.
156204 Focused,
157205 Key(Key, Modifiers),
158206 /// A mouse button went down: the mouse is in use, so the keyboard cursor hides.
@@ -175,11 +223,21 @@ struct App {
175223 cursor_shown: bool,
176224 /// Whether the list of keys is open.
177225 help: bool,
226+ /// `--device`, for the worker.
227+ device: Option<BdAddr>,
228+ link: Link,
229+ /// Where commands go, once the worker is up.
230+ worker: Option<Sender<Command>>,
231+ /// The commands sent and not answered yet, oldest first: the item each one writes, or `None`
232+ /// for a read. The worker answers them in order.
233+ in_flight: VecDeque<Option<Item>>,
234+ /// When the buds were last read.
235+ read_at: Option<Instant>,
178236 }
179237
180238 impl App {
181− fn new(theme_file: Option<PathBuf>, font: Font) -> Self {
182− let buds = demo::buds();
239+ fn new(theme_file: Option<PathBuf>, font: Font, device: Option<BdAddr>, demo: bool) -> Self {
240+ let buds = if demo { demo::buds() } else { demo::unknown() };
183241 let anc_level = buds
184242 .status
185243 .noise_control
@@ -201,6 +259,11 @@ impl App {
201259 cursor: 0,
202260 cursor_shown: false,
203261 help: false,
262+ device,
263+ link: if demo { Link::Demo } else { Link::Connecting },
264+ worker: None,
265+ in_flight: VecDeque::new(),
266+ read_at: None,
204267 };
205268 app.refresh_palette();
206269 app
@@ -209,26 +272,16 @@ impl App {
209272 fn update(&mut self, message: Message) -> Task<Message> {
210273 match message {
211274 Message::Open(page) => return self.open(page),
212− Message::Noise(mode) => {
213− let level = match mode {
214− NoiseMode::Anc(level) => {
215− self.anc_level = level;
216− Some(level.name().to_owned())
217− }
218− NoiseMode::Off | NoiseMode::Transparency => None,
219− };
220− self.buds.status.noise_control = Some(NoiseControl {
221− mode: mode.name().to_owned(),
222− level,
223− });
224− }
225− Message::Switch(key, on) => {
226− if let Some(switch) = self.buds.status.switches.iter_mut().find(|s| s.key == key) {
227− switch.on = on;
275+ Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
276+ Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key, on)),
277+ Message::Refresh => self.refresh(),
278+ Message::Worker(event) => self.answer(event),
279+ Message::Focused => {
280+ self.refresh_palette();
281+ if self.read_at.is_some_and(|at| at.elapsed() >= STALE) && self.in_flight.is_empty() {
282+ self.refresh();
228283 }
229284 }
230− Message::Connect(on) => self.buds.connected = on,
231− Message::Focused => self.refresh_palette(),
232285 Message::Key(key, modifiers) => return self.key(&key, modifiers),
233286 Message::Pointer => self.cursor_shown = false,
234287 Message::CloseHelp => self.help = false,
@@ -236,6 +289,128 @@ impl App {
236289 Task::none()
237290 }
238291
292+ /// Changes a setting: at once with the made-up buds, else through the worker. Until the buds
293+ /// answer, the item shows that it is saving and takes no other write.
294+ fn write(&mut self, item: Item, command: Command) {
295+ if self.link == Link::Demo {
296+ match command {
297+ Command::Noise(mode) => self.apply_noise(NoiseControl {
298+ mode: mode.name().to_owned(),
299+ level: match mode {
300+ NoiseMode::Anc(level) => Some(level.name().to_owned()),
301+ NoiseMode::Off | NoiseMode::Transparency => None,
302+ },
303+ }),
304+ Command::Switch(key, on) => {
305+ if let Some(switch) = self.buds.status.switches.iter().find(|s| s.key == key) {
306+ let switch = Switch { on, ..switch.clone() };
307+ self.apply_switch(switch);
308+ }
309+ }
310+ Command::Read => {}
311+ }
312+ } else if !self.pending().contains(&item) {
313+ self.send(command, Some(item));
314+ }
315+ }
316+
317+ /// Reads the buds again; with the made-up buds, starts them over.
318+ fn refresh(&mut self) {
319+ match self.link {
320+ Link::Demo => self.buds = demo::buds(),
321+ _ if self.in_flight.contains(&None) => {}
322+ _ => {
323+ if self.read_at.is_none() {
324+ self.link = Link::Connecting;
325+ }
326+ self.send(Command::Read, None);
327+ }
328+ }
329+ }
330+
331+ fn send(&mut self, command: Command, item: Option<Item>) {
332+ let Some(worker) = &self.worker else {
333+ return;
334+ };
335+ if worker.send(command).is_ok() {
336+ self.in_flight.push_back(item);
337+ }
338+ }
339+
340+ /// Takes in what the worker reports.
341+ fn answer(&mut self, event: Event) {
342+ match event {
343+ Event::Ready(worker) => {
344+ self.worker = Some(worker);
345+ self.send(Command::Read, None);
346+ }
347+ Event::Found(name) => self.buds.name = name,
348+ Event::Lost(message) => {
349+ self.buds.connected = false;
350+ self.link = Link::Failed {
351+ kind: Kind::NotFound,
352+ message,
353+ };
354+ }
355+ Event::Read(reading) => {
356+ self.in_flight.pop_front();
357+ self.apply_reading(*reading);
358+ }
359+ Event::Noise(now) => {
360+ self.in_flight.pop_front();
361+ self.apply_noise(now);
362+ self.link = Link::Live;
363+ }
364+ Event::Switch(switch) => {
365+ self.in_flight.pop_front();
366+ self.apply_switch(switch);
367+ self.link = Link::Live;
368+ }
369+ Event::Failed { kind, message } => {
370+ self.in_flight.pop_front();
371+ if kind == Kind::NotFound {
372+ self.buds.connected = false;
373+ }
374+ self.link = Link::Failed { kind, message };
375+ }
376+ }
377+ }
378+
379+ fn apply_reading(&mut self, reading: Reading) {
380+ self.buds.eq_name = eq_name(reading.status.eq.as_deref(), reading.eq.as_ref());
381+ if let Some(level) = reading
382+ .status
383+ .noise_control
384+ .as_ref()
385+ .and_then(|now| now.level.as_deref())
386+ .and_then(anc_level)
387+ {
388+ self.anc_level = level;
389+ }
390+ self.buds.status = reading.status;
391+ self.buds.connected = true;
392+ self.link = Link::Live;
393+ self.read_at = Some(Instant::now());
394+ }
395+
396+ fn apply_noise(&mut self, now: NoiseControl) {
397+ if let Some(level) = now.level.as_deref().and_then(anc_level) {
398+ self.anc_level = level;
399+ }
400+ self.buds.status.noise_control = Some(now);
401+ }
402+
403+ fn apply_switch(&mut self, switch: Switch) {
404+ if let Some(shown) = self.buds.status.switches.iter_mut().find(|s| s.key == switch.key) {
405+ *shown = switch;
406+ }
407+ }
408+
409+ /// The items with a write on its way.
410+ fn pending(&self) -> Vec<Item> {
411+ self.in_flight.iter().flatten().copied().collect()
412+ }
413+
239414 /// Shows `page` from its top, with the cursor on its first item.
240415 fn open(&mut self, page: Page) -> Task<Message> {
241416 self.page = page;
@@ -262,6 +437,10 @@ impl App {
262437 self.cursor_shown = false;
263438 Task::none()
264439 }
440+ Action::Refresh => {
441+ self.refresh();
442+ Task::none()
443+ }
265444 Action::NextPage => self.open(self.page.step(true)),
266445 Action::PreviousPage => self.open(self.page.step(false)),
267446 Action::Page(n) => match Page::ALL.get(n) {
@@ -281,11 +460,16 @@ impl App {
281460 /// What the current page offers the keyboard cursor, top to bottom.
282461 fn items(&self) -> Vec<Item> {
283462 match self.page {
284− Page::Overview => overview::items(&self.buds),
463+ Page::Overview if self.has_reading() => overview::items(&self.buds),
285464 _ => Vec::new(),
286465 }
287466 }
288467
468+ /// Whether there is anything to show: the made-up buds, or a reading of the real ones.
469+ fn has_reading(&self) -> bool {
470+ self.link == Link::Demo || self.read_at.is_some()
471+ }
472+
289473 /// The item under the cursor, when the cursor shows.
290474 fn focus(&self) -> Option<Item> {
291475 let items = self.items();
@@ -376,20 +560,33 @@ impl App {
376560 }
377561
378562 fn subscription(&self) -> Subscription<Message> {
379− event::listen_with(|event, _status, _window| match event {
563+ let events = event::listen_with(|event, _status, _window| match event {
380564 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
381565 iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
382566 Some(Message::Key(key, modifiers))
383567 }
384568 iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer),
385569 _ => None,
386− })
570+ });
571+ if self.link == Link::Demo {
572+ return events;
573+ }
574+ let worker = Subscription::run_with(self.device, worker::run).map(Message::Worker);
575+ Subscription::batch([events, worker])
387576 }
388577
389578 fn view(&self) -> Element<'_, Message> {
390579 let look = self.look;
580+ let pending = self.pending();
391581 let page = match self.page {
392− Page::Overview => overview::view(look, &self.buds, self.anc_level, self.focus()),
582+ Page::Overview if self.has_reading() => {
583+ let marks = overview::Marks {
584+ focus: self.focus(),
585+ pending: &pending,
586+ };
587+ overview::view(look, &self.buds, self.anc_level, marks)
588+ }
589+ Page::Overview => self.connecting(),
393590 page => column![
394591 ui::title(look, page.title()),
395592 text("This page comes in a later step.").size(14).color(look.p.dim)
@@ -397,6 +594,7 @@ impl App {
397594 .spacing(12)
398595 .into(),
399596 };
597+ let page = column![self.problem(), page].spacing(18);
400598 let p = look.p;
401599 let content = scrollable(
402600 container(container(page).max_width(860))
@@ -423,31 +621,90 @@ impl App {
423621 }
424622 }
425623
624+ /// The Overview before the first reading: connecting, or why it did not work.
625+ fn connecting(&self) -> Element<'_, Message> {
626+ let look = self.look;
627+ let p = look.p;
628+ let (headline, detail) = match &self.link {
629+ Link::Failed {
630+ kind: Kind::NotFound,
631+ message,
632+ } => ("Your buds are not connected", message.as_str()),
633+ Link::Failed {
634+ kind: Kind::Busy,
635+ message,
636+ } => ("The control channel is busy", message.as_str()),
637+ Link::Failed { message, .. } => ("Something went wrong", message.as_str()),
638+ _ => (
639+ "Connecting to your buds…",
640+ "budctl looks for the paired buds it supports and reads them.",
641+ ),
642+ };
643+ let retry = matches!(self.link, Link::Failed { .. })
644+ .then(|| container(ui::outline_button(look, "Try again", Message::Refresh)).width(180));
645+ let words = column![
646+ text(headline).size(20).font(look.bold()).color(p.fg),
647+ text(detail).size(14).color(p.dim)
648+ ]
649+ .spacing(8);
650+ column![
651+ ui::title(look, "Overview"),
652+ ui::panel(look, column![words].push(retry).spacing(18).into())
653+ ]
654+ .spacing(18)
655+ .into()
656+ }
657+
658+ /// A failure after the buds were read once: what went wrong, above the page.
659+ fn problem(&self) -> Option<Element<'_, Message>> {
660+ let Link::Failed { message, .. } = &self.link else {
661+ return None;
662+ };
663+ // Before the first reading, the Overview says it already.
664+ self.read_at?;
665+ let look = self.look;
666+ let p = look.p;
667+ let content = row![
668+ text(message.as_str()).size(14).color(p.err).width(Length::Fill),
669+ container(ui::outline_button(look, "Try again", Message::Refresh)).width(140)
670+ ]
671+ .spacing(16)
672+ .align_y(Alignment::Center);
673+ Some(
674+ container(content)
675+ .padding([12, 16])
676+ .width(Length::Fill)
677+ .style(move |_| container::Style {
678+ background: Some(p.surface.into()),
679+ border: Border {
680+ color: p.err,
681+ width: 1.0,
682+ radius: 14.0.into(),
683+ },
684+ ..container::Style::default()
685+ })
686+ .into(),
687+ )
688+ }
689+
426690 fn sidebar(&self) -> Element<'_, Message> {
427691 let look = self.look;
428692 let p = look.p;
429− let connected = self.buds.connected;
430− let (light, state) = if connected {
431− (p.ok, "Connected")
432− } else {
433− (p.dim, "Not connected")
693+ let (light, state) = match &self.link {
694+ Link::Demo | Link::Live => (p.ok, "Connected"),
695+ Link::Connecting => (p.dim, "Connecting…"),
696+ Link::Failed { kind: Kind::Busy, .. } => (p.err, "Busy"),
697+ Link::Failed { .. } if !self.buds.connected => (p.dim, "Not connected"),
698+ Link::Failed { .. } => (p.err, "Error"),
434699 };
435700 let device = container(
436701 column![
437702 text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg),
438− row![
439− ui::light(light),
440− text(state).size(13).color(p.dim),
441− space::horizontal(),
442− ui::tag(look, "demo")
443− ]
444− .spacing(8)
445− .align_y(Alignment::Center),
446− ui::outline_button(
447− look,
448− if connected { "Disconnect" } else { "Connect" },
449− Message::Connect(!connected)
450− ),
703+ row![ui::light(light), text(state).size(13).color(p.dim), space::horizontal()]
704+ .push((self.link == Link::Demo).then(|| ui::tag(look, "demo")))
705+ .spacing(8)
706+ .align_y(Alignment::Center),
707+ ui::outline_button(look, "Refresh", Message::Refresh),
451708 ]
452709 .spacing(12),
453710 )
@@ -523,15 +780,54 @@ fn anc_level(name: &str) -> Option<AncLevel> {
523780 .find(|level| level.name() == name)
524781 }
525782
783+/// The name to show for the EQ preset in use: a custom preset's own name (`custom-4` →
784+/// "Custom1"), else the preset's name in words (`pure-bass` → "Pure bass").
785+fn eq_name(current: Option<&str>, presets: Option<&EqPresets>) -> String {
786+ let Some(current) = current else {
787+ return "Unknown".to_owned();
788+ };
789+ let custom = current
790+ .strip_prefix("custom-")
791+ .and_then(|id| id.parse::<u8>().ok())
792+ .and_then(|id| presets?.presets.iter().find(|p| p.custom && p.id == id))
793+ .map(|preset| preset.name.clone());
794+ custom.unwrap_or_else(|| {
795+ let words = current.replace('-', " ");
796+ let mut letters = words.chars();
797+ letters
798+ .next()
799+ .map(|first| first.to_uppercase().chain(letters).collect())
800+ .unwrap_or_default()
801+ })
802+}
803+
526804 #[cfg(test)]
527805 mod tests {
528806 use super::*;
807+ use budctl_core::status::EqPreset;
529808 use iced::keyboard::key::Named;
530809 use std::fs;
531810 use std::path::Path;
811+ use std::sync::mpsc::{self, Receiver};
532812
813+ /// With the made-up buds.
533814 fn app() -> App {
534− App::new(None, Font::DEFAULT)
815+ App::new(None, Font::DEFAULT, None, true)
816+ }
817+
818+ /// Talking to a worker that the test plays: the receiver gets what the app sends it.
819+ fn live() -> (App, Receiver<Command>) {
820+ let mut app = App::new(None, Font::DEFAULT, None, false);
821+ let (worker, commands) = mpsc::channel();
822+ let _ = app.update(Message::Worker(Event::Ready(worker)));
823+ (app, commands)
824+ }
825+
826+ fn reading() -> Reading {
827+ Reading {
828+ status: demo::buds().status,
829+ eq: None,
830+ }
535831 }
536832
537833 /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`.
@@ -714,6 +1010,106 @@ mod tests {
7141010 assert!(!app.help);
7151011 }
7161012
1013+ #[test]
1014+ fn live_buds_are_read_first() {
1015+ let (mut app, commands) = live();
1016+ assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1017+ assert_eq!(app.link, Link::Connecting);
1018+ assert!(app.items().is_empty(), "nothing to move over before the first reading");
1019+ // A second refresh while the read is on its way sends nothing.
1020+ press(&mut app, "r");
1021+ assert!(commands.try_recv().is_err());
1022+
1023+ let _ = app.update(Message::Worker(Event::Found("realme Buds T310".to_owned())));
1024+ let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1025+ assert_eq!(app.link, Link::Live);
1026+ assert_eq!(app.buds.name, "realme Buds T310");
1027+ assert!(app.buds.connected);
1028+ assert_eq!(app.items().len(), 10);
1029+ }
1030+
1031+ #[test]
1032+ fn live_writes_wait_for_the_buds() {
1033+ let (mut app, commands) = live();
1034+ let _ = commands.try_recv();
1035+ let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1036+
1037+ let _ = app.update(Message::Switch("game", true));
1038+ assert!(matches!(commands.try_recv(), Ok(Command::Switch("game", true))));
1039+ assert!(!switch(&app, "game"), "the switch changes when the buds say so");
1040+ assert_eq!(app.pending(), [Item::Switch("game")]);
1041+ // While it is on its way, the same switch takes no other write.
1042+ press(&mut app, "j j j Enter");
1043+ assert!(commands.try_recv().is_err());
1044+
1045+ let game = Switch {
1046+ key: "game",
1047+ label: "Game mode",
1048+ on: true,
1049+ };
1050+ let _ = app.update(Message::Worker(Event::Switch(game)));
1051+ assert!(switch(&app, "game"));
1052+ assert!(app.pending().is_empty());
1053+
1054+ let _ = app.update(Message::Noise(NoiseMode::Transparency));
1055+ assert!(matches!(
1056+ commands.try_recv(),
1057+ Ok(Command::Noise(NoiseMode::Transparency))
1058+ ));
1059+ assert_eq!(mode(&app), "anc");
1060+ let now = NoiseControl {
1061+ mode: "transparency".to_owned(),
1062+ level: None,
1063+ };
1064+ let _ = app.update(Message::Worker(Event::Noise(now)));
1065+ assert_eq!(mode(&app), "transparency");
1066+ }
1067+
1068+ #[test]
1069+ fn failures_say_why_and_clear_the_write() {
1070+ let (mut app, commands) = live();
1071+ let _ = app.update(Message::Worker(Event::Failed {
1072+ kind: Kind::Busy,
1073+ message: "control channel busy".to_owned(),
1074+ }));
1075+ assert!(matches!(app.link, Link::Failed { kind: Kind::Busy, .. }));
1076+ assert!(app.problem().is_none(), "the Overview says it, not a banner");
1077+
1078+ let _ = commands.try_recv();
1079+ let _ = app.update(Message::Refresh);
1080+ assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1081+ assert_eq!(app.link, Link::Connecting);
1082+ let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1083+
1084+ let _ = app.update(Message::Switch("dual", false));
1085+ let _ = app.update(Message::Worker(Event::Failed {
1086+ kind: Kind::NotFound,
1087+ message: "the buds closed the control link".to_owned(),
1088+ }));
1089+ assert!(app.pending().is_empty());
1090+ assert!(switch(&app, "dual"));
1091+ assert!(!app.buds.connected);
1092+ assert!(app.problem().is_some());
1093+ }
1094+
1095+ #[test]
1096+ fn eq_names() {
1097+ let presets = EqPresets {
1098+ current: Some("custom-4".to_owned()),
1099+ presets: vec![EqPreset {
1100+ name: "Custom1".to_owned(),
1101+ id: 4,
1102+ custom: true,
1103+ bands: Vec::new(),
1104+ }],
1105+ warnings: Vec::new(),
1106+ };
1107+ assert_eq!(eq_name(Some("custom-4"), Some(&presets)), "Custom1");
1108+ assert_eq!(eq_name(Some("custom-4"), None), "Custom 4");
1109+ assert_eq!(eq_name(Some("pure-bass"), Some(&presets)), "Pure bass");
1110+ assert_eq!(eq_name(None, None), "Unknown");
1111+ }
1112+
7171113 #[test]
7181114 fn steps() {
7191115 let abc = ['a', 'b', 'c'];
@@ -727,10 +1123,20 @@ mod tests {
7271123 #[test]
7281124 fn arguments() {
7291125 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
730− assert!(matches!(args(&[]), Ok(Args::Run { theme: None })));
731− assert!(matches!(args(&["--theme", "x.json"]), Ok(Args::Run { theme: Some(_) })));
1126+ assert!(matches!(args(&[]), Ok(Args::Run(Options { demo: false, .. }))));
1127+ assert!(matches!(args(&["--demo"]), Ok(Args::Run(Options { demo: true, .. }))));
1128+ assert!(matches!(args(&["--debug"]), Ok(Args::Run(Options { debug: true, .. }))));
1129+ assert!(matches!(
1130+ args(&["--theme", "x.json"]),
1131+ Ok(Args::Run(Options { theme: Some(_), .. }))
1132+ ));
1133+ assert!(matches!(
1134+ args(&["--device", "88:0E:85:46:FE:5C"]),
1135+ Ok(Args::Run(Options { device: Some(_), .. }))
1136+ ));
7321137 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
7331138 assert!(args(&["--theme"]).is_err());
1139+ assert!(args(&["--device", "nope"]).is_err());
7341140 assert!(args(&["--bogus"]).is_err());
7351141 }
7361142
@@ -743,7 +1149,7 @@ mod tests {
7431149 let font = theme::system_font();
7441150 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
7451151 let on = |page: Page, keys: &str| {
746− let mut app = App::new(palette.clone(), font);
1152+ let mut app = App::new(palette.clone(), font, None, true);
7471153 app.page = page;
7481154 press(&mut app, keys);
7491155 app
@@ -755,6 +1161,24 @@ mod tests {
7551161 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
7561162 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
7571163 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
1164+ let mut connecting = App::new(palette.clone(), font, None, false);
1165+ connecting.link = Link::Connecting;
1166+ shots.push(("connecting".to_owned(), 800.0, connecting));
1167+ let mut busy = App::new(palette.clone(), font, None, false);
1168+ busy.answer(Event::Failed {
1169+ kind: Kind::Busy,
1170+ message: "control channel busy: is another app connected?".to_owned(),
1171+ });
1172+ shots.push(("busy".to_owned(), 800.0, busy));
1173+ let (mut saving, _commands) = live();
1174+ saving.answer(Event::Read(Box::new(reading())));
1175+ saving.answer(Event::Failed {
1176+ kind: Kind::Failed,
1177+ message: "the buds did not answer: no reply to command 0x0403".to_owned(),
1178+ });
1179+ let _ = saving.update(Message::Switch("spatial", false));
1180+ let _ = saving.update(Message::Noise(NoiseMode::Off));
1181+ shots.push(("saving".to_owned(), 1240.0, saving));
7581182 for (name, height, app) in shots {
7591183 let settings = iced::Settings {
7601184 default_font: font,
M crates/budctl-gui/src/overview.rs View
@@ -24,17 +24,27 @@ pub fn items(buds: &Buds) -> Vec<Item> {
2424 items
2525 }
2626
27−/// `focus` is the item under the keyboard cursor, if it shows.
28−pub fn view<'a>(look: Look, buds: &'a Buds, anc_level: AncLevel, focus: Option<Item>) -> Element<'a, Message> {
27+/// What the page needs besides the buds: the item under the keyboard cursor, if it shows, and the
28+/// items with a write on its way.
29+#[derive(Clone, Copy)]
30+pub struct Marks<'a> {
31+ pub focus: Option<Item>,
32+ pub pending: &'a [Item],
33+}
34+
35+/// Shown instead of an item's subtitle while a write to it is on its way.
36+const SAVING: &str = "Saving…";
37+
38+pub fn view<'a>(look: Look, buds: &'a Buds, anc_level: AncLevel, marks: Marks) -> Element<'a, Message> {
2939 column![ui::title(look, "Overview"), battery(look, buds)]
3040 .push(
3141 buds.status
3242 .noise_control
3343 .as_ref()
34− .map(|now| noise_control(look, &now.mode, anc_level, focus)),
44+ .map(|now| noise_control(look, &now.mode, anc_level, marks)),
3545 )
36− .push(switches(look, &buds.status.switches, focus))
37− .push(more(look, buds, focus))
46+ .push(switches(look, &buds.status.switches, marks))
47+ .push(more(look, buds, marks.focus))
3848 .spacing(18)
3949 .into()
4050 }
@@ -80,7 +90,8 @@ fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> {
8090 ui::card(look, vec![container(gauges).padding([22, 8]).into()])
8191 }
8292
83−fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel, focus: Option<Item>) -> Element<'a, Message> {
93+fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel, marks: Marks) -> Element<'a, Message> {
94+ let focus = marks.focus;
8495 let modes = [
8596 (Icon::NoiseOff, "Off", NoiseMode::Off),
8697 (Icon::Anc, "Noise cancelling", NoiseMode::Anc(anc_level)),
@@ -114,7 +125,12 @@ fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel, focus: Option<
114125 ]
115126 .align_y(Alignment::Center)
116127 .padding([0, 8]);
128+ let saving = marks
129+ .pending
130+ .contains(&Item::NoiseMode)
131+ .then(|| container(text(SAVING).size(13).color(look.p.icon)).center_x(Length::Fill));
117132 column![ui::segmented(look, segments, true, focus == Some(Item::NoiseMode))]
133+ .push(saving)
118134 .push((mode == NoiseMode::Anc(anc_level).name()).then_some(strength))
119135 .spacing(12)
120136 .into()
@@ -143,14 +159,14 @@ fn shown(switches: &[Switch]) -> Vec<&Switch> {
143159 shown
144160 }
145161
146−fn switches<'a>(look: Look, switches: &[Switch], focus: Option<Item>) -> Element<'a, Message> {
162+fn switches<'a>(look: Look, switches: &[Switch], marks: Marks) -> Element<'a, Message> {
147163 let known = |key: &str| SWITCHES.iter().find(|(k, _, _)| *k == key);
148164 let rows = shown(switches)
149165 .into_iter()
150166 .map(|s| {
151− let (icon, subtitle) = known(s.key).map_or((Icon::Settings, None), |&(_, icon, subtitle)| {
152− (icon, Some(subtitle.to_owned()))
153− });
167+ let (icon, about) = known(s.key).map_or((Icon::Settings, None), |&(_, icon, about)| (icon, Some(about)));
168+ let saving = marks.pending.contains(&Item::Switch(s.key));
169+ let subtitle = if saving { Some(SAVING) } else { about }.map(str::to_owned);
154170 ui::setting(
155171 look,
156172 icon,
@@ -158,7 +174,7 @@ fn switches<'a>(look: Look, switches: &[Switch], focus: Option<Item>) -> Element
158174 subtitle,
159175 ui::switch(look, s.on),
160176 Message::Switch(s.key, !s.on),
161− focus == Some(Item::Switch(s.key)),
177+ marks.focus == Some(Item::Switch(s.key)),
162178 )
163179 })
164180 .collect();
A crates/budctl-gui/src/worker.rs View
@@ -0,0 +1,183 @@
1+//! The connection to the buds, on a thread of its own. budctl-core blocks, and a write with its
2+//! read-back can take seconds, so the window never waits on it: it sends [`Command`]s and gets
3+//! [`Event`]s back, in order, one per command.
4+//!
5+//! The session opens when a command needs it and closes after [`IDLE`] without one, so the
6+//! control channel is free for other apps (CLAUDE.md, connection behavior). While it is open, the
7+//! buds' own requests are answered.
8+
9+use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
10+use std::thread;
11+use std::time::{Duration, Instant};
12+
13+use budctl_core::bt::BdAddr;
14+use budctl_core::devices::Session;
15+use budctl_core::status::{EqPresets, NoiseControl, NoiseMode, Status, Switch};
16+use budctl_core::{Error, Kind, Result};
17+use iced::futures::channel::mpsc as channel;
18+use iced::futures::{SinkExt, Stream, StreamExt};
19+
20+/// How long the session stays open after the last command.
21+const IDLE: Duration = Duration::from_secs(10);
22+/// How often an open session answers the buds while it waits for a command.
23+const SERVE_EVERY: Duration = Duration::from_millis(100);
24+
25+#[derive(Debug, Clone)]
26+pub enum Command {
27+ /// Read everything, connecting first if needed.
28+ Read,
29+ Noise(NoiseMode),
30+ /// A switch's key, and whether to turn it on.
31+ Switch(&'static str, bool),
32+}
33+
34+#[derive(Debug, Clone)]
35+pub enum Event {
36+ /// The worker takes commands here.
37+ Ready(Sender<Command>),
38+ /// The buds a new session is for, by the name their owner gave them. Answers no command.
39+ Found(String),
40+ /// The answer to [`Command::Read`]. Boxed: it is much larger than the other events.
41+ Read(Box<Reading>),
42+ /// The answer to [`Command::Noise`]: what the buds report after the write.
43+ Noise(NoiseControl),
44+ /// The answer to [`Command::Switch`]: the switch, as read back.
45+ Switch(Switch),
46+ /// The answer to a command that failed. The session is closed; the next command opens a
47+ /// new one.
48+ Failed { kind: Kind, message: String },
49+ /// The link dropped while the session waited. Answers no command.
50+ Lost(String),
51+}
52+
53+#[derive(Debug, Clone)]
54+pub struct Reading {
55+ pub status: Status,
56+ /// The EQ presets, when the buds can list them: they name the custom ones.
57+ pub eq: Option<EqPresets>,
58+}
59+
60+/// The worker, as a stream of events for an iced subscription. `device` picks the buds, as
61+/// `budctl --device` does.
62+pub fn run(device: &Option<BdAddr>) -> impl Stream<Item = Event> + use<> {
63+ let device = *device;
64+ iced::stream::channel(16, async move |mut output: channel::Sender<Event>| {
65+ let (commands, inbox) = mpsc::channel();
66+ let (events, mut outbox) = channel::unbounded();
67+ if output.send(Event::Ready(commands)).await.is_err() {
68+ return;
69+ }
70+ let worker = Worker {
71+ device,
72+ session: None,
73+ events,
74+ last_command: Instant::now(),
75+ };
76+ let started = thread::Builder::new()
77+ .name("budctl-buds".to_owned())
78+ .spawn(move || worker.run(&inbox));
79+ if let Err(e) = started {
80+ let message = format!("could not start the Bluetooth thread: {e}");
81+ let _ = output.send(Event::Lost(message)).await;
82+ return;
83+ }
84+ while let Some(event) = outbox.next().await {
85+ if output.send(event).await.is_err() {
86+ break;
87+ }
88+ }
89+ })
90+}
91+
92+struct Worker {
93+ device: Option<BdAddr>,
94+ session: Option<Box<dyn Session>>,
95+ events: channel::UnboundedSender<Event>,
96+ last_command: Instant,
97+}
98+
99+impl Worker {
100+ /// Runs until the window is gone.
101+ fn run(mut self, inbox: &Receiver<Command>) {
102+ loop {
103+ let next = if self.session.is_some() {
104+ inbox.recv_timeout(SERVE_EVERY)
105+ } else {
106+ inbox.recv().map_err(|_| RecvTimeoutError::Disconnected)
107+ };
108+ match next {
109+ Ok(command) => {
110+ self.last_command = Instant::now();
111+ self.handle(command);
112+ }
113+ Err(RecvTimeoutError::Timeout) => self.wait(),
114+ Err(RecvTimeoutError::Disconnected) => return,
115+ }
116+ }
117+ }
118+
119+ /// Between commands: answer the buds, and close the session once it has been idle long
120+ /// enough.
121+ fn wait(&mut self) {
122+ let Some(session) = self.session.as_mut() else {
123+ return;
124+ };
125+ if self.last_command.elapsed() >= IDLE {
126+ self.session = None;
127+ if budctl_core::debug::enabled() {
128+ eprintln!("# session closed after {} s without a command", IDLE.as_secs());
129+ }
130+ } else if let Err(error) = session.serve() {
131+ self.session = None;
132+ self.send(Event::Lost(error.to_string()));
133+ }
134+ }
135+
136+ fn handle(&mut self, command: Command) {
137+ let answer = match command {
138+ Command::Read => self.read().map(|reading| Event::Read(Box::new(reading))),
139+ Command::Noise(mode) => self
140+ .session()
141+ .and_then(|session| session.set_noise_control(mode))
142+ .map(Event::Noise),
143+ Command::Switch(key, on) => self
144+ .session()
145+ .and_then(|session| session.set_switch(key, on))
146+ .map(Event::Switch),
147+ };
148+ match answer {
149+ Ok(event) => self.send(event),
150+ Err(error) => {
151+ self.session = None;
152+ self.send(Event::Failed {
153+ kind: error.kind(),
154+ message: error.to_string(),
155+ });
156+ }
157+ }
158+ }
159+
160+ fn read(&mut self) -> Result<Reading> {
161+ let session = self.session()?;
162+ let status = session.status()?;
163+ let eq = session.eq_presets().ok();
164+ Ok(Reading { status, eq })
165+ }
166+
167+ /// The open session, opening one first if there is none.
168+ fn session(&mut self) -> Result<&mut Box<dyn Session>> {
169+ if self.session.is_none() {
170+ let (device, model) = budctl_core::select(budctl_core::scan()?, self.device)?;
171+ self.send(Event::Found(device.alias.clone()));
172+ self.session = Some(budctl_core::connect(&device, model)?);
173+ }
174+ self.session
175+ .as_mut()
176+ .ok_or_else(|| Error::NotConnected("the session closed".to_owned()))
177+ }
178+
179+ fn send(&self, event: Event) {
180+ // An error only means the window is gone; `run` ends on its next receive.
181+ let _ = self.events.unbounded_send(event);
182+ }
183+}