Krolyxon
Commit 9dea828

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

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