Krolyxon
Commit 54dede1

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

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