Krolyxon
Commit 449c3e4

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

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