Krolyxon
Commit 350d95a

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

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