Krolyxon
Commit 904ab9a

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

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