Krolyxon
Commit 47dc78a

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

1256 lines · 46.0 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 // The choices only when the buds take writes: the GUI offers what it shows here.
454 let writable = |cmd| self.commands.allows(cmd);
455 let choices = if writable(codec::GESTURE_WRITE) {
456 codec::touch_choices()
457 } else {
458 Vec::new()
459 };
460 let cycle_modes = if hold_cycle.is_some() && writable(codec::NOISE_CONTROL_WRITE) {
461 codec::cycle_modes()
462 } else {
463 Vec::new()
464 };
465 Ok(TouchControls {
466 gestures,
467 hold_cycle,
468 choices,
469 cycle_modes,
470 warnings: reads.warnings,
471 })
472 }
473
474 /// Spec: realme_t310.md#hold-cycle-write, #write-read-back
475 fn set_hold_cycle(&mut self, modes: &[&str]) -> Result<Vec<String>> {
476 let mask = codec::hold_cycle_mask(modes)?;
477 if !self.commands.allows(codec::NOISE_CONTROL_WRITE) || !self.commands.allows(codec::NOISE_CONTROL) {
478 return Err(Error::Unsupported(
479 "these buds do not offer noise-control settings".to_owned(),
480 ));
481 }
482 let reply = self
483 .link
484 .request(codec::NOISE_CONTROL_WRITE, &codec::hold_cycle_write_payload(mask))?;
485 codec::check_write_reply(&reply, "the hold-cycle change")?;
486
487 self.read_back(
488 READ_BACK,
489 codec::NOISE_CONTROL,
490 &codec::HOLD_CYCLE_QUERY,
491 |reply| {
492 Ok((
493 codec::decode_hold_cycle_mask(reply)? == mask,
494 codec::decode_hold_cycle(reply)?,
495 ))
496 },
497 |now| {
498 format!(
499 "the buds accepted the hold cycle {}, but still report {}",
500 modes.join(" → "),
501 now.join(" → ")
502 )
503 },
504 )
505 }
506
507 /// Spec: realme_t310.md#gesture-write, #gesture-choices
508 fn set_touch(&mut self, side: &str, gesture: &str, action: &str, dry_run: bool) -> Result<TouchChange> {
509 let target = codec::touch_target(side, gesture, action)?;
510 if !self.commands.allows(codec::GESTURE_WRITE) || !self.commands.allows(codec::GESTURES) {
511 return Err(Error::Unsupported(
512 "these buds do not offer touch-control settings".to_owned(),
513 ));
514 }
515 // The write carries the whole table, so it starts from the table read just now.
516 let before = codec::decode_gesture_entries(&self.link.request(codec::GESTURES, &[])?)?;
517 let after = codec::retarget_gestures(&before, &target)?;
518 let changes: Vec<GestureChange> = before
519 .iter()
520 .zip(&after)
521 .filter(|(old, new)| old != new)
522 .map(|(old, new)| {
523 let (old, new) = (codec::name_gesture(old), codec::name_gesture(new));
524 GestureChange {
525 side: new.side,
526 gesture: new.gesture,
527 from: old.action,
528 to: new.action,
529 }
530 })
531 .collect();
532 let planned: Vec<_> = after.iter().map(codec::name_gesture).collect();
533 if changes.is_empty() {
534 return Ok(TouchChange {
535 changes,
536 write: None,
537 sent: false,
538 gestures: planned,
539 });
540 }
541 let write = RawWrite {
542 command: codec::GESTURE_WRITE,
543 payload: codec::gesture_write_payload(&after)?,
544 };
545 if dry_run {
546 return Ok(TouchChange {
547 changes,
548 write: Some(write),
549 sent: false,
550 gestures: planned,
551 });
552 }
553
554 let reply = self.link.request(write.command, &write.payload)?;
555 codec::check_write_reply(&reply, "the touch-control change")?;
556 let gestures = self.read_back(
557 TOUCH_READ_BACK,
558 codec::GESTURES,
559 &[],
560 |reply| {
561 let now = codec::decode_gesture_entries(reply)?;
562 // Applied when retargeting the table read back would change nothing.
563 let applied = codec::retarget_gestures(&now, &target).is_ok_and(|again| again == now);
564 Ok((applied, now))
565 },
566 |now| {
567 let still: Vec<String> = changes
568 .iter()
569 .filter_map(|change| {
570 now.iter()
571 .map(codec::name_gesture)
572 .find(|g| g.group.is_none() && g.side == change.side && g.gesture == change.gesture)
573 })
574 .filter(|g| g.action != action)
575 .map(|g| format!("{} {} {}", g.side, g.gesture, g.action))
576 .collect();
577 if still.is_empty() {
578 format!("the buds accepted {side} {gesture} {action}, but the table read back does not show it")
579 } else {
580 format!(
581 "the buds accepted {side} {gesture} {action}, but still report {}",
582 still.join(", ")
583 )
584 }
585 },
586 )?;
587 Ok(TouchChange {
588 changes,
589 write: Some(write),
590 sent: true,
591 gestures: gestures.iter().map(codec::name_gesture).collect(),
592 })
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use crate::status::{AncLevel, Charge};
600 use crate::transport::mock::{Script, Step, hex};
601
602 fn tx(cmd: u16, seq: u8, payload: &str) -> Step {
603 Step::Tx(frame::encode(cmd, seq, &hex(payload)).unwrap())
604 }
605
606 fn rx(cmd: u16, seq: u8, payload: &str) -> Step {
607 Step::Rx(frame::encode(cmd, seq, &hex(payload)).unwrap())
608 }
609
610 fn fixed_clock() -> u32 {
611 1_750_000_000
612 }
613
614 fn open(script: &Script) -> Result<T310Session> {
615 T310Session::open(Link::new(script.transport(), fixed_clock))
616 }
617
618 /// The handshake of the owner's T310 (HW1); seq 01-04 are used up.
619 fn handshake() -> Vec<Step> {
620 vec![
621 tx(0x0100, 0x01, ""),
622 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
623 tx(0x0103, 0x02, ""),
624 rx(0x8103, 0x02, "00 12 3C 06"),
625 tx(0x0200, 0x03, ""),
626 rx(0x8200, 0x03, "00 09 01 02 03 04 05 06 0D F1 F4"),
627 tx(0x0205, 0x04, "04 01 02 03 04"),
628 rx(0x8205, 0x04, "00 04 01 00 02 00 03 00 04 00"),
629 ]
630 }
631
632 #[test]
633 fn serve_answers_the_buds_at_once() {
634 let mut steps = handshake();
635 // The time request, at Unix time 1750000000. Spec: realme_t310.md#buds-requests
636 steps.extend([rx(0x0500, 0x02, ""), tx(0x8500, 0x02, "00 80 E1 4E 68")]);
637 let script = Script::new(steps);
638 let mut session = open(&script).unwrap();
639 session.serve().unwrap();
640 script.assert_done();
641
642 let mut steps = handshake();
643 steps.push(Step::Close);
644 let script = Script::new(steps);
645 let mut session = open(&script).unwrap();
646 assert!(matches!(session.serve(), Err(Error::NotConnected(_))));
647 }
648
649 #[test]
650 fn full_status() {
651 let mut steps = handshake();
652 let mut firmware = String::from("00 03");
653 for byte in b"1,2,120,2,2,120,3,2,105" {
654 firmware.push_str(&format!(" {byte:02X}"));
655 }
656 steps.extend([
657 tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"),
658 rx(0x810D, 0x05, "00 06 05 00 06 00 11 01 1A 00 1B 01 1D 01"),
659 tx(0x010C, 0x06, "01 01"),
660 rx(0x810C, 0x06, "00 01 01 08"),
661 tx(0x0106, 0x07, ""),
662 rx(0x8106, 0x07, "00 03 01 50 02 E4 03 3C"),
663 tx(0x010F, 0x08, ""),
664 rx(0x810F, 0x08, "00 01"),
665 tx(0x0114, 0x09, ""),
666 rx(0x8114, 0x09, "00 02"),
667 tx(0x0105, 0x0A, ""),
668 rx(0x8105, 0x0A, &firmware),
669 ]);
670 let script = Script::new(steps);
671 let status = open(&script).unwrap().status().unwrap();
672 script.assert_done();
673
674 assert_eq!(
675 status.battery.left,
676 Some(Charge {
677 level: 80,
678 charging: false
679 })
680 );
681 assert_eq!(
682 status.battery.right,
683 Some(Charge {
684 level: 100,
685 charging: true
686 })
687 );
688 assert_eq!(
689 status.battery.case,
690 Some(Charge {
691 level: 60,
692 charging: false
693 })
694 );
695 let noise = status.noise_control.unwrap();
696 assert_eq!((noise.mode.as_str(), noise.level.as_deref()), ("anc", Some("deep")));
697 assert_eq!(status.eq.as_deref(), Some("deep-bass"));
698 assert_eq!(status.bass_level, None);
699 assert_eq!(status.codec.as_deref(), Some("AAC"));
700 assert_eq!(status.firmware.as_deref(), Some("120.120.105"));
701 let game = status.switches.iter().find(|s| s.key == "game").unwrap();
702 assert!(!game.on);
703 assert_eq!(status.switches.len(), 5);
704 assert!(status.warnings.is_empty());
705 }
706
707 #[test]
708 fn answers_the_time_request_during_the_settle() {
709 let mut steps = vec![
710 rx(0x0500, 0x01, ""),
711 Step::Tx(hex("AA 0C 00 00 00 85 01 05 00 00 80 E1 4E 68")),
712 ];
713 steps.extend(handshake());
714 // No 0x8205 ack this time: it may never arrive, and `open` does not wait for it.
715 // Spec: realme_t310.md#subscribe
716 steps.pop();
717 let script = Script::new(steps);
718 open(&script).unwrap();
719 script.assert_done();
720 }
721
722 #[test]
723 fn a_silent_handshake_reports_a_busy_channel() {
724 let script = Script::new(vec![tx(0x0100, 0x01, "")]);
725 assert!(matches!(open(&script), Err(Error::ChannelBusy(_))));
726 }
727
728 #[test]
729 fn other_models_are_refused() {
730 let script = Script::new(vec![
731 tx(0x0100, 0x01, ""),
732 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
733 tx(0x0103, 0x02, ""),
734 rx(0x8103, 0x02, "00 14 54 06"),
735 ]);
736 match open(&script) {
737 Err(Error::WrongModel { found, .. }) => assert_eq!(found, "065414"),
738 other => panic!("expected WrongModel, got {:?}", other.err()),
739 }
740 }
741
742 #[test]
743 fn commands_missing_from_the_bitmap_are_never_sent() {
744 // A bitmap with no bits: only always-allowed commands go out, and no subscription.
745 let script = Script::new(vec![
746 tx(0x0100, 0x01, ""),
747 rx(0x8100, 0x01, "00"),
748 tx(0x0103, 0x02, ""),
749 rx(0x8103, 0x02, "00 12 3C 06"),
750 tx(0x0200, 0x03, ""),
751 rx(0x8200, 0x03, "00 02 01 02"),
752 tx(0x010D, 0x04, "06 05 06 11 1A 1B 1D"),
753 rx(0x810D, 0x04, "00 01 06 01"),
754 tx(0x0106, 0x05, ""),
755 // no battery reply: becomes a warning
756 ]);
757 let status = open(&script).unwrap().status().unwrap();
758 script.assert_done();
759 assert_eq!(status.switches.len(), 1);
760 assert!(status.noise_control.is_none() && status.firmware.is_none());
761 assert_eq!(status.warnings.len(), 1);
762 assert!(status.warnings.first().unwrap().starts_with("battery"));
763 }
764
765 #[test]
766 fn a_lost_link_stops_the_status() {
767 let mut steps = handshake();
768 steps.extend([tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"), Step::Close]);
769 let script = Script::new(steps);
770 assert!(matches!(open(&script).unwrap().status(), Err(Error::NotConnected(_))));
771 }
772
773 #[test]
774 fn touch_controls_and_hold_cycle() {
775 let mut steps = handshake();
776 steps.extend([
777 tx(0x0108, 0x05, ""),
778 rx(0x8108, 0x05, "00 02 01 01 02 01 02 01 04 08"),
779 tx(0x010C, 0x06, "02 01"),
780 rx(0x810C, 0x06, "00 02 01 0A"),
781 ]);
782 let script = Script::new(steps);
783 let touch = open(&script).unwrap().touch_controls().unwrap();
784 script.assert_done();
785 assert_eq!(touch.gestures.len(), 2);
786 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
787 let gestures: Vec<&str> = touch.choices.iter().map(|c| c.gesture.as_str()).collect();
788 assert_eq!(gestures, ["double-tap", "triple-tap", "hold"]);
789 let hold = touch.choices.iter().find(|c| c.gesture == "hold").unwrap();
790 assert_eq!(hold.actions, ["none", "noise-cycle", "voice-assistant", "game-mode"]);
791 assert_eq!(touch.cycle_modes, ["off", "transparency", "anc"]);
792 }
793
794 /// The owner's `budctl --debug touch` (HW1), byte for byte. The buds' own frames (slice
795 /// byte 08) arrive glued to replies and need no answer.
796 #[test]
797 fn replays_the_hw1_touch_session() {
798 let script = Script::new(vec![
799 Step::Tx(hex("AA 07 00 00 00 01 01 00 00")),
800 Step::Rx(hex(
801 "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",
802 )),
803 Step::Tx(hex("AA 07 00 00 03 01 02 00 00")),
804 Step::Rx(hex("AA 0B 00 00 03 81 02 04 00 00 12 3C 06")),
805 Step::Tx(hex("AA 07 00 00 00 02 03 00 00")),
806 Step::Rx(hex("AA 12 00 00 00 82 03 0B 00 00 09 01 02 03 04 05 06 0D F1 F4")),
807 Step::Tx(hex("AA 0C 00 00 05 02 04 05 00 04 01 02 03 04")),
808 Step::Rx(hex(
809 "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",
810 )),
811 Step::Tx(hex("AA 07 00 00 08 01 05 00 00")),
812 Step::Rx(hex(
813 "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",
814 )),
815 Step::Tx(hex("AA 09 00 00 0C 01 06 02 00 02 01")),
816 Step::Rx(hex("AA 0B 00 00 0C 81 06 04 00 00 02 01 0A")),
817 ]);
818 let touch = open(&script).unwrap().touch_controls().unwrap();
819 script.assert_done();
820 let table: Vec<(&str, &str, &str)> = touch
821 .gestures
822 .iter()
823 .map(|g| (g.side.as_str(), g.gesture.as_str(), g.action.as_str()))
824 .collect();
825 assert_eq!(
826 table,
827 [
828 ("left", "double-tap", "play-pause"),
829 ("left", "triple-tap", "next"),
830 ("left", "hold", "noise-cycle"),
831 ("right", "double-tap", "play-pause"),
832 ("right", "triple-tap", "next"),
833 ("right", "hold", "noise-cycle"),
834 ]
835 );
836 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
837 assert!(touch.warnings.is_empty());
838 }
839
840 #[test]
841 fn sets_noise_control_and_reads_it_back() {
842 let mut steps = handshake();
843 steps.extend([
844 tx(0x0404, 0x05, "01 01 02"),
845 rx(0x8404, 0x05, "00"),
846 // The buds announce the change; it needs no answer.
847 rx(0x0204, 0x10, "03 01 01 02"),
848 tx(0x010C, 0x06, "01 01"),
849 rx(0x810C, 0x06, "00 01 01 02"),
850 ]);
851 let script = Script::new(steps);
852 let now = open(&script)
853 .unwrap()
854 .set_noise_control(NoiseMode::Transparency)
855 .unwrap();
856 script.assert_done();
857 assert_eq!((now.mode.as_str(), now.level), ("transparency", None));
858 }
859
860 #[test]
861 fn reads_back_again_until_the_change_shows() {
862 let mut steps = handshake();
863 steps.extend([
864 tx(0x0404, 0x05, "01 01 01"),
865 rx(0x8404, 0x05, "00"),
866 tx(0x010C, 0x06, "01 01"),
867 rx(0x810C, 0x06, "00 01 01 08"),
868 tx(0x010C, 0x07, "01 01"),
869 rx(0x810C, 0x07, "00 01 01 01"),
870 ]);
871 let script = Script::new(steps);
872 let now = open(&script).unwrap().set_noise_control(NoiseMode::Off).unwrap();
873 script.assert_done();
874 assert_eq!(now.mode, "off");
875 }
876
877 #[test]
878 fn sets_a_noise_cancelling_level() {
879 let mut steps = handshake();
880 steps.extend([
881 tx(0x0404, 0x05, "01 01 04"),
882 rx(0x8404, 0x05, "00"),
883 tx(0x010C, 0x06, "01 01"),
884 rx(0x810C, 0x06, "00 01 01 04"),
885 ]);
886 let script = Script::new(steps);
887 let now = open(&script)
888 .unwrap()
889 .set_noise_control(NoiseMode::Anc(AncLevel::Light))
890 .unwrap();
891 script.assert_done();
892 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
893 }
894
895 #[test]
896 fn a_rejected_noise_control_change_is_not_read_back() {
897 let mut steps = handshake();
898 steps.extend([tx(0x0404, 0x05, "01 01 08"), rx(0x8404, 0x05, "0E")]);
899 let script = Script::new(steps);
900 let result = open(&script).unwrap().set_noise_control(NoiseMode::Anc(AncLevel::Deep));
901 script.assert_done();
902 assert!(matches!(result, Err(Error::Rejected { status: 0x0E, .. })));
903 }
904
905 #[test]
906 fn noise_control_is_not_written_without_its_bitmap_bit() {
907 let script = Script::new(vec![
908 tx(0x0100, 0x01, ""),
909 rx(0x8100, 0x01, "00"),
910 tx(0x0103, 0x02, ""),
911 rx(0x8103, 0x02, "00 12 3C 06"),
912 tx(0x0200, 0x03, ""),
913 rx(0x8200, 0x03, "00 00"),
914 ]);
915 let result = open(&script).unwrap().set_noise_control(NoiseMode::Off);
916 script.assert_done();
917 assert!(matches!(result, Err(Error::Unsupported(_))));
918 }
919
920 #[test]
921 fn sets_game_mode_and_reads_it_back() {
922 let mut steps = handshake();
923 steps.extend([
924 tx(0x0403, 0x05, "06 01"),
925 rx(0x8403, 0x05, "00"),
926 // Still the old value: budctl reads again.
927 tx(0x010D, 0x06, "06 05 06 11 1A 1B 1D"),
928 rx(0x810D, 0x06, "00 06 05 00 06 00 11 00 1A 01 1B 00 1D 00"),
929 tx(0x010D, 0x07, "06 05 06 11 1A 1B 1D"),
930 rx(0x810D, 0x07, "00 06 05 00 06 01 11 00 1A 01 1B 00 1D 00"),
931 ]);
932 let script = Script::new(steps);
933 let game = open(&script).unwrap().set_switch("game", true).unwrap();
934 script.assert_done();
935 assert_eq!((game.key, game.on), ("game", true));
936 }
937
938 #[test]
939 fn unknown_switches_are_never_written() {
940 // Nothing is sent after the handshake, so the subscribe ack is left out.
941 let mut steps = handshake();
942 steps.pop();
943 let script = Script::new(steps);
944 let mut session = open(&script).unwrap();
945 assert!(matches!(session.set_switch("turbo", true), Err(Error::Unsupported(_))));
946 script.assert_done();
947 }
948
949 #[test]
950 fn sets_bass_boost_and_reads_it_back_at_once() {
951 let mut steps = handshake();
952 steps.extend([
953 tx(0x0403, 0x05, "1D 01"),
954 rx(0x8403, 0x05, "00"),
955 tx(0x010D, 0x06, "06 05 06 11 1A 1B 1D"),
956 rx(0x810D, 0x06, "00 06 05 00 06 00 11 00 1A 01 1B 00 1D 01"),
957 ]);
958 let script = Script::new(steps);
959 let now = open(&script).unwrap().set_switch("bass-boost", true).unwrap();
960 script.assert_done();
961 assert_eq!((now.key, now.on), ("bass-boost", true));
962 }
963
964 #[test]
965 fn a_switch_the_buds_lack_is_rejected() {
966 let mut steps = handshake();
967 steps.extend([tx(0x0403, 0x05, "06 00"), rx(0x8403, 0x05, "01")]);
968 let script = Script::new(steps);
969 let result = open(&script).unwrap().set_switch("game", false);
970 script.assert_done();
971 assert!(matches!(result, Err(Error::Rejected { status: 0x01, .. })));
972 }
973
974 #[test]
975 fn selects_an_eq_preset_and_reads_it_back() {
976 let mut steps = handshake();
977 steps.extend([
978 tx(0x0406, 0x05, "01"),
979 rx(0x8406, 0x05, "00"),
980 tx(0x010F, 0x06, ""),
981 rx(0x810F, 0x06, "00 01"),
982 ]);
983 let script = Script::new(steps);
984 let now = open(&script).unwrap().set_eq("deep-bass").unwrap();
985 script.assert_done();
986 assert_eq!(now, "deep-bass");
987 }
988
989 #[test]
990 fn an_unknown_eq_preset_is_a_usage_error() {
991 let mut steps = handshake();
992 steps.pop();
993 let script = Script::new(steps);
994 let result = open(&script).unwrap().set_eq("loudness");
995 script.assert_done();
996 match result {
997 Err(Error::Usage(message)) => assert!(message.contains("pure-raw, deep-bass, pure-vocals, pure-bass")),
998 other => panic!("expected a usage error, got {other:?}"),
999 }
1000 }
1001
1002 #[test]
1003 fn rings_and_stops() {
1004 let mut steps = handshake();
1005 steps.extend([
1006 tx(0x0400, 0x05, "01"),
1007 rx(0x8400, 0x05, "00"),
1008 tx(0x0400, 0x06, "00"),
1009 rx(0x8400, 0x06, "00"),
1010 ]);
1011 let script = Script::new(steps);
1012 let mut session = open(&script).unwrap();
1013 session.find(true).unwrap();
1014 session.find(false).unwrap();
1015 script.assert_done();
1016 }
1017
1018 #[test]
1019 fn find_is_not_sent_without_its_bitmap_bit() {
1020 // Bits 0-4 only: no bit 5.
1021 let script = Script::new(vec![
1022 tx(0x0100, 0x01, ""),
1023 rx(0x8100, 0x01, "00 1F"),
1024 tx(0x0103, 0x02, ""),
1025 rx(0x8103, 0x02, "00 12 3C 06"),
1026 tx(0x0200, 0x03, ""),
1027 rx(0x8200, 0x03, "00 00"),
1028 ]);
1029 let result = open(&script).unwrap().find(true);
1030 script.assert_done();
1031 assert!(matches!(result, Err(Error::Unsupported(_))));
1032 }
1033
1034 /// The owner's gesture table (HW1) and the same table with the left triple tap on volume up.
1035 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";
1036 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";
1037
1038 #[test]
1039 fn a_touch_dry_run_reads_the_table_and_sends_nothing() {
1040 let mut steps = handshake();
1041 steps.extend([tx(0x0108, 0x05, ""), rx(0x8108, 0x05, HW1_TABLE)]);
1042 let script = Script::new(steps);
1043 let change = open(&script)
1044 .unwrap()
1045 .set_touch("left", "triple-tap", "volume-up", true)
1046 .unwrap();
1047 script.assert_done();
1048 assert!(!change.sent);
1049 let write = change.write.unwrap();
1050 assert_eq!((write.command, write.payload), (0x0401, hex(LEFT_VOLUME_UP)));
1051 assert_eq!(
1052 change.changes,
1053 [GestureChange {
1054 side: "left".to_owned(),
1055 gesture: "triple-tap".to_owned(),
1056 from: "next".to_owned(),
1057 to: "volume-up".to_owned(),
1058 }]
1059 );
1060 let left_triple = change.gestures.get(1).unwrap();
1061 assert_eq!(left_triple.action, "volume-up");
1062 }
1063
1064 #[test]
1065 fn sets_a_gesture_and_reads_the_table_back() {
1066 let mut steps = handshake();
1067 steps.extend([
1068 tx(0x0108, 0x05, ""),
1069 rx(0x8108, 0x05, HW1_TABLE),
1070 tx(0x0401, 0x06, LEFT_VOLUME_UP),
1071 // The owner's ack (W8): 00, then the old table cut to the write's length.
1072 rx(
1073 0x8401,
1074 0x06,
1075 "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",
1076 ),
1077 tx(0x0108, 0x07, ""),
1078 rx(0x8108, 0x07, &format!("00 {LEFT_VOLUME_UP}")),
1079 ]);
1080 let script = Script::new(steps);
1081 let change = open(&script)
1082 .unwrap()
1083 .set_touch("left", "triple-tap", "volume-up", false)
1084 .unwrap();
1085 script.assert_done();
1086 assert!(change.sent);
1087 assert_eq!(change.changes.len(), 1);
1088 assert_eq!(change.gestures.get(1).unwrap().action, "volume-up");
1089 }
1090
1091 #[test]
1092 fn a_gesture_that_already_has_the_action_is_not_written() {
1093 let mut steps = handshake();
1094 steps.extend([tx(0x0108, 0x05, ""), rx(0x8108, 0x05, HW1_TABLE)]);
1095 let script = Script::new(steps);
1096 let change = open(&script)
1097 .unwrap()
1098 .set_touch("both", "hold", "noise-cycle", false)
1099 .unwrap();
1100 script.assert_done();
1101 assert!(change.changes.is_empty() && change.write.is_none() && !change.sent);
1102 }
1103
1104 #[test]
1105 fn a_bad_touch_action_is_refused_before_anything_is_sent() {
1106 let mut steps = handshake();
1107 steps.pop();
1108 let script = Script::new(steps);
1109 let result = open(&script)
1110 .unwrap()
1111 .set_touch("left", "double-tap", "volume-up", false);
1112 script.assert_done();
1113 assert!(matches!(result, Err(Error::Usage(_))));
1114 }
1115
1116 #[test]
1117 fn sets_the_hold_cycle_and_reads_it_back() {
1118 let mut steps = handshake();
1119 steps.extend([
1120 tx(0x0404, 0x05, "02 01 0B"),
1121 rx(0x8404, 0x05, "00"),
1122 tx(0x010C, 0x06, "02 01"),
1123 rx(0x810C, 0x06, "00 02 01 0B"),
1124 ]);
1125 let script = Script::new(steps);
1126 let now = open(&script)
1127 .unwrap()
1128 .set_hold_cycle(&["off", "transparency", "anc"])
1129 .unwrap();
1130 script.assert_done();
1131 assert_eq!(now, ["off", "transparency", "anc"]);
1132 }
1133
1134 #[test]
1135 fn a_one_mode_hold_cycle_is_refused_before_anything_is_sent() {
1136 let mut steps = handshake();
1137 steps.pop();
1138 let script = Script::new(steps);
1139 let result = open(&script).unwrap().set_hold_cycle(&["anc"]);
1140 script.assert_done();
1141 assert!(matches!(result, Err(Error::Usage(_))));
1142 }
1143
1144 /// The custom EQ list of the owner's T310 (W11a): one flat preset, "Custom1", id 04.
1145 const OWNER_CUSTOM_EQS: &str =
1146 "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";
1147
1148 #[test]
1149 fn lists_built_in_and_custom_eq_presets() {
1150 let mut steps = handshake();
1151 steps.extend([
1152 tx(0x010F, 0x05, ""),
1153 rx(0x810F, 0x05, "00 04"),
1154 tx(0x0122, 0x06, ""),
1155 // The owner's T310 (W11a).
1156 rx(0x8122, 0x06, OWNER_CUSTOM_EQS),
1157 ]);
1158 let script = Script::new(steps);
1159 let eq = open(&script).unwrap().eq_presets().unwrap();
1160 script.assert_done();
1161 assert_eq!(eq.current.as_deref(), Some("custom-4"));
1162 let names: Vec<(&str, bool)> = eq.presets.iter().map(|p| (p.name.as_str(), p.custom)).collect();
1163 assert_eq!(
1164 names,
1165 [
1166 ("pure-raw", false),
1167 ("deep-bass", false),
1168 ("pure-vocals", false),
1169 ("pure-bass", false),
1170 ("Custom1", true)
1171 ]
1172 );
1173 let bands: Vec<u16> = eq.presets.last().unwrap().bands.iter().map(|b| b.hz).collect();
1174 assert_eq!(bands, [50, 250, 1000, 4000, 8000, 16000]);
1175 assert!(eq.warnings.is_empty());
1176 }
1177
1178 #[test]
1179 fn sets_the_bass_level() {
1180 let mut steps = handshake();
1181 steps.extend([tx(0x041B, 0x05, "FB 05 FE"), rx(0x841B, 0x05, "00")]);
1182 let script = Script::new(steps);
1183 open(&script).unwrap().set_bass_level(-2).unwrap();
1184 script.assert_done();
1185 }
1186
1187 #[test]
1188 fn a_bass_level_out_of_range_is_refused_before_anything_is_sent() {
1189 let mut steps = handshake();
1190 steps.pop();
1191 let script = Script::new(steps);
1192 let result = open(&script).unwrap().set_bass_level(6);
1193 script.assert_done();
1194 assert!(matches!(result, Err(Error::Usage(_))));
1195 }
1196
1197 #[test]
1198 fn retunes_a_custom_preset_and_reads_it_back() {
1199 let tuned =
1200 "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";
1201 let mut steps = handshake();
1202 steps.extend([
1203 tx(0x0122, 0x05, ""),
1204 rx(0x8122, 0x05, OWNER_CUSTOM_EQS),
1205 tx(
1206 0x0418,
1207 0x06,
1208 "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",
1209 ),
1210 rx(0x8418, 0x06, "00 04"),
1211 tx(0x0122, 0x07, ""),
1212 rx(0x8122, 0x07, tuned),
1213 ]);
1214 let script = Script::new(steps);
1215 let now = open(&script)
1216 .unwrap()
1217 .set_custom_eq("custom-4", &[EqBand { hz: 50, gain: 3 }])
1218 .unwrap();
1219 script.assert_done();
1220 assert_eq!((now.name.as_str(), now.id), ("Custom1", 4));
1221 assert_eq!(now.bands.first(), Some(&EqBand { hz: 50, gain: 3 }));
1222 }
1223
1224 #[test]
1225 fn eq_custom_selects_by_saving_the_preset_unchanged() {
1226 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";
1227 let mut steps = handshake();
1228 steps.extend([
1229 tx(0x0122, 0x05, ""),
1230 rx(0x8122, 0x05, OWNER_CUSTOM_EQS),
1231 tx(0x0418, 0x06, saved),
1232 rx(0x8418, 0x06, "00 04"),
1233 tx(0x0122, 0x07, ""),
1234 rx(0x8122, 0x07, OWNER_CUSTOM_EQS),
1235 ]);
1236 let script = Script::new(steps);
1237 let now = open(&script).unwrap().set_eq("custom-4").unwrap();
1238 script.assert_done();
1239 assert_eq!(now, "custom-4");
1240 }
1241
1242 #[test]
1243 fn a_custom_preset_the_buds_lack_is_never_written() {
1244 let mut steps = handshake();
1245 steps.extend([tx(0x0122, 0x05, ""), rx(0x8122, 0x05, OWNER_CUSTOM_EQS)]);
1246 let script = Script::new(steps);
1247 let result = open(&script)
1248 .unwrap()
1249 .set_custom_eq("custom-5", &[EqBand { hz: 50, gain: 3 }]);
1250 script.assert_done();
1251 match result {
1252 Err(Error::Usage(message)) => assert!(message.ends_with("custom-4")),
1253 other => panic!("expected a usage error, got {other:?}"),
1254 }
1255 }
1256}