Krolyxon
Commit b1f5115

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

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