Krolyxon
Commit 00f061b

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

1101 lines · 39.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 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 /// Ack-only: the T310 never answers the bass-level read. Spec: realme_t310.md#bass-write
323 fn set_bass_level(&mut self, level: i8) -> Result<()> {
324 let payload = codec::bass_write_payload(level)?;
325 if !self.commands.allows(codec::BASS_LEVEL_WRITE) {
326 return Err(Error::Unsupported("these buds do not offer a bass level".to_owned()));
327 }
328 let reply = self.link.request(codec::BASS_LEVEL_WRITE, &payload)?;
329 codec::check_write_reply(&reply, "the bass-level change")
330 }
331
332 /// Spec: realme_t310.md#find
333 fn find(&mut self, ring: bool) -> Result<()> {
334 if !self.commands.allows(codec::FIND) {
335 return Err(Error::Unsupported("these buds cannot ring through budctl".to_owned()));
336 }
337 let reply = self.link.request(codec::FIND, &codec::find_payload(ring))?;
338 codec::check_write_reply(&reply, if ring { "ringing" } else { "stopping the ringing" })
339 }
340
341 /// Spec: realme_t310.md#switch-write, #write-read-back
342 fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> {
343 let Some(id) = codec::switch_id(key) else {
344 return Err(Error::Unsupported(format!("these buds have no switch called {key}")));
345 };
346 let Some(&(_, timing)) = WRITABLE_SWITCHES.iter().find(|(k, _)| *k == key) else {
347 return Err(Error::Unsupported(format!("changing {key} is not supported yet")));
348 };
349 if !self.commands.allows(codec::SWITCH_WRITE) || !self.commands.allows(codec::SWITCHES) {
350 return Err(Error::Unsupported("these buds do not offer switch settings".to_owned()));
351 }
352 let reply = self
353 .link
354 .request(codec::SWITCH_WRITE, &codec::switch_write_payload(id, on))?;
355 codec::check_write_reply(&reply, "the switch change")?;
356
357 self.read_back(
358 timing,
359 codec::SWITCHES,
360 &codec::SWITCH_QUERY,
361 |reply| {
362 let switch = codec::decode_switches(reply)?
363 .into_iter()
364 .find(|s| s.key == key)
365 .ok_or_else(|| Error::Protocol(format!("the switch reply has no {key}")))?;
366 Ok((switch.on == on, switch))
367 },
368 |now| {
369 format!(
370 "the buds accepted {} {}, but still report {}",
371 now.label,
372 on_off(on),
373 on_off(now.on)
374 )
375 },
376 )
377 }
378
379 fn touch_controls(&mut self) -> Result<TouchControls> {
380 if !self.commands.allows(codec::GESTURES) {
381 return Err(Error::Unsupported(
382 "these buds do not offer touch-control settings".to_owned(),
383 ));
384 }
385 let gestures = codec::decode_gestures(&self.link.request(codec::GESTURES, &[])?)?;
386 let mut reads = Reads::default();
387 let hold_cycle = self.read(
388 &mut reads,
389 "hold cycle",
390 codec::NOISE_CONTROL,
391 &codec::HOLD_CYCLE_QUERY,
392 codec::decode_hold_cycle,
393 )?;
394 Ok(TouchControls {
395 gestures,
396 hold_cycle,
397 warnings: reads.warnings,
398 })
399 }
400
401 /// Spec: realme_t310.md#hold-cycle-write, #write-read-back
402 fn set_hold_cycle(&mut self, modes: &[&str]) -> Result<Vec<String>> {
403 let mask = codec::hold_cycle_mask(modes)?;
404 if !self.commands.allows(codec::NOISE_CONTROL_WRITE) || !self.commands.allows(codec::NOISE_CONTROL) {
405 return Err(Error::Unsupported(
406 "these buds do not offer noise-control settings".to_owned(),
407 ));
408 }
409 let reply = self
410 .link
411 .request(codec::NOISE_CONTROL_WRITE, &codec::hold_cycle_write_payload(mask))?;
412 codec::check_write_reply(&reply, "the hold-cycle change")?;
413
414 self.read_back(
415 READ_BACK,
416 codec::NOISE_CONTROL,
417 &codec::HOLD_CYCLE_QUERY,
418 |reply| {
419 Ok((
420 codec::decode_hold_cycle_mask(reply)? == mask,
421 codec::decode_hold_cycle(reply)?,
422 ))
423 },
424 |now| {
425 format!(
426 "the buds accepted the hold cycle {}, but still report {}",
427 modes.join(" → "),
428 now.join(" → ")
429 )
430 },
431 )
432 }
433
434 /// Spec: realme_t310.md#gesture-write, #gesture-choices
435 fn set_touch(&mut self, side: &str, gesture: &str, action: &str, dry_run: bool) -> Result<TouchChange> {
436 let target = codec::touch_target(side, gesture, action)?;
437 if !self.commands.allows(codec::GESTURE_WRITE) || !self.commands.allows(codec::GESTURES) {
438 return Err(Error::Unsupported(
439 "these buds do not offer touch-control settings".to_owned(),
440 ));
441 }
442 // The write carries the whole table, so it starts from the table read just now.
443 let before = codec::decode_gesture_entries(&self.link.request(codec::GESTURES, &[])?)?;
444 let after = codec::retarget_gestures(&before, &target)?;
445 let changes: Vec<GestureChange> = before
446 .iter()
447 .zip(&after)
448 .filter(|(old, new)| old != new)
449 .map(|(old, new)| {
450 let (old, new) = (codec::name_gesture(old), codec::name_gesture(new));
451 GestureChange {
452 side: new.side,
453 gesture: new.gesture,
454 from: old.action,
455 to: new.action,
456 }
457 })
458 .collect();
459 let planned: Vec<_> = after.iter().map(codec::name_gesture).collect();
460 if changes.is_empty() {
461 return Ok(TouchChange {
462 changes,
463 write: None,
464 sent: false,
465 gestures: planned,
466 });
467 }
468 let write = RawWrite {
469 command: codec::GESTURE_WRITE,
470 payload: codec::gesture_write_payload(&after)?,
471 };
472 if dry_run {
473 return Ok(TouchChange {
474 changes,
475 write: Some(write),
476 sent: false,
477 gestures: planned,
478 });
479 }
480
481 let reply = self.link.request(write.command, &write.payload)?;
482 codec::check_write_reply(&reply, "the touch-control change")?;
483 let gestures = self.read_back(
484 TOUCH_READ_BACK,
485 codec::GESTURES,
486 &[],
487 |reply| {
488 let now = codec::decode_gesture_entries(reply)?;
489 // Applied when retargeting the table read back would change nothing.
490 let applied = codec::retarget_gestures(&now, &target).is_ok_and(|again| again == now);
491 Ok((applied, now))
492 },
493 |now| {
494 let still: Vec<String> = changes
495 .iter()
496 .filter_map(|change| {
497 now.iter()
498 .map(codec::name_gesture)
499 .find(|g| g.group.is_none() && g.side == change.side && g.gesture == change.gesture)
500 })
501 .filter(|g| g.action != action)
502 .map(|g| format!("{} {} {}", g.side, g.gesture, g.action))
503 .collect();
504 if still.is_empty() {
505 format!("the buds accepted {side} {gesture} {action}, but the table read back does not show it")
506 } else {
507 format!(
508 "the buds accepted {side} {gesture} {action}, but still report {}",
509 still.join(", ")
510 )
511 }
512 },
513 )?;
514 Ok(TouchChange {
515 changes,
516 write: Some(write),
517 sent: true,
518 gestures: gestures.iter().map(codec::name_gesture).collect(),
519 })
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use crate::status::{AncLevel, Charge};
527 use crate::transport::mock::{Script, Step, hex};
528
529 fn tx(cmd: u16, seq: u8, payload: &str) -> Step {
530 Step::Tx(frame::encode(cmd, seq, &hex(payload)).unwrap())
531 }
532
533 fn rx(cmd: u16, seq: u8, payload: &str) -> Step {
534 Step::Rx(frame::encode(cmd, seq, &hex(payload)).unwrap())
535 }
536
537 fn fixed_clock() -> u32 {
538 1_750_000_000
539 }
540
541 fn open(script: &Script) -> Result<T310Session> {
542 T310Session::open(Link::new(script.transport(), fixed_clock))
543 }
544
545 /// The handshake of the owner's T310 (HW1); seq 01-04 are used up.
546 fn handshake() -> Vec<Step> {
547 vec![
548 tx(0x0100, 0x01, ""),
549 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
550 tx(0x0103, 0x02, ""),
551 rx(0x8103, 0x02, "00 12 3C 06"),
552 tx(0x0200, 0x03, ""),
553 rx(0x8200, 0x03, "00 09 01 02 03 04 05 06 0D F1 F4"),
554 tx(0x0205, 0x04, "04 01 02 03 04"),
555 rx(0x8205, 0x04, "00 04 01 00 02 00 03 00 04 00"),
556 ]
557 }
558
559 #[test]
560 fn full_status() {
561 let mut steps = handshake();
562 let mut firmware = String::from("00 03");
563 for byte in b"1,2,120,2,2,120,3,2,105" {
564 firmware.push_str(&format!(" {byte:02X}"));
565 }
566 steps.extend([
567 tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"),
568 rx(0x810D, 0x05, "00 06 05 00 06 00 11 01 1A 00 1B 01 1D 01"),
569 tx(0x010C, 0x06, "01 01"),
570 rx(0x810C, 0x06, "00 01 01 08"),
571 tx(0x0106, 0x07, ""),
572 rx(0x8106, 0x07, "00 03 01 50 02 E4 03 3C"),
573 tx(0x010F, 0x08, ""),
574 rx(0x810F, 0x08, "00 01"),
575 tx(0x0114, 0x09, ""),
576 rx(0x8114, 0x09, "00 02"),
577 tx(0x0105, 0x0A, ""),
578 rx(0x8105, 0x0A, &firmware),
579 ]);
580 let script = Script::new(steps);
581 let status = open(&script).unwrap().status().unwrap();
582 script.assert_done();
583
584 assert_eq!(
585 status.battery.left,
586 Some(Charge {
587 level: 80,
588 charging: false
589 })
590 );
591 assert_eq!(
592 status.battery.right,
593 Some(Charge {
594 level: 100,
595 charging: true
596 })
597 );
598 assert_eq!(
599 status.battery.case,
600 Some(Charge {
601 level: 60,
602 charging: false
603 })
604 );
605 let noise = status.noise_control.unwrap();
606 assert_eq!((noise.mode.as_str(), noise.level.as_deref()), ("anc", Some("deep")));
607 assert_eq!(status.eq.as_deref(), Some("deep-bass"));
608 assert_eq!(status.bass_level, None);
609 assert_eq!(status.codec.as_deref(), Some("AAC"));
610 assert_eq!(status.firmware.as_deref(), Some("120.120.105"));
611 let game = status.switches.iter().find(|s| s.key == "game").unwrap();
612 assert!(!game.on);
613 assert_eq!(status.switches.len(), 5);
614 assert!(status.warnings.is_empty());
615 }
616
617 #[test]
618 fn answers_the_time_request_during_the_settle() {
619 let mut steps = vec![
620 rx(0x0500, 0x01, ""),
621 Step::Tx(hex("AA 0C 00 00 00 85 01 05 00 00 80 E1 4E 68")),
622 ];
623 steps.extend(handshake());
624 // No 0x8205 ack this time: it may never arrive, and `open` does not wait for it.
625 // Spec: realme_t310.md#subscribe
626 steps.pop();
627 let script = Script::new(steps);
628 open(&script).unwrap();
629 script.assert_done();
630 }
631
632 #[test]
633 fn a_silent_handshake_reports_a_busy_channel() {
634 let script = Script::new(vec![tx(0x0100, 0x01, "")]);
635 assert!(matches!(open(&script), Err(Error::ChannelBusy(_))));
636 }
637
638 #[test]
639 fn other_models_are_refused() {
640 let script = Script::new(vec![
641 tx(0x0100, 0x01, ""),
642 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
643 tx(0x0103, 0x02, ""),
644 rx(0x8103, 0x02, "00 14 54 06"),
645 ]);
646 match open(&script) {
647 Err(Error::WrongModel { found, .. }) => assert_eq!(found, "065414"),
648 other => panic!("expected WrongModel, got {:?}", other.err()),
649 }
650 }
651
652 #[test]
653 fn commands_missing_from_the_bitmap_are_never_sent() {
654 // A bitmap with no bits: only always-allowed commands go out, and no subscription.
655 let script = Script::new(vec![
656 tx(0x0100, 0x01, ""),
657 rx(0x8100, 0x01, "00"),
658 tx(0x0103, 0x02, ""),
659 rx(0x8103, 0x02, "00 12 3C 06"),
660 tx(0x0200, 0x03, ""),
661 rx(0x8200, 0x03, "00 02 01 02"),
662 tx(0x010D, 0x04, "06 05 06 11 1A 1B 1D"),
663 rx(0x810D, 0x04, "00 01 06 01"),
664 tx(0x0106, 0x05, ""),
665 // no battery reply: becomes a warning
666 ]);
667 let status = open(&script).unwrap().status().unwrap();
668 script.assert_done();
669 assert_eq!(status.switches.len(), 1);
670 assert!(status.noise_control.is_none() && status.firmware.is_none());
671 assert_eq!(status.warnings.len(), 1);
672 assert!(status.warnings.first().unwrap().starts_with("battery"));
673 }
674
675 #[test]
676 fn a_lost_link_stops_the_status() {
677 let mut steps = handshake();
678 steps.extend([tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"), Step::Close]);
679 let script = Script::new(steps);
680 assert!(matches!(open(&script).unwrap().status(), Err(Error::NotConnected(_))));
681 }
682
683 #[test]
684 fn touch_controls_and_hold_cycle() {
685 let mut steps = handshake();
686 steps.extend([
687 tx(0x0108, 0x05, ""),
688 rx(0x8108, 0x05, "00 02 01 01 02 01 02 01 04 08"),
689 tx(0x010C, 0x06, "02 01"),
690 rx(0x810C, 0x06, "00 02 01 0A"),
691 ]);
692 let script = Script::new(steps);
693 let touch = open(&script).unwrap().touch_controls().unwrap();
694 script.assert_done();
695 assert_eq!(touch.gestures.len(), 2);
696 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
697 }
698
699 /// The owner's `budctl --debug touch` (HW1), byte for byte. The buds' own frames (slice
700 /// byte 08) arrive glued to replies and need no answer.
701 #[test]
702 fn replays_the_hw1_touch_session() {
703 let script = Script::new(vec![
704 Step::Tx(hex("AA 07 00 00 00 01 01 00 00")),
705 Step::Rx(hex(
706 "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",
707 )),
708 Step::Tx(hex("AA 07 00 00 03 01 02 00 00")),
709 Step::Rx(hex("AA 0B 00 00 03 81 02 04 00 00 12 3C 06")),
710 Step::Tx(hex("AA 07 00 00 00 02 03 00 00")),
711 Step::Rx(hex("AA 12 00 00 00 82 03 0B 00 00 09 01 02 03 04 05 06 0D F1 F4")),
712 Step::Tx(hex("AA 0C 00 00 05 02 04 05 00 04 01 02 03 04")),
713 Step::Rx(hex(
714 "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",
715 )),
716 Step::Tx(hex("AA 07 00 00 08 01 05 00 00")),
717 Step::Rx(hex(
718 "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",
719 )),
720 Step::Tx(hex("AA 09 00 00 0C 01 06 02 00 02 01")),
721 Step::Rx(hex("AA 0B 00 00 0C 81 06 04 00 00 02 01 0A")),
722 ]);
723 let touch = open(&script).unwrap().touch_controls().unwrap();
724 script.assert_done();
725 let table: Vec<(&str, &str, &str)> = touch
726 .gestures
727 .iter()
728 .map(|g| (g.side.as_str(), g.gesture.as_str(), g.action.as_str()))
729 .collect();
730 assert_eq!(
731 table,
732 [
733 ("left", "double-tap", "play-pause"),
734 ("left", "triple-tap", "next"),
735 ("left", "hold", "noise-cycle"),
736 ("right", "double-tap", "play-pause"),
737 ("right", "triple-tap", "next"),
738 ("right", "hold", "noise-cycle"),
739 ]
740 );
741 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
742 assert!(touch.warnings.is_empty());
743 }
744
745 #[test]
746 fn sets_noise_control_and_reads_it_back() {
747 let mut steps = handshake();
748 steps.extend([
749 tx(0x0404, 0x05, "01 01 02"),
750 rx(0x8404, 0x05, "00"),
751 // The buds announce the change; it needs no answer.
752 rx(0x0204, 0x10, "03 01 01 02"),
753 tx(0x010C, 0x06, "01 01"),
754 rx(0x810C, 0x06, "00 01 01 02"),
755 ]);
756 let script = Script::new(steps);
757 let now = open(&script)
758 .unwrap()
759 .set_noise_control(NoiseMode::Transparency)
760 .unwrap();
761 script.assert_done();
762 assert_eq!((now.mode.as_str(), now.level), ("transparency", None));
763 }
764
765 #[test]
766 fn reads_back_again_until_the_change_shows() {
767 let mut steps = handshake();
768 steps.extend([
769 tx(0x0404, 0x05, "01 01 01"),
770 rx(0x8404, 0x05, "00"),
771 tx(0x010C, 0x06, "01 01"),
772 rx(0x810C, 0x06, "00 01 01 08"),
773 tx(0x010C, 0x07, "01 01"),
774 rx(0x810C, 0x07, "00 01 01 01"),
775 ]);
776 let script = Script::new(steps);
777 let now = open(&script).unwrap().set_noise_control(NoiseMode::Off).unwrap();
778 script.assert_done();
779 assert_eq!(now.mode, "off");
780 }
781
782 #[test]
783 fn sets_a_noise_cancelling_level() {
784 let mut steps = handshake();
785 steps.extend([
786 tx(0x0404, 0x05, "01 01 04"),
787 rx(0x8404, 0x05, "00"),
788 tx(0x010C, 0x06, "01 01"),
789 rx(0x810C, 0x06, "00 01 01 04"),
790 ]);
791 let script = Script::new(steps);
792 let now = open(&script)
793 .unwrap()
794 .set_noise_control(NoiseMode::Anc(AncLevel::Light))
795 .unwrap();
796 script.assert_done();
797 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
798 }
799
800 #[test]
801 fn a_rejected_noise_control_change_is_not_read_back() {
802 let mut steps = handshake();
803 steps.extend([tx(0x0404, 0x05, "01 01 08"), rx(0x8404, 0x05, "0E")]);
804 let script = Script::new(steps);
805 let result = open(&script).unwrap().set_noise_control(NoiseMode::Anc(AncLevel::Deep));
806 script.assert_done();
807 assert!(matches!(result, Err(Error::Rejected { status: 0x0E, .. })));
808 }
809
810 #[test]
811 fn noise_control_is_not_written_without_its_bitmap_bit() {
812 let script = Script::new(vec![
813 tx(0x0100, 0x01, ""),
814 rx(0x8100, 0x01, "00"),
815 tx(0x0103, 0x02, ""),
816 rx(0x8103, 0x02, "00 12 3C 06"),
817 tx(0x0200, 0x03, ""),
818 rx(0x8200, 0x03, "00 00"),
819 ]);
820 let result = open(&script).unwrap().set_noise_control(NoiseMode::Off);
821 script.assert_done();
822 assert!(matches!(result, Err(Error::Unsupported(_))));
823 }
824
825 #[test]
826 fn sets_game_mode_and_reads_it_back() {
827 let mut steps = handshake();
828 steps.extend([
829 tx(0x0403, 0x05, "06 01"),
830 rx(0x8403, 0x05, "00"),
831 // Still the old value: budctl reads again.
832 tx(0x010D, 0x06, "06 05 06 11 1A 1B 1D"),
833 rx(0x810D, 0x06, "00 06 05 00 06 00 11 00 1A 01 1B 00 1D 00"),
834 tx(0x010D, 0x07, "06 05 06 11 1A 1B 1D"),
835 rx(0x810D, 0x07, "00 06 05 00 06 01 11 00 1A 01 1B 00 1D 00"),
836 ]);
837 let script = Script::new(steps);
838 let game = open(&script).unwrap().set_switch("game", true).unwrap();
839 script.assert_done();
840 assert_eq!((game.key, game.on), ("game", true));
841 }
842
843 #[test]
844 fn unknown_switches_are_never_written() {
845 // Nothing is sent after the handshake, so the subscribe ack is left out.
846 let mut steps = handshake();
847 steps.pop();
848 let script = Script::new(steps);
849 let mut session = open(&script).unwrap();
850 assert!(matches!(session.set_switch("turbo", true), Err(Error::Unsupported(_))));
851 script.assert_done();
852 }
853
854 #[test]
855 fn sets_bass_boost_and_reads_it_back_at_once() {
856 let mut steps = handshake();
857 steps.extend([
858 tx(0x0403, 0x05, "1D 01"),
859 rx(0x8403, 0x05, "00"),
860 tx(0x010D, 0x06, "06 05 06 11 1A 1B 1D"),
861 rx(0x810D, 0x06, "00 06 05 00 06 00 11 00 1A 01 1B 00 1D 01"),
862 ]);
863 let script = Script::new(steps);
864 let now = open(&script).unwrap().set_switch("bass-boost", true).unwrap();
865 script.assert_done();
866 assert_eq!((now.key, now.on), ("bass-boost", true));
867 }
868
869 #[test]
870 fn a_switch_the_buds_lack_is_rejected() {
871 let mut steps = handshake();
872 steps.extend([tx(0x0403, 0x05, "06 00"), rx(0x8403, 0x05, "01")]);
873 let script = Script::new(steps);
874 let result = open(&script).unwrap().set_switch("game", false);
875 script.assert_done();
876 assert!(matches!(result, Err(Error::Rejected { status: 0x01, .. })));
877 }
878
879 #[test]
880 fn selects_an_eq_preset_and_reads_it_back() {
881 let mut steps = handshake();
882 steps.extend([
883 tx(0x0406, 0x05, "01"),
884 rx(0x8406, 0x05, "00"),
885 tx(0x010F, 0x06, ""),
886 rx(0x810F, 0x06, "00 01"),
887 ]);
888 let script = Script::new(steps);
889 let now = open(&script).unwrap().set_eq("deep-bass").unwrap();
890 script.assert_done();
891 assert_eq!(now, "deep-bass");
892 }
893
894 #[test]
895 fn an_unknown_eq_preset_is_a_usage_error() {
896 let mut steps = handshake();
897 steps.pop();
898 let script = Script::new(steps);
899 let result = open(&script).unwrap().set_eq("loudness");
900 script.assert_done();
901 match result {
902 Err(Error::Usage(message)) => assert!(message.contains("pure-raw, deep-bass, pure-vocals, pure-bass")),
903 other => panic!("expected a usage error, got {other:?}"),
904 }
905 }
906
907 #[test]
908 fn rings_and_stops() {
909 let mut steps = handshake();
910 steps.extend([
911 tx(0x0400, 0x05, "01"),
912 rx(0x8400, 0x05, "00"),
913 tx(0x0400, 0x06, "00"),
914 rx(0x8400, 0x06, "00"),
915 ]);
916 let script = Script::new(steps);
917 let mut session = open(&script).unwrap();
918 session.find(true).unwrap();
919 session.find(false).unwrap();
920 script.assert_done();
921 }
922
923 #[test]
924 fn find_is_not_sent_without_its_bitmap_bit() {
925 // Bits 0-4 only: no bit 5.
926 let script = Script::new(vec![
927 tx(0x0100, 0x01, ""),
928 rx(0x8100, 0x01, "00 1F"),
929 tx(0x0103, 0x02, ""),
930 rx(0x8103, 0x02, "00 12 3C 06"),
931 tx(0x0200, 0x03, ""),
932 rx(0x8200, 0x03, "00 00"),
933 ]);
934 let result = open(&script).unwrap().find(true);
935 script.assert_done();
936 assert!(matches!(result, Err(Error::Unsupported(_))));
937 }
938
939 /// The owner's gesture table (HW1) and the same table with the left triple tap on volume up.
940 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";
941 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";
942
943 #[test]
944 fn a_touch_dry_run_reads_the_table_and_sends_nothing() {
945 let mut steps = handshake();
946 steps.extend([tx(0x0108, 0x05, ""), rx(0x8108, 0x05, HW1_TABLE)]);
947 let script = Script::new(steps);
948 let change = open(&script)
949 .unwrap()
950 .set_touch("left", "triple-tap", "volume-up", true)
951 .unwrap();
952 script.assert_done();
953 assert!(!change.sent);
954 let write = change.write.unwrap();
955 assert_eq!((write.command, write.payload), (0x0401, hex(LEFT_VOLUME_UP)));
956 assert_eq!(
957 change.changes,
958 [GestureChange {
959 side: "left".to_owned(),
960 gesture: "triple-tap".to_owned(),
961 from: "next".to_owned(),
962 to: "volume-up".to_owned(),
963 }]
964 );
965 let left_triple = change.gestures.get(1).unwrap();
966 assert_eq!(left_triple.action, "volume-up");
967 }
968
969 #[test]
970 fn sets_a_gesture_and_reads_the_table_back() {
971 let mut steps = handshake();
972 steps.extend([
973 tx(0x0108, 0x05, ""),
974 rx(0x8108, 0x05, HW1_TABLE),
975 tx(0x0401, 0x06, LEFT_VOLUME_UP),
976 // The owner's ack (W8): 00, then the old table cut to the write's length.
977 rx(
978 0x8401,
979 0x06,
980 "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",
981 ),
982 tx(0x0108, 0x07, ""),
983 rx(0x8108, 0x07, &format!("00 {LEFT_VOLUME_UP}")),
984 ]);
985 let script = Script::new(steps);
986 let change = open(&script)
987 .unwrap()
988 .set_touch("left", "triple-tap", "volume-up", false)
989 .unwrap();
990 script.assert_done();
991 assert!(change.sent);
992 assert_eq!(change.changes.len(), 1);
993 assert_eq!(change.gestures.get(1).unwrap().action, "volume-up");
994 }
995
996 #[test]
997 fn a_gesture_that_already_has_the_action_is_not_written() {
998 let mut steps = handshake();
999 steps.extend([tx(0x0108, 0x05, ""), rx(0x8108, 0x05, HW1_TABLE)]);
1000 let script = Script::new(steps);
1001 let change = open(&script)
1002 .unwrap()
1003 .set_touch("both", "hold", "noise-cycle", false)
1004 .unwrap();
1005 script.assert_done();
1006 assert!(change.changes.is_empty() && change.write.is_none() && !change.sent);
1007 }
1008
1009 #[test]
1010 fn a_bad_touch_action_is_refused_before_anything_is_sent() {
1011 let mut steps = handshake();
1012 steps.pop();
1013 let script = Script::new(steps);
1014 let result = open(&script)
1015 .unwrap()
1016 .set_touch("left", "double-tap", "volume-up", false);
1017 script.assert_done();
1018 assert!(matches!(result, Err(Error::Usage(_))));
1019 }
1020
1021 #[test]
1022 fn sets_the_hold_cycle_and_reads_it_back() {
1023 let mut steps = handshake();
1024 steps.extend([
1025 tx(0x0404, 0x05, "02 01 0B"),
1026 rx(0x8404, 0x05, "00"),
1027 tx(0x010C, 0x06, "02 01"),
1028 rx(0x810C, 0x06, "00 02 01 0B"),
1029 ]);
1030 let script = Script::new(steps);
1031 let now = open(&script)
1032 .unwrap()
1033 .set_hold_cycle(&["off", "transparency", "anc"])
1034 .unwrap();
1035 script.assert_done();
1036 assert_eq!(now, ["off", "transparency", "anc"]);
1037 }
1038
1039 #[test]
1040 fn a_one_mode_hold_cycle_is_refused_before_anything_is_sent() {
1041 let mut steps = handshake();
1042 steps.pop();
1043 let script = Script::new(steps);
1044 let result = open(&script).unwrap().set_hold_cycle(&["anc"]);
1045 script.assert_done();
1046 assert!(matches!(result, Err(Error::Usage(_))));
1047 }
1048
1049 /// The custom EQ list of the owner's T310 (W11a): one flat preset, "Custom1", id 04.
1050 const OWNER_CUSTOM_EQS: &str =
1051 "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";
1052
1053 #[test]
1054 fn lists_built_in_and_custom_eq_presets() {
1055 let mut steps = handshake();
1056 steps.extend([
1057 tx(0x010F, 0x05, ""),
1058 rx(0x810F, 0x05, "00 04"),
1059 tx(0x0122, 0x06, ""),
1060 // The owner's T310 (W11a).
1061 rx(0x8122, 0x06, OWNER_CUSTOM_EQS),
1062 ]);
1063 let script = Script::new(steps);
1064 let eq = open(&script).unwrap().eq_presets().unwrap();
1065 script.assert_done();
1066 assert_eq!(eq.current.as_deref(), Some("custom-4"));
1067 let names: Vec<(&str, bool)> = eq.presets.iter().map(|p| (p.name.as_str(), p.custom)).collect();
1068 assert_eq!(
1069 names,
1070 [
1071 ("pure-raw", false),
1072 ("deep-bass", false),
1073 ("pure-vocals", false),
1074 ("pure-bass", false),
1075 ("Custom1", true)
1076 ]
1077 );
1078 let bands: Vec<u16> = eq.presets.last().unwrap().bands.iter().map(|b| b.hz).collect();
1079 assert_eq!(bands, [50, 250, 1000, 4000, 8000, 16000]);
1080 assert!(eq.warnings.is_empty());
1081 }
1082
1083 #[test]
1084 fn sets_the_bass_level() {
1085 let mut steps = handshake();
1086 steps.extend([tx(0x041B, 0x05, "FB 05 FE"), rx(0x841B, 0x05, "00")]);
1087 let script = Script::new(steps);
1088 open(&script).unwrap().set_bass_level(-2).unwrap();
1089 script.assert_done();
1090 }
1091
1092 #[test]
1093 fn a_bass_level_out_of_range_is_refused_before_anything_is_sent() {
1094 let mut steps = handshake();
1095 steps.pop();
1096 let script = Script::new(steps);
1097 let result = open(&script).unwrap().set_bass_level(6);
1098 script.assert_done();
1099 assert!(matches!(result, Err(Error::Usage(_))));
1100 }
1101}