Krolyxon
Commit 4c49f6c

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

1119 lines · 40.5 KB · Rust

1//! realme Buds T310. Spec: docs/protocol/realme_t310.md
2//!
3//! `frame.rs` and `link.rs` hold the protocol family's framing and request/reply logic;
4//! `codec.rs` holds everything specific to the T310.
5
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use crate::bluez::PairedDevice;
9use crate::bt::Uuid;
10use crate::devices::{Device, Session};
11use crate::error::{Error, Kind, Result};
12use crate::status::{
13 BudFit, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls,
14};
15use crate::transport::Transport;
16
17mod codec;
18mod frame;
19mod link;
20
21use codec::Commands;
22use link::Link;
23
24/// When to read a setting back after the buds ack a write. Spec: realme_t310.md#write-read-back
25#[derive(Clone, Copy)]
26struct ReadBack {
27 /// How long after the ack the first read goes out.
28 first: Duration,
29 /// How long after the ack budctl gives up.
30 window: Duration,
31}
32
33/// Spec: realme_t310.md#write-read-back
34const READ_BACK: ReadBack = ReadBack {
35 first: Duration::ZERO,
36 window: Duration::from_secs(2),
37};
38/// Game mode lands about 1 s after the ack. Spec: realme_t310.md#switch-write
39const GAME_READ_BACK: ReadBack = ReadBack {
40 first: Duration::from_secs(1),
41 window: Duration::from_secs(3),
42};
43/// The gesture table is read back about 600 ms after the ack. Spec: realme_t310.md#gesture-write
44const TOUCH_READ_BACK: ReadBack = ReadBack {
45 first: Duration::from_millis(600),
46 window: Duration::from_secs(2),
47};
48/// How long budctl waits for the fit-test result after the ack: Buds 4 take about 8.6 s.
49/// Spec: realme_t310.md#fit-test
50const FIT_TEST_WAIT: Duration = Duration::from_secs(15);
51/// Spec: realme_t310.md#write-read-back
52const READ_BACK_PAUSE: Duration = Duration::from_millis(300);
53
54/// The switches budctl may write, each added after the previous one passed its hardware
55/// checkpoint (CLAUDE.md rule 4). Spec: realme_t310.md#switch-write
56const WRITABLE_SWITCHES: [(&str, ReadBack); 5] = [
57 ("game", GAME_READ_BACK),
58 ("bass-boost", READ_BACK),
59 ("spatial", READ_BACK),
60 ("wind", READ_BACK),
61 ("dual", READ_BACK),
62];
63
64fn on_off(on: bool) -> &'static str {
65 if on { "on" } else { "off" }
66}
67
68/// Spec: realme_t310.md#identity
69const BLUETOOTH_NAME: &str = "realme Buds T310";
70
71/// Control services, in the order to try. Spec: realme_t310.md#transport
72const SERVICES: [Uuid; 2] = [
73 Uuid::from_u128(0x0000_079A_D102_11E1_9B23_0002_5B00_A5A5),
74 Uuid::from_u128(0x0000_1107_D102_11E1_9B23_0002_5B00_A5A5),
75];
76
77/// Quiet time after connecting, before the first request. Spec: realme_t310.md#init
78const SETTLE: Duration = Duration::from_millis(300);
79
80pub struct RealmeT310;
81
82impl Device for RealmeT310 {
83 fn name(&self) -> &'static str {
84 BLUETOOTH_NAME
85 }
86
87 fn matches(&self, device: &PairedDevice) -> bool {
88 device.name == BLUETOOTH_NAME
89 }
90
91 fn rfcomm_uuids(&self) -> &'static [Uuid] {
92 &SERVICES
93 }
94
95 fn open(&self, transport: Box<dyn Transport>) -> Result<Box<dyn Session>> {
96 Ok(Box::new(T310Session::open(Link::new(transport, unix_time))?))
97 }
98}
99
100fn unix_time() -> u32 {
101 let seconds = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
102 u32::try_from(seconds).unwrap_or(u32::MAX)
103}
104
105struct T310Session {
106 link: Link,
107 commands: Commands,
108 /// Whether the session subscribed to fit-test results. Spec: realme_t310.md#subscribe
109 fit_results: bool,
110}
111
112/// Collects what a series of best-effort reads produced.
113#[derive(Default)]
114struct Reads {
115 warnings: Vec<String>,
116 answered: usize,
117}
118
119impl T310Session {
120 /// The session start. Spec: realme_t310.md#init
121 fn open(mut link: Link) -> Result<Self> {
122 link.pump_until(Instant::now() + SETTLE)?;
123
124 let bitmap = link.request(codec::HANDSHAKE, &[]).map_err(|error| match error {
125 // Provisional until checkpoint HW1: a link that connects but stays silent is most
126 // likely held by another app. Spec: realme_t310.md#open
127 Error::Timeout(_) => Error::ChannelBusy("connected, but the buds did not answer the handshake".to_owned()),
128 other => other,
129 })?;
130 let commands = Commands::parse(&bitmap)?;
131
132 let product = codec::decode_product_id(&link.request(codec::PRODUCT_ID, &[])?)?;
133 if product != codec::T310_PRODUCT_ID {
134 return Err(Error::WrongModel {
135 expected: BLUETOOTH_NAME,
136 found: format!("{product:06X}"),
137 });
138 }
139
140 let offered = codec::decode_events_offered(&link.request(codec::EVENTS_OFFERED, &[])?)?;
141 let subscription = codec::subscribe_payload(&offered, &commands);
142 let mut fit_results = false;
143 if commands.allows(codec::SUBSCRIBE) && subscription.len() > 1 {
144 // Some buds never acknowledge this, so budctl does not wait. Spec: realme_t310.md#subscribe
145 link.send(codec::SUBSCRIBE, &subscription)?;
146 fit_results = subscription.iter().skip(1).any(|&code| code == codec::FIT_EVENT);
147 }
148 Ok(Self {
149 link,
150 commands,
151 fit_results,
152 })
153 }
154
155 /// Read and decode one value, if the buds offer the command. A read the buds answer badly
156 /// (timeout, refusal, garbage) becomes a warning so the rest still shows; a lost link stops
157 /// everything.
158 fn read<T>(
159 &mut self,
160 reads: &mut Reads,
161 what: &str,
162 cmd: u16,
163 payload: &[u8],
164 decode: fn(&[u8]) -> Result<T>,
165 ) -> Result<Option<T>> {
166 if !self.commands.allows(cmd) {
167 return Ok(None);
168 }
169 match self.link.request(cmd, payload).and_then(|reply| decode(&reply)) {
170 Ok(value) => {
171 reads.answered += 1;
172 Ok(Some(value))
173 }
174 Err(error) if error.kind() == Kind::Failed => {
175 reads.warnings.push(format!("{what}: {error}"));
176 Ok(None)
177 }
178 Err(error) => Err(error),
179 }
180 }
181}
182
183impl T310Session {
184 /// After an acked write, read the setting back with `cmd query` until `check` reports the
185 /// new value, following `timing`. `check` returns whether the value is the one written,
186 /// and the value; `not_applied` describes a value that never changed.
187 /// Spec: realme_t310.md#write-read-back
188 fn read_back<T>(
189 &mut self,
190 timing: ReadBack,
191 cmd: u16,
192 query: &[u8],
193 check: impl Fn(&[u8]) -> Result<(bool, T)>,
194 not_applied: impl Fn(T) -> String,
195 ) -> Result<T> {
196 let acked = Instant::now();
197 self.link.pump_until(acked + timing.first)?;
198 loop {
199 let (applied, value) = check(&self.link.request(cmd, query)?)?;
200 if applied {
201 return Ok(value);
202 }
203 if Instant::now() >= acked + timing.window {
204 return Err(Error::NotApplied(not_applied(value)));
205 }
206 self.link.pump_until(Instant::now() + READ_BACK_PAUSE)?;
207 }
208 }
209}
210
211impl Session for T310Session {
212 fn status(&mut self) -> Result<Status> {
213 let mut reads = Reads::default();
214 let switches = self.read(
215 &mut reads,
216 "switches",
217 codec::SWITCHES,
218 &codec::SWITCH_QUERY,
219 codec::decode_switches,
220 )?;
221 let noise_control = self.read(
222 &mut reads,
223 "noise control",
224 codec::NOISE_CONTROL,
225 &codec::NOISE_MODE_QUERY,
226 codec::decode_noise_mode,
227 )?;
228 let battery = self.read(&mut reads, "battery", codec::BATTERY, &[], codec::decode_battery)?;
229 let eq = self.read(&mut reads, "EQ", codec::EQ_PRESET, &[], codec::decode_eq)?;
230 // No bass-level read: the T310 does not answer an empty 0x0124 (HW1). It returns with W6.
231 // Spec: realme_t310.md#bass-read
232 let codec = self.read(&mut reads, "codec", codec::CODEC, &[], codec::decode_codec)?;
233 let firmware = self.read(&mut reads, "firmware", codec::FIRMWARE, &[], codec::decode_firmware)?;
234
235 if reads.answered == 0 && !reads.warnings.is_empty() {
236 return Err(Error::Timeout(reads.warnings.join("; ")));
237 }
238 Ok(Status {
239 battery: battery.unwrap_or_default(),
240 noise_control,
241 eq,
242 bass_level: None,
243 switches: switches.unwrap_or_default(),
244 codec,
245 firmware,
246 warnings: reads.warnings,
247 })
248 }
249
250 /// Spec: realme_t310.md#anc-write, #write-read-back
251 fn set_noise_control(&mut self, mode: NoiseMode) -> Result<NoiseControl> {
252 if !self.commands.allows(codec::NOISE_CONTROL_WRITE) || !self.commands.allows(codec::NOISE_CONTROL) {
253 return Err(Error::Unsupported(
254 "these buds do not offer noise-control settings".to_owned(),
255 ));
256 }
257 let reply = self
258 .link
259 .request(codec::NOISE_CONTROL_WRITE, &codec::noise_write_payload(mode))?;
260 codec::check_write_reply(&reply, "the noise-control change")?;
261
262 let wanted = codec::noise_write_mask(mode);
263 self.read_back(
264 READ_BACK,
265 codec::NOISE_CONTROL,
266 &codec::NOISE_MODE_QUERY,
267 |reply| {
268 Ok((
269 codec::decode_noise_mask(reply)? == wanted,
270 codec::decode_noise_mode(reply)?,
271 ))
272 },
273 |now| {
274 let wanted = match mode {
275 NoiseMode::Anc(level) => format!("anc ({})", level.name()),
276 _ => mode.name().to_owned(),
277 };
278 let reported = match &now.level {
279 Some(level) => format!("{} ({level})", now.mode),
280 None => now.mode,
281 };
282 format!("the buds accepted noise control {wanted}, but still report {reported}")
283 },
284 )
285 }
286
287 /// Built-in presets only; custom EQs come with W11. Spec: realme_t310.md#eq-write
288 fn set_eq(&mut self, preset: &str) -> Result<String> {
289 let Some(id) = codec::eq_id(preset) else {
290 return Err(Error::Usage(format!(
291 "no EQ preset called {preset}; these buds have: {}",
292 codec::eq_names().join(", ")
293 )));
294 };
295 if !self.commands.allows(codec::EQ_SELECT) || !self.commands.allows(codec::EQ_PRESET) {
296 return Err(Error::Unsupported("these buds do not offer EQ presets".to_owned()));
297 }
298 let reply = self.link.request(codec::EQ_SELECT, &[id])?;
299 codec::check_write_reply(&reply, "the EQ change")?;
300
301 self.read_back(
302 READ_BACK,
303 codec::EQ_PRESET,
304 &[],
305 |reply| {
306 let now = codec::decode_eq(reply)?;
307 Ok((now == preset, now))
308 },
309 |now| format!("the buds accepted EQ {preset}, but still report {now}"),
310 )
311 }
312
313 /// Spec: realme_t310.md#find
314 fn find(&mut self, ring: bool) -> Result<()> {
315 if !self.commands.allows(codec::FIND) {
316 return Err(Error::Unsupported("these buds cannot ring through budctl".to_owned()));
317 }
318 let reply = self.link.request(codec::FIND, &codec::find_payload(ring))?;
319 codec::check_write_reply(&reply, if ring { "ringing" } else { "stopping the ringing" })
320 }
321
322 /// Spec: realme_t310.md#switch-write, #write-read-back
323 fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> {
324 let Some(id) = codec::switch_id(key) else {
325 return Err(Error::Unsupported(format!("these buds have no switch called {key}")));
326 };
327 let Some(&(_, timing)) = WRITABLE_SWITCHES.iter().find(|(k, _)| *k == key) else {
328 return Err(Error::Unsupported(format!("changing {key} is not supported yet")));
329 };
330 if !self.commands.allows(codec::SWITCH_WRITE) || !self.commands.allows(codec::SWITCHES) {
331 return Err(Error::Unsupported("these buds do not offer switch settings".to_owned()));
332 }
333 let reply = self
334 .link
335 .request(codec::SWITCH_WRITE, &codec::switch_write_payload(id, on))?;
336 codec::check_write_reply(&reply, "the switch change")?;
337
338 self.read_back(
339 timing,
340 codec::SWITCHES,
341 &codec::SWITCH_QUERY,
342 |reply| {
343 let switch = codec::decode_switches(reply)?
344 .into_iter()
345 .find(|s| s.key == key)
346 .ok_or_else(|| Error::Protocol(format!("the switch reply has no {key}")))?;
347 Ok((switch.on == on, switch))
348 },
349 |now| {
350 format!(
351 "the buds accepted {} {}, but still report {}",
352 now.label,
353 on_off(on),
354 on_off(now.on)
355 )
356 },
357 )
358 }
359
360 fn touch_controls(&mut self) -> Result<TouchControls> {
361 if !self.commands.allows(codec::GESTURES) {
362 return Err(Error::Unsupported(
363 "these buds do not offer touch-control settings".to_owned(),
364 ));
365 }
366 let gestures = codec::decode_gestures(&self.link.request(codec::GESTURES, &[])?)?;
367 let mut reads = Reads::default();
368 let hold_cycle = self.read(
369 &mut reads,
370 "hold cycle",
371 codec::NOISE_CONTROL,
372 &codec::HOLD_CYCLE_QUERY,
373 codec::decode_hold_cycle,
374 )?;
375 Ok(TouchControls {
376 gestures,
377 hold_cycle,
378 warnings: reads.warnings,
379 })
380 }
381
382 /// Spec: realme_t310.md#fit-test
383 fn fit_test(&mut self) -> Result<Vec<BudFit>> {
384 if !self.commands.allows(codec::FIT_TEST) || !self.fit_results {
385 return Err(Error::Unsupported("these buds do not offer a fit test".to_owned()));
386 }
387 let reply = self.link.request(codec::FIT_TEST, &codec::fit_test_payload(true))?;
388 codec::check_write_reply(&reply, "starting the fit test")?;
389
390 let deadline = Instant::now() + FIT_TEST_WAIT;
391 let result = self.link.wait_for(deadline, |frame| {
392 codec::is_fit_push(frame.cmd, &frame.payload).then(|| codec::decode_fit_push(&frame.payload))
393 })?;
394 match result {
395 Some(buds) => buds,
396 None => {
397 // Do not leave the test running. Spec: realme_t310.md#fit-test
398 let stopped = match self.stop_fit_test() {
399 Ok(()) => "budctl stopped the test".to_owned(),
400 Err(error) => format!("stopping it failed too: {error}"),
401 };
402 Err(Error::Timeout(format!(
403 "no fit-test result within {} s; {stopped}",
404 FIT_TEST_WAIT.as_secs()
405 )))
406 }
407 }
408 }
409
410 /// Spec: realme_t310.md#fit-test
411 fn stop_fit_test(&mut self) -> Result<()> {
412 if !self.commands.allows(codec::FIT_TEST) {
413 return Err(Error::Unsupported("these buds do not offer a fit test".to_owned()));
414 }
415 let reply = self.link.request(codec::FIT_TEST, &codec::fit_test_payload(false))?;
416 codec::check_write_reply(&reply, "stopping the fit test")
417 }
418
419 /// Spec: realme_t310.md#hold-cycle-write, #write-read-back
420 fn set_hold_cycle(&mut self, modes: &[&str]) -> Result<Vec<String>> {
421 let mask = codec::hold_cycle_mask(modes)?;
422 if !self.commands.allows(codec::NOISE_CONTROL_WRITE) || !self.commands.allows(codec::NOISE_CONTROL) {
423 return Err(Error::Unsupported(
424 "these buds do not offer noise-control settings".to_owned(),
425 ));
426 }
427 let reply = self
428 .link
429 .request(codec::NOISE_CONTROL_WRITE, &codec::hold_cycle_write_payload(mask))?;
430 codec::check_write_reply(&reply, "the hold-cycle change")?;
431
432 self.read_back(
433 READ_BACK,
434 codec::NOISE_CONTROL,
435 &codec::HOLD_CYCLE_QUERY,
436 |reply| {
437 Ok((
438 codec::decode_hold_cycle_mask(reply)? == mask,
439 codec::decode_hold_cycle(reply)?,
440 ))
441 },
442 |now| {
443 format!(
444 "the buds accepted the hold cycle {}, but still report {}",
445 modes.join(" → "),
446 now.join(" → ")
447 )
448 },
449 )
450 }
451
452 /// Spec: realme_t310.md#gesture-write, #gesture-choices
453 fn set_touch(&mut self, side: &str, gesture: &str, action: &str, dry_run: bool) -> Result<TouchChange> {
454 let target = codec::touch_target(side, gesture, action)?;
455 if !self.commands.allows(codec::GESTURE_WRITE) || !self.commands.allows(codec::GESTURES) {
456 return Err(Error::Unsupported(
457 "these buds do not offer touch-control settings".to_owned(),
458 ));
459 }
460 // The write carries the whole table, so it starts from the table read just now.
461 let before = codec::decode_gesture_entries(&self.link.request(codec::GESTURES, &[])?)?;
462 let after = codec::retarget_gestures(&before, &target)?;
463 let changes: Vec<GestureChange> = before
464 .iter()
465 .zip(&after)
466 .filter(|(old, new)| old != new)
467 .map(|(old, new)| {
468 let (old, new) = (codec::name_gesture(old), codec::name_gesture(new));
469 GestureChange {
470 side: new.side,
471 gesture: new.gesture,
472 from: old.action,
473 to: new.action,
474 }
475 })
476 .collect();
477 let planned: Vec<_> = after.iter().map(codec::name_gesture).collect();
478 if changes.is_empty() {
479 return Ok(TouchChange {
480 changes,
481 write: None,
482 sent: false,
483 gestures: planned,
484 });
485 }
486 let write = RawWrite {
487 command: codec::GESTURE_WRITE,
488 payload: codec::gesture_write_payload(&after)?,
489 };
490 if dry_run {
491 return Ok(TouchChange {
492 changes,
493 write: Some(write),
494 sent: false,
495 gestures: planned,
496 });
497 }
498
499 let reply = self.link.request(write.command, &write.payload)?;
500 codec::check_write_reply(&reply, "the touch-control change")?;
501 let gestures = self.read_back(
502 TOUCH_READ_BACK,
503 codec::GESTURES,
504 &[],
505 |reply| {
506 let now = codec::decode_gesture_entries(reply)?;
507 // Applied when retargeting the table read back would change nothing.
508 let applied = codec::retarget_gestures(&now, &target).is_ok_and(|again| again == now);
509 Ok((applied, now))
510 },
511 |now| {
512 let still: Vec<String> = changes
513 .iter()
514 .filter_map(|change| {
515 now.iter()
516 .map(codec::name_gesture)
517 .find(|g| g.group.is_none() && g.side == change.side && g.gesture == change.gesture)
518 })
519 .filter(|g| g.action != action)
520 .map(|g| format!("{} {} {}", g.side, g.gesture, g.action))
521 .collect();
522 if still.is_empty() {
523 format!("the buds accepted {side} {gesture} {action}, but the table read back does not show it")
524 } else {
525 format!(
526 "the buds accepted {side} {gesture} {action}, but still report {}",
527 still.join(", ")
528 )
529 }
530 },
531 )?;
532 Ok(TouchChange {
533 changes,
534 write: Some(write),
535 sent: true,
536 gestures: gestures.iter().map(codec::name_gesture).collect(),
537 })
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use crate::status::{AncLevel, Charge};
545 use crate::transport::mock::{Script, Step, hex};
546
547 fn tx(cmd: u16, seq: u8, payload: &str) -> Step {
548 Step::Tx(frame::encode(cmd, seq, &hex(payload)).unwrap())
549 }
550
551 fn rx(cmd: u16, seq: u8, payload: &str) -> Step {
552 Step::Rx(frame::encode(cmd, seq, &hex(payload)).unwrap())
553 }
554
555 fn fixed_clock() -> u32 {
556 1_750_000_000
557 }
558
559 fn open(script: &Script) -> Result<T310Session> {
560 T310Session::open(Link::new(script.transport(), fixed_clock))
561 }
562
563 /// The handshake of the owner's T310 (HW1); seq 01-04 are used up.
564 fn handshake() -> Vec<Step> {
565 vec![
566 tx(0x0100, 0x01, ""),
567 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
568 tx(0x0103, 0x02, ""),
569 rx(0x8103, 0x02, "00 12 3C 06"),
570 tx(0x0200, 0x03, ""),
571 rx(0x8200, 0x03, "00 09 01 02 03 04 05 06 0D F1 F4"),
572 tx(0x0205, 0x04, "04 01 02 03 04"),
573 rx(0x8205, 0x04, "00 04 01 00 02 00 03 00 04 00"),
574 ]
575 }
576
577 #[test]
578 fn full_status() {
579 let mut steps = handshake();
580 let mut firmware = String::from("00 03");
581 for byte in b"1,2,120,2,2,120,3,2,105" {
582 firmware.push_str(&format!(" {byte:02X}"));
583 }
584 steps.extend([
585 tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"),
586 rx(0x810D, 0x05, "00 06 05 00 06 00 11 01 1A 00 1B 01 1D 01"),
587 tx(0x010C, 0x06, "01 01"),
588 rx(0x810C, 0x06, "00 01 01 08"),
589 tx(0x0106, 0x07, ""),
590 rx(0x8106, 0x07, "00 03 01 50 02 E4 03 3C"),
591 tx(0x010F, 0x08, ""),
592 rx(0x810F, 0x08, "00 01"),
593 tx(0x0114, 0x09, ""),
594 rx(0x8114, 0x09, "00 02"),
595 tx(0x0105, 0x0A, ""),
596 rx(0x8105, 0x0A, &firmware),
597 ]);
598 let script = Script::new(steps);
599 let status = open(&script).unwrap().status().unwrap();
600 script.assert_done();
601
602 assert_eq!(
603 status.battery.left,
604 Some(Charge {
605 level: 80,
606 charging: false
607 })
608 );
609 assert_eq!(
610 status.battery.right,
611 Some(Charge {
612 level: 100,
613 charging: true
614 })
615 );
616 assert_eq!(
617 status.battery.case,
618 Some(Charge {
619 level: 60,
620 charging: false
621 })
622 );
623 let noise = status.noise_control.unwrap();
624 assert_eq!((noise.mode.as_str(), noise.level.as_deref()), ("anc", Some("deep")));
625 assert_eq!(status.eq.as_deref(), Some("deep-bass"));
626 assert_eq!(status.bass_level, None);
627 assert_eq!(status.codec.as_deref(), Some("AAC"));
628 assert_eq!(status.firmware.as_deref(), Some("120.120.105"));
629 let game = status.switches.iter().find(|s| s.key == "game").unwrap();
630 assert!(!game.on);
631 assert_eq!(status.switches.len(), 5);
632 assert!(status.warnings.is_empty());
633 }
634
635 #[test]
636 fn answers_the_time_request_during_the_settle() {
637 let mut steps = vec![
638 rx(0x0500, 0x01, ""),
639 Step::Tx(hex("AA 0C 00 00 00 85 01 05 00 00 80 E1 4E 68")),
640 ];
641 steps.extend(handshake());
642 // No 0x8205 ack this time: it may never arrive, and `open` does not wait for it.
643 // Spec: realme_t310.md#subscribe
644 steps.pop();
645 let script = Script::new(steps);
646 open(&script).unwrap();
647 script.assert_done();
648 }
649
650 #[test]
651 fn a_silent_handshake_reports_a_busy_channel() {
652 let script = Script::new(vec![tx(0x0100, 0x01, "")]);
653 assert!(matches!(open(&script), Err(Error::ChannelBusy(_))));
654 }
655
656 #[test]
657 fn other_models_are_refused() {
658 let script = Script::new(vec![
659 tx(0x0100, 0x01, ""),
660 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
661 tx(0x0103, 0x02, ""),
662 rx(0x8103, 0x02, "00 14 54 06"),
663 ]);
664 match open(&script) {
665 Err(Error::WrongModel { found, .. }) => assert_eq!(found, "065414"),
666 other => panic!("expected WrongModel, got {:?}", other.err()),
667 }
668 }
669
670 #[test]
671 fn commands_missing_from_the_bitmap_are_never_sent() {
672 // A bitmap with no bits: only always-allowed commands go out, and no subscription.
673 let script = Script::new(vec![
674 tx(0x0100, 0x01, ""),
675 rx(0x8100, 0x01, "00"),
676 tx(0x0103, 0x02, ""),
677 rx(0x8103, 0x02, "00 12 3C 06"),
678 tx(0x0200, 0x03, ""),
679 rx(0x8200, 0x03, "00 02 01 02"),
680 tx(0x010D, 0x04, "06 05 06 11 1A 1B 1D"),
681 rx(0x810D, 0x04, "00 01 06 01"),
682 tx(0x0106, 0x05, ""),
683 // no battery reply: becomes a warning
684 ]);
685 let status = open(&script).unwrap().status().unwrap();
686 script.assert_done();
687 assert_eq!(status.switches.len(), 1);
688 assert!(status.noise_control.is_none() && status.firmware.is_none());
689 assert_eq!(status.warnings.len(), 1);
690 assert!(status.warnings.first().unwrap().starts_with("battery"));
691 }
692
693 #[test]
694 fn a_lost_link_stops_the_status() {
695 let mut steps = handshake();
696 steps.extend([tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"), Step::Close]);
697 let script = Script::new(steps);
698 assert!(matches!(open(&script).unwrap().status(), Err(Error::NotConnected(_))));
699 }
700
701 #[test]
702 fn touch_controls_and_hold_cycle() {
703 let mut steps = handshake();
704 steps.extend([
705 tx(0x0108, 0x05, ""),
706 rx(0x8108, 0x05, "00 02 01 01 02 01 02 01 04 08"),
707 tx(0x010C, 0x06, "02 01"),
708 rx(0x810C, 0x06, "00 02 01 0A"),
709 ]);
710 let script = Script::new(steps);
711 let touch = open(&script).unwrap().touch_controls().unwrap();
712 script.assert_done();
713 assert_eq!(touch.gestures.len(), 2);
714 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
715 }
716
717 /// The owner's `budctl --debug touch` (HW1), byte for byte. The buds' own frames (slice
718 /// byte 08) arrive glued to replies and need no answer.
719 #[test]
720 fn replays_the_hw1_touch_session() {
721 let script = Script::new(vec![
722 Step::Tx(hex("AA 07 00 00 00 01 01 00 00")),
723 Step::Rx(hex(
724 "AA 10 00 00 00 81 01 09 00 00 BF 1F 5A 20 45 04 40 00 AA 07 08 00 01 05 0F 00 00",
725 )),
726 Step::Tx(hex("AA 07 00 00 03 01 02 00 00")),
727 Step::Rx(hex("AA 0B 00 00 03 81 02 04 00 00 12 3C 06")),
728 Step::Tx(hex("AA 07 00 00 00 02 03 00 00")),
729 Step::Rx(hex("AA 12 00 00 00 82 03 0B 00 00 09 01 02 03 04 05 06 0D F1 F4")),
730 Step::Tx(hex("AA 0C 00 00 05 02 04 05 00 04 01 02 03 04")),
731 Step::Rx(hex(
732 "AA 11 00 00 05 82 04 0A 00 00 04 01 00 02 00 03 00 04 00 AA 0F 08 00 04 02 10 08 00 01 03 02 64 01 64 03 00",
733 )),
734 Step::Tx(hex("AA 07 00 00 08 01 05 00 00")),
735 Step::Rx(hex(
736 "AA 21 00 00 08 81 05 1A 00 00 06 01 01 02 01 01 01 03 06 01 01 04 08 02 01 02 01 02 01 03 06 02 01 04 08",
737 )),
738 Step::Tx(hex("AA 09 00 00 0C 01 06 02 00 02 01")),
739 Step::Rx(hex("AA 0B 00 00 0C 81 06 04 00 00 02 01 0A")),
740 ]);
741 let touch = open(&script).unwrap().touch_controls().unwrap();
742 script.assert_done();
743 let table: Vec<(&str, &str, &str)> = touch
744 .gestures
745 .iter()
746 .map(|g| (g.side.as_str(), g.gesture.as_str(), g.action.as_str()))
747 .collect();
748 assert_eq!(
749 table,
750 [
751 ("left", "double-tap", "play-pause"),
752 ("left", "triple-tap", "next"),
753 ("left", "hold", "noise-cycle"),
754 ("right", "double-tap", "play-pause"),
755 ("right", "triple-tap", "next"),
756 ("right", "hold", "noise-cycle"),
757 ]
758 );
759 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
760 assert!(touch.warnings.is_empty());
761 }
762
763 #[test]
764 fn sets_noise_control_and_reads_it_back() {
765 let mut steps = handshake();
766 steps.extend([
767 tx(0x0404, 0x05, "01 01 02"),
768 rx(0x8404, 0x05, "00"),
769 // The buds announce the change; it needs no answer.
770 rx(0x0204, 0x10, "03 01 01 02"),
771 tx(0x010C, 0x06, "01 01"),
772 rx(0x810C, 0x06, "00 01 01 02"),
773 ]);
774 let script = Script::new(steps);
775 let now = open(&script)
776 .unwrap()
777 .set_noise_control(NoiseMode::Transparency)
778 .unwrap();
779 script.assert_done();
780 assert_eq!((now.mode.as_str(), now.level), ("transparency", None));
781 }
782
783 #[test]
784 fn reads_back_again_until_the_change_shows() {
785 let mut steps = handshake();
786 steps.extend([
787 tx(0x0404, 0x05, "01 01 01"),
788 rx(0x8404, 0x05, "00"),
789 tx(0x010C, 0x06, "01 01"),
790 rx(0x810C, 0x06, "00 01 01 08"),
791 tx(0x010C, 0x07, "01 01"),
792 rx(0x810C, 0x07, "00 01 01 01"),
793 ]);
794 let script = Script::new(steps);
795 let now = open(&script).unwrap().set_noise_control(NoiseMode::Off).unwrap();
796 script.assert_done();
797 assert_eq!(now.mode, "off");
798 }
799
800 #[test]
801 fn sets_a_noise_cancelling_level() {
802 let mut steps = handshake();
803 steps.extend([
804 tx(0x0404, 0x05, "01 01 04"),
805 rx(0x8404, 0x05, "00"),
806 tx(0x010C, 0x06, "01 01"),
807 rx(0x810C, 0x06, "00 01 01 04"),
808 ]);
809 let script = Script::new(steps);
810 let now = open(&script)
811 .unwrap()
812 .set_noise_control(NoiseMode::Anc(AncLevel::Light))
813 .unwrap();
814 script.assert_done();
815 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
816 }
817
818 #[test]
819 fn a_rejected_noise_control_change_is_not_read_back() {
820 let mut steps = handshake();
821 steps.extend([tx(0x0404, 0x05, "01 01 08"), rx(0x8404, 0x05, "0E")]);
822 let script = Script::new(steps);
823 let result = open(&script).unwrap().set_noise_control(NoiseMode::Anc(AncLevel::Deep));
824 script.assert_done();
825 assert!(matches!(result, Err(Error::Rejected { status: 0x0E, .. })));
826 }
827
828 #[test]
829 fn noise_control_is_not_written_without_its_bitmap_bit() {
830 let script = Script::new(vec![
831 tx(0x0100, 0x01, ""),
832 rx(0x8100, 0x01, "00"),
833 tx(0x0103, 0x02, ""),
834 rx(0x8103, 0x02, "00 12 3C 06"),
835 tx(0x0200, 0x03, ""),
836 rx(0x8200, 0x03, "00 00"),
837 ]);
838 let result = open(&script).unwrap().set_noise_control(NoiseMode::Off);
839 script.assert_done();
840 assert!(matches!(result, Err(Error::Unsupported(_))));
841 }
842
843 #[test]
844 fn sets_game_mode_and_reads_it_back() {
845 let mut steps = handshake();
846 steps.extend([
847 tx(0x0403, 0x05, "06 01"),
848 rx(0x8403, 0x05, "00"),
849 // Still the old value: budctl reads again.
850 tx(0x010D, 0x06, "06 05 06 11 1A 1B 1D"),
851 rx(0x810D, 0x06, "00 06 05 00 06 00 11 00 1A 01 1B 00 1D 00"),
852 tx(0x010D, 0x07, "06 05 06 11 1A 1B 1D"),
853 rx(0x810D, 0x07, "00 06 05 00 06 01 11 00 1A 01 1B 00 1D 00"),
854 ]);
855 let script = Script::new(steps);
856 let game = open(&script).unwrap().set_switch("game", true).unwrap();
857 script.assert_done();
858 assert_eq!((game.key, game.on), ("game", true));
859 }
860
861 #[test]
862 fn unknown_switches_are_never_written() {
863 // Nothing is sent after the handshake, so the subscribe ack is left out.
864 let mut steps = handshake();
865 steps.pop();
866 let script = Script::new(steps);
867 let mut session = open(&script).unwrap();
868 assert!(matches!(session.set_switch("turbo", true), Err(Error::Unsupported(_))));
869 script.assert_done();
870 }
871
872 #[test]
873 fn sets_bass_boost_and_reads_it_back_at_once() {
874 let mut steps = handshake();
875 steps.extend([
876 tx(0x0403, 0x05, "1D 01"),
877 rx(0x8403, 0x05, "00"),
878 tx(0x010D, 0x06, "06 05 06 11 1A 1B 1D"),
879 rx(0x810D, 0x06, "00 06 05 00 06 00 11 00 1A 01 1B 00 1D 01"),
880 ]);
881 let script = Script::new(steps);
882 let now = open(&script).unwrap().set_switch("bass-boost", true).unwrap();
883 script.assert_done();
884 assert_eq!((now.key, now.on), ("bass-boost", true));
885 }
886
887 #[test]
888 fn a_switch_the_buds_lack_is_rejected() {
889 let mut steps = handshake();
890 steps.extend([tx(0x0403, 0x05, "06 00"), rx(0x8403, 0x05, "01")]);
891 let script = Script::new(steps);
892 let result = open(&script).unwrap().set_switch("game", false);
893 script.assert_done();
894 assert!(matches!(result, Err(Error::Rejected { status: 0x01, .. })));
895 }
896
897 #[test]
898 fn selects_an_eq_preset_and_reads_it_back() {
899 let mut steps = handshake();
900 steps.extend([
901 tx(0x0406, 0x05, "01"),
902 rx(0x8406, 0x05, "00"),
903 tx(0x010F, 0x06, ""),
904 rx(0x810F, 0x06, "00 01"),
905 ]);
906 let script = Script::new(steps);
907 let now = open(&script).unwrap().set_eq("deep-bass").unwrap();
908 script.assert_done();
909 assert_eq!(now, "deep-bass");
910 }
911
912 #[test]
913 fn an_unknown_eq_preset_is_a_usage_error() {
914 let mut steps = handshake();
915 steps.pop();
916 let script = Script::new(steps);
917 let result = open(&script).unwrap().set_eq("loudness");
918 script.assert_done();
919 match result {
920 Err(Error::Usage(message)) => assert!(message.contains("pure-raw, deep-bass, pure-vocals, pure-bass")),
921 other => panic!("expected a usage error, got {other:?}"),
922 }
923 }
924
925 #[test]
926 fn rings_and_stops() {
927 let mut steps = handshake();
928 steps.extend([
929 tx(0x0400, 0x05, "01"),
930 rx(0x8400, 0x05, "00"),
931 tx(0x0400, 0x06, "00"),
932 rx(0x8400, 0x06, "00"),
933 ]);
934 let script = Script::new(steps);
935 let mut session = open(&script).unwrap();
936 session.find(true).unwrap();
937 session.find(false).unwrap();
938 script.assert_done();
939 }
940
941 #[test]
942 fn find_is_not_sent_without_its_bitmap_bit() {
943 // Bits 0-4 only: no bit 5.
944 let script = Script::new(vec![
945 tx(0x0100, 0x01, ""),
946 rx(0x8100, 0x01, "00 1F"),
947 tx(0x0103, 0x02, ""),
948 rx(0x8103, 0x02, "00 12 3C 06"),
949 tx(0x0200, 0x03, ""),
950 rx(0x8200, 0x03, "00 00"),
951 ]);
952 let result = open(&script).unwrap().find(true);
953 script.assert_done();
954 assert!(matches!(result, Err(Error::Unsupported(_))));
955 }
956
957 /// The owner's gesture table (HW1) and the same table with the left triple tap on volume up.
958 const HW1_TABLE: &str = "00 06 01 01 02 01 01 01 03 06 01 01 04 08 02 01 02 01 02 01 03 06 02 01 04 08";
959 const LEFT_VOLUME_UP: &str = "06 01 01 02 01 01 01 03 0B 01 01 04 08 02 01 02 01 02 01 03 06 02 01 04 08";
960
961 #[test]
962 fn a_touch_dry_run_reads_the_table_and_sends_nothing() {
963 let mut steps = handshake();
964 steps.extend([tx(0x0108, 0x05, ""), rx(0x8108, 0x05, HW1_TABLE)]);
965 let script = Script::new(steps);
966 let change = open(&script)
967 .unwrap()
968 .set_touch("left", "triple-tap", "volume-up", true)
969 .unwrap();
970 script.assert_done();
971 assert!(!change.sent);
972 let write = change.write.unwrap();
973 assert_eq!((write.command, write.payload), (0x0401, hex(LEFT_VOLUME_UP)));
974 assert_eq!(
975 change.changes,
976 [GestureChange {
977 side: "left".to_owned(),
978 gesture: "triple-tap".to_owned(),
979 from: "next".to_owned(),
980 to: "volume-up".to_owned(),
981 }]
982 );
983 let left_triple = change.gestures.get(1).unwrap();
984 assert_eq!(left_triple.action, "volume-up");
985 }
986
987 #[test]
988 fn sets_a_gesture_and_reads_the_table_back() {
989 let mut steps = handshake();
990 steps.extend([
991 tx(0x0108, 0x05, ""),
992 rx(0x8108, 0x05, HW1_TABLE),
993 tx(0x0401, 0x06, LEFT_VOLUME_UP),
994 // The owner's ack (W8): 00, then the old table cut to the write's length.
995 rx(
996 0x8401,
997 0x06,
998 "00 06 01 01 02 01 01 01 03 06 01 01 04 08 02 01 02 01 02 01 03 06 02 01 04",
999 ),
1000 tx(0x0108, 0x07, ""),
1001 rx(0x8108, 0x07, &format!("00 {LEFT_VOLUME_UP}")),
1002 ]);
1003 let script = Script::new(steps);
1004 let change = open(&script)
1005 .unwrap()
1006 .set_touch("left", "triple-tap", "volume-up", false)
1007 .unwrap();
1008 script.assert_done();
1009 assert!(change.sent);
1010 assert_eq!(change.changes.len(), 1);
1011 assert_eq!(change.gestures.get(1).unwrap().action, "volume-up");
1012 }
1013
1014 #[test]
1015 fn a_gesture_that_already_has_the_action_is_not_written() {
1016 let mut steps = handshake();
1017 steps.extend([tx(0x0108, 0x05, ""), rx(0x8108, 0x05, HW1_TABLE)]);
1018 let script = Script::new(steps);
1019 let change = open(&script)
1020 .unwrap()
1021 .set_touch("both", "hold", "noise-cycle", false)
1022 .unwrap();
1023 script.assert_done();
1024 assert!(change.changes.is_empty() && change.write.is_none() && !change.sent);
1025 }
1026
1027 #[test]
1028 fn a_bad_touch_action_is_refused_before_anything_is_sent() {
1029 let mut steps = handshake();
1030 steps.pop();
1031 let script = Script::new(steps);
1032 let result = open(&script)
1033 .unwrap()
1034 .set_touch("left", "double-tap", "volume-up", false);
1035 script.assert_done();
1036 assert!(matches!(result, Err(Error::Usage(_))));
1037 }
1038
1039 #[test]
1040 fn sets_the_hold_cycle_and_reads_it_back() {
1041 let mut steps = handshake();
1042 steps.extend([
1043 tx(0x0404, 0x05, "02 01 0B"),
1044 rx(0x8404, 0x05, "00"),
1045 tx(0x010C, 0x06, "02 01"),
1046 rx(0x810C, 0x06, "00 02 01 0B"),
1047 ]);
1048 let script = Script::new(steps);
1049 let now = open(&script)
1050 .unwrap()
1051 .set_hold_cycle(&["off", "transparency", "anc"])
1052 .unwrap();
1053 script.assert_done();
1054 assert_eq!(now, ["off", "transparency", "anc"]);
1055 }
1056
1057 #[test]
1058 fn a_one_mode_hold_cycle_is_refused_before_anything_is_sent() {
1059 let mut steps = handshake();
1060 steps.pop();
1061 let script = Script::new(steps);
1062 let result = open(&script).unwrap().set_hold_cycle(&["anc"]);
1063 script.assert_done();
1064 assert!(matches!(result, Err(Error::Usage(_))));
1065 }
1066
1067 #[test]
1068 fn runs_a_fit_test_and_waits_for_the_result() {
1069 let mut steps = handshake();
1070 steps.extend([
1071 tx(0x0405, 0x05, "01"),
1072 rx(0x8405, 0x05, "00"),
1073 // Other pushes may come first; they are skipped.
1074 rx(0x0204, 0x20, "01 03 01 5A 02 5A 03 00"),
1075 rx(0x0204, 0x21, "04 01 01 02 00"),
1076 ]);
1077 let script = Script::new(steps);
1078 let buds = open(&script).unwrap().fit_test().unwrap();
1079 script.assert_done();
1080 let rows: Vec<(&str, &str)> = buds.iter().map(|b| (b.side.as_str(), b.fit.as_str())).collect();
1081 assert_eq!(rows, [("left", "good"), ("right", "average")]);
1082 }
1083
1084 #[test]
1085 fn a_fit_test_with_no_result_is_stopped() {
1086 let mut steps = handshake();
1087 steps.extend([
1088 tx(0x0405, 0x05, "01"),
1089 rx(0x8405, 0x05, "00"),
1090 // No result: the wait runs out, and budctl stops the test.
1091 tx(0x0405, 0x06, "00"),
1092 rx(0x8405, 0x06, "00"),
1093 ]);
1094 let script = Script::new(steps);
1095 let result = open(&script).unwrap().fit_test();
1096 script.assert_done();
1097 match result {
1098 Err(Error::Timeout(message)) => assert!(message.ends_with("budctl stopped the test")),
1099 other => panic!("expected a timeout, got {other:?}"),
1100 }
1101 }
1102
1103 #[test]
1104 fn no_fit_test_without_the_result_subscription() {
1105 // The buds offer no event 04, so no result could ever arrive: nothing is sent.
1106 let script = Script::new(vec![
1107 tx(0x0100, 0x01, ""),
1108 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
1109 tx(0x0103, 0x02, ""),
1110 rx(0x8103, 0x02, "00 12 3C 06"),
1111 tx(0x0200, 0x03, ""),
1112 rx(0x8200, 0x03, "00 03 01 02 03"),
1113 tx(0x0205, 0x04, "03 01 02 03"),
1114 ]);
1115 let result = open(&script).unwrap().fit_test();
1116 script.assert_done();
1117 assert!(matches!(result, Err(Error::Unsupported(_))));
1118 }
1119}