Krolyxon
Commit 52014a4

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

740 lines · 26.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::{NoiseControl, NoiseMode, Status, Switch, 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/// Spec: realme_t310.md#write-read-back
42const READ_BACK_PAUSE: Duration = Duration::from_millis(300);
43
44/// The switches budctl may write, each added after the previous one passed its hardware
45/// checkpoint (CLAUDE.md rule 4). Spec: realme_t310.md#switch-write
46const WRITABLE_SWITCHES: [(&str, ReadBack); 4] = [
47 ("game", GAME_READ_BACK),
48 ("bass-boost", READ_BACK),
49 ("spatial", READ_BACK),
50 ("wind", READ_BACK),
51];
52
53fn on_off(on: bool) -> &'static str {
54 if on { "on" } else { "off" }
55}
56
57/// Spec: realme_t310.md#identity
58const BLUETOOTH_NAME: &str = "realme Buds T310";
59
60/// Control services, in the order to try. Spec: realme_t310.md#transport
61const SERVICES: [Uuid; 2] = [
62 Uuid::from_u128(0x0000_079A_D102_11E1_9B23_0002_5B00_A5A5),
63 Uuid::from_u128(0x0000_1107_D102_11E1_9B23_0002_5B00_A5A5),
64];
65
66/// Quiet time after connecting, before the first request. Spec: realme_t310.md#init
67const SETTLE: Duration = Duration::from_millis(300);
68
69pub struct RealmeT310;
70
71impl Device for RealmeT310 {
72 fn name(&self) -> &'static str {
73 BLUETOOTH_NAME
74 }
75
76 fn matches(&self, device: &PairedDevice) -> bool {
77 device.name == BLUETOOTH_NAME
78 }
79
80 fn rfcomm_uuids(&self) -> &'static [Uuid] {
81 &SERVICES
82 }
83
84 fn open(&self, transport: Box<dyn Transport>) -> Result<Box<dyn Session>> {
85 Ok(Box::new(T310Session::open(Link::new(transport, unix_time))?))
86 }
87}
88
89fn unix_time() -> u32 {
90 let seconds = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
91 u32::try_from(seconds).unwrap_or(u32::MAX)
92}
93
94struct T310Session {
95 link: Link,
96 commands: Commands,
97}
98
99/// Collects what a series of best-effort reads produced.
100#[derive(Default)]
101struct Reads {
102 warnings: Vec<String>,
103 answered: usize,
104}
105
106impl T310Session {
107 /// The session start. Spec: realme_t310.md#init
108 fn open(mut link: Link) -> Result<Self> {
109 link.pump_until(Instant::now() + SETTLE)?;
110
111 let bitmap = link.request(codec::HANDSHAKE, &[]).map_err(|error| match error {
112 // Provisional until checkpoint HW1: a link that connects but stays silent is most
113 // likely held by another app. Spec: realme_t310.md#open
114 Error::Timeout(_) => Error::ChannelBusy("connected, but the buds did not answer the handshake".to_owned()),
115 other => other,
116 })?;
117 let commands = Commands::parse(&bitmap)?;
118
119 let product = codec::decode_product_id(&link.request(codec::PRODUCT_ID, &[])?)?;
120 if product != codec::T310_PRODUCT_ID {
121 return Err(Error::WrongModel {
122 expected: BLUETOOTH_NAME,
123 found: format!("{product:06X}"),
124 });
125 }
126
127 let offered = codec::decode_events_offered(&link.request(codec::EVENTS_OFFERED, &[])?)?;
128 let subscription = codec::subscribe_payload(&offered, &commands);
129 if commands.allows(codec::SUBSCRIBE) && subscription.len() > 1 {
130 // Some buds never acknowledge this, so budctl does not wait. Spec: realme_t310.md#subscribe
131 link.send(codec::SUBSCRIBE, &subscription)?;
132 }
133 Ok(Self { link, commands })
134 }
135
136 /// Read and decode one value, if the buds offer the command. A read the buds answer badly
137 /// (timeout, refusal, garbage) becomes a warning so the rest still shows; a lost link stops
138 /// everything.
139 fn read<T>(
140 &mut self,
141 reads: &mut Reads,
142 what: &str,
143 cmd: u16,
144 payload: &[u8],
145 decode: fn(&[u8]) -> Result<T>,
146 ) -> Result<Option<T>> {
147 if !self.commands.allows(cmd) {
148 return Ok(None);
149 }
150 match self.link.request(cmd, payload).and_then(|reply| decode(&reply)) {
151 Ok(value) => {
152 reads.answered += 1;
153 Ok(Some(value))
154 }
155 Err(error) if error.kind() == Kind::Failed => {
156 reads.warnings.push(format!("{what}: {error}"));
157 Ok(None)
158 }
159 Err(error) => Err(error),
160 }
161 }
162}
163
164impl T310Session {
165 /// After an acked write, read the setting back with `cmd query` until `check` reports the
166 /// new value, following `timing`. `check` returns whether the value is the one written,
167 /// and the value; `not_applied` describes a value that never changed.
168 /// Spec: realme_t310.md#write-read-back
169 fn read_back<T>(
170 &mut self,
171 timing: ReadBack,
172 cmd: u16,
173 query: &[u8],
174 check: impl Fn(&[u8]) -> Result<(bool, T)>,
175 not_applied: impl Fn(T) -> String,
176 ) -> Result<T> {
177 let acked = Instant::now();
178 self.link.pump_until(acked + timing.first)?;
179 loop {
180 let (applied, value) = check(&self.link.request(cmd, query)?)?;
181 if applied {
182 return Ok(value);
183 }
184 if Instant::now() >= acked + timing.window {
185 return Err(Error::NotApplied(not_applied(value)));
186 }
187 self.link.pump_until(Instant::now() + READ_BACK_PAUSE)?;
188 }
189 }
190}
191
192impl Session for T310Session {
193 fn status(&mut self) -> Result<Status> {
194 let mut reads = Reads::default();
195 let switches = self.read(
196 &mut reads,
197 "switches",
198 codec::SWITCHES,
199 &codec::SWITCH_QUERY,
200 codec::decode_switches,
201 )?;
202 let noise_control = self.read(
203 &mut reads,
204 "noise control",
205 codec::NOISE_CONTROL,
206 &codec::NOISE_MODE_QUERY,
207 codec::decode_noise_mode,
208 )?;
209 let battery = self.read(&mut reads, "battery", codec::BATTERY, &[], codec::decode_battery)?;
210 let eq = self.read(&mut reads, "EQ", codec::EQ_PRESET, &[], codec::decode_eq)?;
211 // No bass-level read: the T310 does not answer an empty 0x0124 (HW1). It returns with W6.
212 // Spec: realme_t310.md#bass-read
213 let codec = self.read(&mut reads, "codec", codec::CODEC, &[], codec::decode_codec)?;
214 let firmware = self.read(&mut reads, "firmware", codec::FIRMWARE, &[], codec::decode_firmware)?;
215
216 if reads.answered == 0 && !reads.warnings.is_empty() {
217 return Err(Error::Timeout(reads.warnings.join("; ")));
218 }
219 Ok(Status {
220 battery: battery.unwrap_or_default(),
221 noise_control,
222 eq,
223 bass_level: None,
224 switches: switches.unwrap_or_default(),
225 codec,
226 firmware,
227 warnings: reads.warnings,
228 })
229 }
230
231 /// Spec: realme_t310.md#anc-write, #write-read-back
232 fn set_noise_control(&mut self, mode: NoiseMode) -> Result<NoiseControl> {
233 if !self.commands.allows(codec::NOISE_CONTROL_WRITE) || !self.commands.allows(codec::NOISE_CONTROL) {
234 return Err(Error::Unsupported(
235 "these buds do not offer noise-control settings".to_owned(),
236 ));
237 }
238 let reply = self
239 .link
240 .request(codec::NOISE_CONTROL_WRITE, &codec::noise_write_payload(mode))?;
241 codec::check_write_reply(&reply, "the noise-control change")?;
242
243 let wanted = codec::noise_write_mask(mode);
244 self.read_back(
245 READ_BACK,
246 codec::NOISE_CONTROL,
247 &codec::NOISE_MODE_QUERY,
248 |reply| {
249 Ok((
250 codec::decode_noise_mask(reply)? == wanted,
251 codec::decode_noise_mode(reply)?,
252 ))
253 },
254 |now| {
255 let wanted = match mode {
256 NoiseMode::Anc(level) => format!("anc ({})", level.name()),
257 _ => mode.name().to_owned(),
258 };
259 let reported = match &now.level {
260 Some(level) => format!("{} ({level})", now.mode),
261 None => now.mode,
262 };
263 format!("the buds accepted noise control {wanted}, but still report {reported}")
264 },
265 )
266 }
267
268 /// Built-in presets only; custom EQs come with W11. Spec: realme_t310.md#eq-write
269 fn set_eq(&mut self, preset: &str) -> Result<String> {
270 let Some(id) = codec::eq_id(preset) else {
271 return Err(Error::Usage(format!(
272 "no EQ preset called {preset}; these buds have: {}",
273 codec::eq_names().join(", ")
274 )));
275 };
276 if !self.commands.allows(codec::EQ_SELECT) || !self.commands.allows(codec::EQ_PRESET) {
277 return Err(Error::Unsupported("these buds do not offer EQ presets".to_owned()));
278 }
279 let reply = self.link.request(codec::EQ_SELECT, &[id])?;
280 codec::check_write_reply(&reply, "the EQ change")?;
281
282 self.read_back(
283 READ_BACK,
284 codec::EQ_PRESET,
285 &[],
286 |reply| {
287 let now = codec::decode_eq(reply)?;
288 Ok((now == preset, now))
289 },
290 |now| format!("the buds accepted EQ {preset}, but still report {now}"),
291 )
292 }
293
294 /// Spec: realme_t310.md#switch-write, #write-read-back
295 fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> {
296 let Some(id) = codec::switch_id(key) else {
297 return Err(Error::Unsupported(format!("these buds have no switch called {key}")));
298 };
299 let Some(&(_, timing)) = WRITABLE_SWITCHES.iter().find(|(k, _)| *k == key) else {
300 return Err(Error::Unsupported(format!("changing {key} is not supported yet")));
301 };
302 if !self.commands.allows(codec::SWITCH_WRITE) || !self.commands.allows(codec::SWITCHES) {
303 return Err(Error::Unsupported("these buds do not offer switch settings".to_owned()));
304 }
305 let reply = self
306 .link
307 .request(codec::SWITCH_WRITE, &codec::switch_write_payload(id, on))?;
308 codec::check_write_reply(&reply, "the switch change")?;
309
310 self.read_back(
311 timing,
312 codec::SWITCHES,
313 &codec::SWITCH_QUERY,
314 |reply| {
315 let switch = codec::decode_switches(reply)?
316 .into_iter()
317 .find(|s| s.key == key)
318 .ok_or_else(|| Error::Protocol(format!("the switch reply has no {key}")))?;
319 Ok((switch.on == on, switch))
320 },
321 |now| {
322 format!(
323 "the buds accepted {} {}, but still report {}",
324 now.label,
325 on_off(on),
326 on_off(now.on)
327 )
328 },
329 )
330 }
331
332 fn touch_controls(&mut self) -> Result<TouchControls> {
333 if !self.commands.allows(codec::GESTURES) {
334 return Err(Error::Unsupported(
335 "these buds do not offer touch-control settings".to_owned(),
336 ));
337 }
338 let gestures = codec::decode_gestures(&self.link.request(codec::GESTURES, &[])?)?;
339 let mut reads = Reads::default();
340 let hold_cycle = self.read(
341 &mut reads,
342 "hold cycle",
343 codec::NOISE_CONTROL,
344 &codec::HOLD_CYCLE_QUERY,
345 codec::decode_hold_cycle,
346 )?;
347 Ok(TouchControls {
348 gestures,
349 hold_cycle,
350 warnings: reads.warnings,
351 })
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::status::{AncLevel, Charge};
359 use crate::transport::mock::{Script, Step, hex};
360
361 fn tx(cmd: u16, seq: u8, payload: &str) -> Step {
362 Step::Tx(frame::encode(cmd, seq, &hex(payload)).unwrap())
363 }
364
365 fn rx(cmd: u16, seq: u8, payload: &str) -> Step {
366 Step::Rx(frame::encode(cmd, seq, &hex(payload)).unwrap())
367 }
368
369 fn fixed_clock() -> u32 {
370 1_750_000_000
371 }
372
373 fn open(script: &Script) -> Result<T310Session> {
374 T310Session::open(Link::new(script.transport(), fixed_clock))
375 }
376
377 /// The handshake of the owner's T310 (HW1); seq 01-04 are used up.
378 fn handshake() -> Vec<Step> {
379 vec![
380 tx(0x0100, 0x01, ""),
381 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
382 tx(0x0103, 0x02, ""),
383 rx(0x8103, 0x02, "00 12 3C 06"),
384 tx(0x0200, 0x03, ""),
385 rx(0x8200, 0x03, "00 09 01 02 03 04 05 06 0D F1 F4"),
386 tx(0x0205, 0x04, "04 01 02 03 04"),
387 rx(0x8205, 0x04, "00 04 01 00 02 00 03 00 04 00"),
388 ]
389 }
390
391 #[test]
392 fn full_status() {
393 let mut steps = handshake();
394 let mut firmware = String::from("00 03");
395 for byte in b"1,2,120,2,2,120,3,2,105" {
396 firmware.push_str(&format!(" {byte:02X}"));
397 }
398 steps.extend([
399 tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"),
400 rx(0x810D, 0x05, "00 06 05 00 06 00 11 01 1A 00 1B 01 1D 01"),
401 tx(0x010C, 0x06, "01 01"),
402 rx(0x810C, 0x06, "00 01 01 08"),
403 tx(0x0106, 0x07, ""),
404 rx(0x8106, 0x07, "00 03 01 50 02 E4 03 3C"),
405 tx(0x010F, 0x08, ""),
406 rx(0x810F, 0x08, "00 01"),
407 tx(0x0114, 0x09, ""),
408 rx(0x8114, 0x09, "00 02"),
409 tx(0x0105, 0x0A, ""),
410 rx(0x8105, 0x0A, &firmware),
411 ]);
412 let script = Script::new(steps);
413 let status = open(&script).unwrap().status().unwrap();
414 script.assert_done();
415
416 assert_eq!(
417 status.battery.left,
418 Some(Charge {
419 level: 80,
420 charging: false
421 })
422 );
423 assert_eq!(
424 status.battery.right,
425 Some(Charge {
426 level: 100,
427 charging: true
428 })
429 );
430 assert_eq!(
431 status.battery.case,
432 Some(Charge {
433 level: 60,
434 charging: false
435 })
436 );
437 let noise = status.noise_control.unwrap();
438 assert_eq!((noise.mode.as_str(), noise.level.as_deref()), ("anc", Some("deep")));
439 assert_eq!(status.eq.as_deref(), Some("deep-bass"));
440 assert_eq!(status.bass_level, None);
441 assert_eq!(status.codec.as_deref(), Some("AAC"));
442 assert_eq!(status.firmware.as_deref(), Some("120.120.105"));
443 let game = status.switches.iter().find(|s| s.key == "game").unwrap();
444 assert!(!game.on);
445 assert_eq!(status.switches.len(), 5);
446 assert!(status.warnings.is_empty());
447 }
448
449 #[test]
450 fn answers_the_time_request_during_the_settle() {
451 let mut steps = vec![
452 rx(0x0500, 0x01, ""),
453 Step::Tx(hex("AA 0C 00 00 00 85 01 05 00 00 80 E1 4E 68")),
454 ];
455 steps.extend(handshake());
456 // No 0x8205 ack this time: it may never arrive, and `open` does not wait for it.
457 // Spec: realme_t310.md#subscribe
458 steps.pop();
459 let script = Script::new(steps);
460 open(&script).unwrap();
461 script.assert_done();
462 }
463
464 #[test]
465 fn a_silent_handshake_reports_a_busy_channel() {
466 let script = Script::new(vec![tx(0x0100, 0x01, "")]);
467 assert!(matches!(open(&script), Err(Error::ChannelBusy(_))));
468 }
469
470 #[test]
471 fn other_models_are_refused() {
472 let script = Script::new(vec![
473 tx(0x0100, 0x01, ""),
474 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
475 tx(0x0103, 0x02, ""),
476 rx(0x8103, 0x02, "00 14 54 06"),
477 ]);
478 match open(&script) {
479 Err(Error::WrongModel { found, .. }) => assert_eq!(found, "065414"),
480 other => panic!("expected WrongModel, got {:?}", other.err()),
481 }
482 }
483
484 #[test]
485 fn commands_missing_from_the_bitmap_are_never_sent() {
486 // A bitmap with no bits: only always-allowed commands go out, and no subscription.
487 let script = Script::new(vec![
488 tx(0x0100, 0x01, ""),
489 rx(0x8100, 0x01, "00"),
490 tx(0x0103, 0x02, ""),
491 rx(0x8103, 0x02, "00 12 3C 06"),
492 tx(0x0200, 0x03, ""),
493 rx(0x8200, 0x03, "00 02 01 02"),
494 tx(0x010D, 0x04, "06 05 06 11 1A 1B 1D"),
495 rx(0x810D, 0x04, "00 01 06 01"),
496 tx(0x0106, 0x05, ""),
497 // no battery reply: becomes a warning
498 ]);
499 let status = open(&script).unwrap().status().unwrap();
500 script.assert_done();
501 assert_eq!(status.switches.len(), 1);
502 assert!(status.noise_control.is_none() && status.firmware.is_none());
503 assert_eq!(status.warnings.len(), 1);
504 assert!(status.warnings.first().unwrap().starts_with("battery"));
505 }
506
507 #[test]
508 fn a_lost_link_stops_the_status() {
509 let mut steps = handshake();
510 steps.extend([tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"), Step::Close]);
511 let script = Script::new(steps);
512 assert!(matches!(open(&script).unwrap().status(), Err(Error::NotConnected(_))));
513 }
514
515 #[test]
516 fn touch_controls_and_hold_cycle() {
517 let mut steps = handshake();
518 steps.extend([
519 tx(0x0108, 0x05, ""),
520 rx(0x8108, 0x05, "00 02 01 01 02 01 02 01 04 08"),
521 tx(0x010C, 0x06, "02 01"),
522 rx(0x810C, 0x06, "00 02 01 0A"),
523 ]);
524 let script = Script::new(steps);
525 let touch = open(&script).unwrap().touch_controls().unwrap();
526 script.assert_done();
527 assert_eq!(touch.gestures.len(), 2);
528 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
529 }
530
531 /// The owner's `budctl --debug touch` (HW1), byte for byte. The buds' own frames (slice
532 /// byte 08) arrive glued to replies and need no answer.
533 #[test]
534 fn replays_the_hw1_touch_session() {
535 let script = Script::new(vec![
536 Step::Tx(hex("AA 07 00 00 00 01 01 00 00")),
537 Step::Rx(hex(
538 "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",
539 )),
540 Step::Tx(hex("AA 07 00 00 03 01 02 00 00")),
541 Step::Rx(hex("AA 0B 00 00 03 81 02 04 00 00 12 3C 06")),
542 Step::Tx(hex("AA 07 00 00 00 02 03 00 00")),
543 Step::Rx(hex("AA 12 00 00 00 82 03 0B 00 00 09 01 02 03 04 05 06 0D F1 F4")),
544 Step::Tx(hex("AA 0C 00 00 05 02 04 05 00 04 01 02 03 04")),
545 Step::Rx(hex(
546 "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",
547 )),
548 Step::Tx(hex("AA 07 00 00 08 01 05 00 00")),
549 Step::Rx(hex(
550 "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",
551 )),
552 Step::Tx(hex("AA 09 00 00 0C 01 06 02 00 02 01")),
553 Step::Rx(hex("AA 0B 00 00 0C 81 06 04 00 00 02 01 0A")),
554 ]);
555 let touch = open(&script).unwrap().touch_controls().unwrap();
556 script.assert_done();
557 let table: Vec<(&str, &str, &str)> = touch
558 .gestures
559 .iter()
560 .map(|g| (g.side.as_str(), g.gesture.as_str(), g.action.as_str()))
561 .collect();
562 assert_eq!(
563 table,
564 [
565 ("left", "double-tap", "play-pause"),
566 ("left", "triple-tap", "next"),
567 ("left", "hold", "noise-cycle"),
568 ("right", "double-tap", "play-pause"),
569 ("right", "triple-tap", "next"),
570 ("right", "hold", "noise-cycle"),
571 ]
572 );
573 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
574 assert!(touch.warnings.is_empty());
575 }
576
577 #[test]
578 fn sets_noise_control_and_reads_it_back() {
579 let mut steps = handshake();
580 steps.extend([
581 tx(0x0404, 0x05, "01 01 02"),
582 rx(0x8404, 0x05, "00"),
583 // The buds announce the change; it needs no answer.
584 rx(0x0204, 0x10, "03 01 01 02"),
585 tx(0x010C, 0x06, "01 01"),
586 rx(0x810C, 0x06, "00 01 01 02"),
587 ]);
588 let script = Script::new(steps);
589 let now = open(&script)
590 .unwrap()
591 .set_noise_control(NoiseMode::Transparency)
592 .unwrap();
593 script.assert_done();
594 assert_eq!((now.mode.as_str(), now.level), ("transparency", None));
595 }
596
597 #[test]
598 fn reads_back_again_until_the_change_shows() {
599 let mut steps = handshake();
600 steps.extend([
601 tx(0x0404, 0x05, "01 01 01"),
602 rx(0x8404, 0x05, "00"),
603 tx(0x010C, 0x06, "01 01"),
604 rx(0x810C, 0x06, "00 01 01 08"),
605 tx(0x010C, 0x07, "01 01"),
606 rx(0x810C, 0x07, "00 01 01 01"),
607 ]);
608 let script = Script::new(steps);
609 let now = open(&script).unwrap().set_noise_control(NoiseMode::Off).unwrap();
610 script.assert_done();
611 assert_eq!(now.mode, "off");
612 }
613
614 #[test]
615 fn sets_a_noise_cancelling_level() {
616 let mut steps = handshake();
617 steps.extend([
618 tx(0x0404, 0x05, "01 01 04"),
619 rx(0x8404, 0x05, "00"),
620 tx(0x010C, 0x06, "01 01"),
621 rx(0x810C, 0x06, "00 01 01 04"),
622 ]);
623 let script = Script::new(steps);
624 let now = open(&script)
625 .unwrap()
626 .set_noise_control(NoiseMode::Anc(AncLevel::Light))
627 .unwrap();
628 script.assert_done();
629 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
630 }
631
632 #[test]
633 fn a_rejected_noise_control_change_is_not_read_back() {
634 let mut steps = handshake();
635 steps.extend([tx(0x0404, 0x05, "01 01 08"), rx(0x8404, 0x05, "0E")]);
636 let script = Script::new(steps);
637 let result = open(&script).unwrap().set_noise_control(NoiseMode::Anc(AncLevel::Deep));
638 script.assert_done();
639 assert!(matches!(result, Err(Error::Rejected { status: 0x0E, .. })));
640 }
641
642 #[test]
643 fn noise_control_is_not_written_without_its_bitmap_bit() {
644 let script = Script::new(vec![
645 tx(0x0100, 0x01, ""),
646 rx(0x8100, 0x01, "00"),
647 tx(0x0103, 0x02, ""),
648 rx(0x8103, 0x02, "00 12 3C 06"),
649 tx(0x0200, 0x03, ""),
650 rx(0x8200, 0x03, "00 00"),
651 ]);
652 let result = open(&script).unwrap().set_noise_control(NoiseMode::Off);
653 script.assert_done();
654 assert!(matches!(result, Err(Error::Unsupported(_))));
655 }
656
657 #[test]
658 fn sets_game_mode_and_reads_it_back() {
659 let mut steps = handshake();
660 steps.extend([
661 tx(0x0403, 0x05, "06 01"),
662 rx(0x8403, 0x05, "00"),
663 // Still the old value: budctl reads again.
664 tx(0x010D, 0x06, "06 05 06 11 1A 1B 1D"),
665 rx(0x810D, 0x06, "00 06 05 00 06 00 11 00 1A 01 1B 00 1D 00"),
666 tx(0x010D, 0x07, "06 05 06 11 1A 1B 1D"),
667 rx(0x810D, 0x07, "00 06 05 00 06 01 11 00 1A 01 1B 00 1D 00"),
668 ]);
669 let script = Script::new(steps);
670 let game = open(&script).unwrap().set_switch("game", true).unwrap();
671 script.assert_done();
672 assert_eq!((game.key, game.on), ("game", true));
673 }
674
675 #[test]
676 fn switches_not_yet_verified_are_never_written() {
677 // Nothing is sent after the handshake, so the subscribe ack is left out.
678 let mut steps = handshake();
679 steps.pop();
680 let script = Script::new(steps);
681 let mut session = open(&script).unwrap();
682 // Dual connection is the last switch (W5d).
683 assert!(matches!(session.set_switch("dual", false), Err(Error::Unsupported(_))));
684 assert!(matches!(session.set_switch("turbo", true), Err(Error::Unsupported(_))));
685 script.assert_done();
686 }
687
688 #[test]
689 fn sets_bass_boost_and_reads_it_back_at_once() {
690 let mut steps = handshake();
691 steps.extend([
692 tx(0x0403, 0x05, "1D 01"),
693 rx(0x8403, 0x05, "00"),
694 tx(0x010D, 0x06, "06 05 06 11 1A 1B 1D"),
695 rx(0x810D, 0x06, "00 06 05 00 06 00 11 00 1A 01 1B 00 1D 01"),
696 ]);
697 let script = Script::new(steps);
698 let now = open(&script).unwrap().set_switch("bass-boost", true).unwrap();
699 script.assert_done();
700 assert_eq!((now.key, now.on), ("bass-boost", true));
701 }
702
703 #[test]
704 fn a_switch_the_buds_lack_is_rejected() {
705 let mut steps = handshake();
706 steps.extend([tx(0x0403, 0x05, "06 00"), rx(0x8403, 0x05, "01")]);
707 let script = Script::new(steps);
708 let result = open(&script).unwrap().set_switch("game", false);
709 script.assert_done();
710 assert!(matches!(result, Err(Error::Rejected { status: 0x01, .. })));
711 }
712
713 #[test]
714 fn selects_an_eq_preset_and_reads_it_back() {
715 let mut steps = handshake();
716 steps.extend([
717 tx(0x0406, 0x05, "01"),
718 rx(0x8406, 0x05, "00"),
719 tx(0x010F, 0x06, ""),
720 rx(0x810F, 0x06, "00 01"),
721 ]);
722 let script = Script::new(steps);
723 let now = open(&script).unwrap().set_eq("deep-bass").unwrap();
724 script.assert_done();
725 assert_eq!(now, "deep-bass");
726 }
727
728 #[test]
729 fn an_unknown_eq_preset_is_a_usage_error() {
730 let mut steps = handshake();
731 steps.pop();
732 let script = Script::new(steps);
733 let result = open(&script).unwrap().set_eq("loudness");
734 script.assert_done();
735 match result {
736 Err(Error::Usage(message)) => assert!(message.contains("pure-raw, deep-bass, pure-vocals, pure-bass")),
737 other => panic!("expected a usage error, got {other:?}"),
738 }
739 }
740}