Krolyxon
Commit 8ec1f82

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

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