Krolyxon
Commit a3246e9

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

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