Krolyxon
Commit e8c78b9

Branches

Refs →

budctle8c78b9

budctl-gui: test the worker's idle close; leave the preset in use alone

G4 on the owner's T310 (now in the verification log): the four built-in
presets and a band drag on Custom1 were acked and read back, and the
session closed 10 s after the last command.

The same run showed that clicking the preset already in use wrote it
again. A click on it now does nothing, as h/l and Enter already did.

How the worker connects is now a parameter (Bluetooth in the window, a
fake in tests), so tests check without hardware that an idle session
closes after answering the buds meanwhile, that the next command opens
a new one, and that a dropped link is reported once.

No new dependencies.
Author
krolyxon
Date
2026-10-27 23:49 +0530
Parent
904ab9a
Commit
e8c78b9976a5f78bcd815f5e9961e3719f7dedee

+166 −13

3 files changed

  1. M crates/budctl-gui/src/main.rs +16 −1
  2. M crates/budctl-gui/src/worker.rs +149 −12
  3. M docs/protocol/realme_t310.md +1 −0
M crates/budctl-gui/src/main.rs View
@@ -493,11 +493,15 @@ impl App {
493493 }
494494 }
495495
496− /// Selects an EQ preset: a built-in one by name, a custom one by saving it unchanged.
496+ /// Selects an EQ preset: a built-in one by name, a custom one by saving it unchanged. The
497+ /// preset in use is left alone.
497498 fn select(&mut self, id: u8) {
498499 let Some(preset) = self.buds.preset(id) else {
499500 return;
500501 };
502+ if self.buds.status.eq.as_deref() == Some(buds::key(preset).as_str()) {
503+ return;
504+ }
501505 let command = if preset.custom {
502506 Command::CustomEq(buds::key(preset), Vec::new())
503507 } else {
@@ -1178,6 +1182,17 @@ mod tests {
11781182 assert_eq!(app.items().len(), 10);
11791183 }
11801184
1185+ #[test]
1186+ fn the_preset_in_use_is_not_written_again() {
1187+ let (mut app, commands) = live();
1188+ let _ = commands.try_recv();
1189+ let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1190+ let _ = app.update(Message::Preset(4));
1191+ assert!(commands.try_recv().is_err(), "Custom1 is in use already");
1192+ let _ = app.update(Message::Preset(3));
1193+ assert!(matches!(commands.try_recv(), Ok(Command::Eq(name)) if name == "pure-bass"));
1194+ }
1195+
11811196 #[test]
11821197 fn live_writes_wait_for_the_buds() {
11831198 let (mut app, commands) = live();
M crates/budctl-gui/src/worker.rs View
@@ -76,12 +76,7 @@ pub fn run(device: &Option<BdAddr>) -> impl Stream<Item = Event> + use<> {
7676 if output.send(Event::Ready(commands)).await.is_err() {
7777 return;
7878 }
79− let worker = Worker {
80− device,
81− session: None,
82− events,
83− last_command: Instant::now(),
84− };
79+ let worker = Worker::new(bluetooth(device), events, IDLE);
8580 let started = thread::Builder::new()
8681 .name("budctl-buds".to_owned())
8782 .spawn(move || worker.run(&inbox));
@@ -98,14 +93,38 @@ pub fn run(device: &Option<BdAddr>) -> impl Stream<Item = Event> + use<> {
9893 })
9994 }
10095
96+/// Finds the buds and opens a session, calling `found` with their name once it is known.
97+type Connect = Box<dyn FnMut(&dyn Fn(String)) -> Result<Box<dyn Session>> + Send>;
98+
99+/// Over Bluetooth, through BlueZ: what the window uses. Tests use a fake.
100+fn bluetooth(device: Option<BdAddr>) -> Connect {
101+ Box::new(move |found| {
102+ let (device, model) = budctl_core::select(budctl_core::scan()?, device)?;
103+ found(device.alias.clone());
104+ budctl_core::connect(&device, model)
105+ })
106+}
107+
101108 struct Worker {
102− device: Option<BdAddr>,
109+ connect: Connect,
103110 session: Option<Box<dyn Session>>,
104111 events: channel::UnboundedSender<Event>,
105112 last_command: Instant,
113+ /// How long the session stays open after the last command.
114+ idle: Duration,
106115 }
107116
108117 impl Worker {
118+ fn new(connect: Connect, events: channel::UnboundedSender<Event>, idle: Duration) -> Self {
119+ Worker {
120+ connect,
121+ session: None,
122+ events,
123+ last_command: Instant::now(),
124+ idle,
125+ }
126+ }
127+
109128 /// Runs until the window is gone.
110129 fn run(mut self, inbox: &Receiver<Command>) {
111130 loop {
@@ -131,10 +150,13 @@ impl Worker {
131150 let Some(session) = self.session.as_mut() else {
132151 return;
133152 };
134− if self.last_command.elapsed() >= IDLE {
153+ if self.last_command.elapsed() >= self.idle {
135154 self.session = None;
136155 if budctl_core::debug::enabled() {
137− eprintln!("# session closed after {} s without a command", IDLE.as_secs());
156+ eprintln!(
157+ "# session closed after {} s without a command",
158+ self.idle.as_secs()
159+ );
138160 }
139161 } else if let Err(error) = session.serve() {
140162 self.session = None;
@@ -184,9 +206,11 @@ impl Worker {
184206 /// The open session, opening one first if there is none.
185207 fn session(&mut self) -> Result<&mut Box<dyn Session>> {
186208 if self.session.is_none() {
187− let (device, model) = budctl_core::select(budctl_core::scan()?, self.device)?;
188− self.send(Event::Found(device.alias.clone()));
189− self.session = Some(budctl_core::connect(&device, model)?);
209+ let events = self.events.clone();
210+ let found = move |name| {
211+ let _ = events.unbounded_send(Event::Found(name));
212+ };
213+ self.session = Some((self.connect)(&found)?);
190214 }
191215 self.session
192216 .as_mut()
@@ -198,3 +222,116 @@ impl Worker {
198222 let _ = self.events.unbounded_send(event);
199223 }
200224 }
225+
226+#[cfg(test)]
227+mod tests {
228+ use super::*;
229+ use iced::futures::executor::block_on;
230+ use std::sync::Arc;
231+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
232+
233+ /// What the fake sessions report back to the test.
234+ #[derive(Clone, Default)]
235+ struct Probe {
236+ opened: Arc<AtomicUsize>,
237+ served: Arc<AtomicUsize>,
238+ closed: Arc<AtomicBool>,
239+ /// Make `serve` fail, as when the buds drop the link.
240+ drop_link: Arc<AtomicBool>,
241+ }
242+
243+ struct Fake(Probe);
244+
245+ impl Session for Fake {
246+ fn status(&mut self) -> Result<Status> {
247+ Ok(Status::default())
248+ }
249+
250+ fn serve(&mut self) -> Result<()> {
251+ self.0.served.fetch_add(1, Ordering::SeqCst);
252+ if self.0.drop_link.load(Ordering::SeqCst) {
253+ return Err(Error::NotConnected("the buds closed the control link".to_owned()));
254+ }
255+ Ok(())
256+ }
257+ }
258+
259+ impl Drop for Fake {
260+ fn drop(&mut self) {
261+ self.0.closed.store(true, Ordering::SeqCst);
262+ }
263+ }
264+
265+ /// A worker on its own thread with fake buds, closing idle sessions after 200 ms.
266+ fn start(
267+ probe: &Probe,
268+ ) -> (
269+ Sender<Command>,
270+ channel::UnboundedReceiver<Event>,
271+ thread::JoinHandle<()>,
272+ ) {
273+ let fake = probe.clone();
274+ let connect: Connect = Box::new(move |found| {
275+ found("Fake buds".to_owned());
276+ fake.opened.fetch_add(1, Ordering::SeqCst);
277+ fake.closed.store(false, Ordering::SeqCst);
278+ Ok(Box::new(Fake(fake.clone())))
279+ });
280+ let (events, outbox) = channel::unbounded();
281+ let (commands, inbox) = mpsc::channel();
282+ let worker = Worker::new(connect, events, Duration::from_millis(200));
283+ let thread = thread::spawn(move || worker.run(&inbox));
284+ (commands, outbox, thread)
285+ }
286+
287+ /// Waits, at most 5 s, for `done`. A receive that times out stands in for a sleep.
288+ fn wait_for(done: impl Fn() -> bool) {
289+ let (_keep, never) = mpsc::channel::<()>();
290+ let deadline = Instant::now() + Duration::from_secs(5);
291+ while !done() {
292+ assert!(Instant::now() < deadline, "gave up waiting");
293+ let _ = never.recv_timeout(Duration::from_millis(10));
294+ }
295+ }
296+
297+ #[test]
298+ fn an_idle_session_closes_and_the_next_command_opens_another() {
299+ let probe = Probe::default();
300+ let (commands, mut events, thread) = start(&probe);
301+
302+ commands.send(Command::Read).unwrap();
303+ assert!(matches!(block_on(events.next()), Some(Event::Found(name)) if name == "Fake buds"));
304+ assert!(matches!(block_on(events.next()), Some(Event::Read(_))));
305+ assert_eq!(probe.opened.load(Ordering::SeqCst), 1);
306+
307+ // While it waits, it answers the buds; then it closes the session.
308+ wait_for(|| probe.closed.load(Ordering::SeqCst));
309+ assert!(probe.served.load(Ordering::SeqCst) > 0);
310+
311+ commands.send(Command::Read).unwrap();
312+ assert!(matches!(block_on(events.next()), Some(Event::Found(_))));
313+ assert!(matches!(block_on(events.next()), Some(Event::Read(_))));
314+ assert_eq!(probe.opened.load(Ordering::SeqCst), 2);
315+
316+ // The window goes away: the worker ends.
317+ drop(commands);
318+ thread.join().unwrap();
319+ }
320+
321+ #[test]
322+ fn a_dropped_link_is_reported_once() {
323+ let probe = Probe::default();
324+ let (commands, mut events, thread) = start(&probe);
325+ commands.send(Command::Read).unwrap();
326+ let _ = block_on(events.next());
327+ let _ = block_on(events.next());
328+
329+ probe.drop_link.store(true, Ordering::SeqCst);
330+ assert!(matches!(block_on(events.next()), Some(Event::Lost(_))));
331+ assert!(probe.closed.load(Ordering::SeqCst));
332+
333+ drop(commands);
334+ thread.join().unwrap();
335+ assert!(block_on(events.next()).is_none(), "nothing more after the link dropped");
336+ }
337+}
M docs/protocol/realme_t310.md View
@@ -559,6 +559,7 @@ The owner will not install realme Link (2026-10-27), so there is no app capture.
559559
560560 | Date | Checkpoint | Result | Notes |
561561 |---|---|---|---|
562+| 2026-10-27 | G4 `budctl-gui --debug`, Equalizer page: the four built-in presets, a band drag on Custom1, then idle | pass (protocol) | `0x0406 00/01/02/03` were acked and read back on the first try. One drag (1 kHz from −6 to +3) sent one `0x0418 02` write: acked `00 04`, and the read-back list shows the new gain with `selected` `01`. The session closed 10 s after the last command, three times; a read when the window got focus after 30 s opened a new session each time. `h`/`l` on a band is not covered yet |
562563 | 2026-10-27 | G3 `budctl-gui --debug`: the first reading, then noise control transparency → noise cancelling, game mode on → off | pass (protocol) | One session for the reading and all four writes, with no new connection between them. The acks and read-backs match W1 and W3; the game-mode read-back, 1 s after the ack, showed the change. The window named the custom preset from the `0x0122` list. No request from the buds came in while the session waited. The log ends about 1 s after the last write, so the 10 s idle close is not in it yet |
563564 | 2026-10-27 | `status` with a control app connected on the phone, dual connection on | pass | Every read answered, 40-190 ms after its request (HW1: 50-90 ms). Changes budctl made showed up in the phone app. The T310 serves both hosts at once. See [question 4](#open) |
564565 | 2026-10-27 | W11b `eq custom-4`, `eq custom-4 50=+3 16000=-2`, then `50=0 16000=0` | pass | The write matched the derived frame, was acked with `00 04` and read back on the first try. The preset is flat again. Then `eq pure-raw`, `eq custom-4` and `status`: `status` showed `custom-4`, so saving also selects. See [§10.9](#custom-eq-save) |