Krolyxon
Commit 5e24422

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

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