Krolyxon
Commit 9f993c9

Branches

Refs →

budctlcratesbudctl-coresrcdevicesrealme_t310mod.rs

536 lines · 19.1 KB · Rust

1//! realme Buds T310. Spec: docs/protocol/realme_t310.md
2//!
3//! `frame.rs` and `link.rs` hold the protocol family's framing and request/reply logic;
4//! `codec.rs` holds everything specific to the T310.
5
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use crate::bluez::PairedDevice;
9use crate::bt::Uuid;
10use crate::devices::{Device, Session};
11use crate::error::{Error, Kind, Result};
12use crate::status::{NoiseControl, NoiseMode, Status, TouchControls};
13use crate::transport::Transport;
14
15mod codec;
16mod frame;
17mod link;
18
19use codec::Commands;
20use link::Link;
21
22/// After a write is acked: how long to keep reading back, and the pause between reads.
23/// Spec: realme_t310.md#write-read-back
24const READ_BACK_WINDOW: Duration = Duration::from_secs(2);
25/// Spec: realme_t310.md#write-read-back
26const READ_BACK_PAUSE: Duration = Duration::from_millis(300);
27
28/// Spec: realme_t310.md#identity
29const BLUETOOTH_NAME: &str = "realme Buds T310";
30
31/// Control services, in the order to try. Spec: realme_t310.md#transport
32const SERVICES: [Uuid; 2] = [
33 Uuid::from_u128(0x0000_079A_D102_11E1_9B23_0002_5B00_A5A5),
34 Uuid::from_u128(0x0000_1107_D102_11E1_9B23_0002_5B00_A5A5),
35];
36
37/// Quiet time after connecting, before the first request. Spec: realme_t310.md#init
38const SETTLE: Duration = Duration::from_millis(300);
39
40pub struct RealmeT310;
41
42impl Device for RealmeT310 {
43 fn name(&self) -> &'static str {
44 BLUETOOTH_NAME
45 }
46
47 fn matches(&self, device: &PairedDevice) -> bool {
48 device.name == BLUETOOTH_NAME
49 }
50
51 fn rfcomm_uuids(&self) -> &'static [Uuid] {
52 &SERVICES
53 }
54
55 fn open(&self, transport: Box<dyn Transport>) -> Result<Box<dyn Session>> {
56 Ok(Box::new(T310Session::open(Link::new(transport, unix_time))?))
57 }
58}
59
60fn unix_time() -> u32 {
61 let seconds = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
62 u32::try_from(seconds).unwrap_or(u32::MAX)
63}
64
65struct T310Session {
66 link: Link,
67 commands: Commands,
68}
69
70/// Collects what a series of best-effort reads produced.
71#[derive(Default)]
72struct Reads {
73 warnings: Vec<String>,
74 answered: usize,
75}
76
77impl T310Session {
78 /// The session start. Spec: realme_t310.md#init
79 fn open(mut link: Link) -> Result<Self> {
80 link.pump_until(Instant::now() + SETTLE)?;
81
82 let bitmap = link.request(codec::HANDSHAKE, &[]).map_err(|error| match error {
83 // Provisional until checkpoint HW1: a link that connects but stays silent is most
84 // likely held by another app. Spec: realme_t310.md#open
85 Error::Timeout(_) => Error::ChannelBusy("connected, but the buds did not answer the handshake".to_owned()),
86 other => other,
87 })?;
88 let commands = Commands::parse(&bitmap)?;
89
90 let product = codec::decode_product_id(&link.request(codec::PRODUCT_ID, &[])?)?;
91 if product != codec::T310_PRODUCT_ID {
92 return Err(Error::WrongModel {
93 expected: BLUETOOTH_NAME,
94 found: format!("{product:06X}"),
95 });
96 }
97
98 let offered = codec::decode_events_offered(&link.request(codec::EVENTS_OFFERED, &[])?)?;
99 let subscription = codec::subscribe_payload(&offered, &commands);
100 if commands.allows(codec::SUBSCRIBE) && subscription.len() > 1 {
101 // Some buds never acknowledge this, so budctl does not wait. Spec: realme_t310.md#subscribe
102 link.send(codec::SUBSCRIBE, &subscription)?;
103 }
104 Ok(Self { link, commands })
105 }
106
107 /// Read and decode one value, if the buds offer the command. A read the buds answer badly
108 /// (timeout, refusal, garbage) becomes a warning so the rest still shows; a lost link stops
109 /// everything.
110 fn read<T>(
111 &mut self,
112 reads: &mut Reads,
113 what: &str,
114 cmd: u16,
115 payload: &[u8],
116 decode: fn(&[u8]) -> Result<T>,
117 ) -> Result<Option<T>> {
118 if !self.commands.allows(cmd) {
119 return Ok(None);
120 }
121 match self.link.request(cmd, payload).and_then(|reply| decode(&reply)) {
122 Ok(value) => {
123 reads.answered += 1;
124 Ok(Some(value))
125 }
126 Err(error) if error.kind() == Kind::Failed => {
127 reads.warnings.push(format!("{what}: {error}"));
128 Ok(None)
129 }
130 Err(error) => Err(error),
131 }
132 }
133}
134
135impl Session for T310Session {
136 fn status(&mut self) -> Result<Status> {
137 let mut reads = Reads::default();
138 let switches = self.read(
139 &mut reads,
140 "switches",
141 codec::SWITCHES,
142 &codec::SWITCH_QUERY,
143 codec::decode_switches,
144 )?;
145 let noise_control = self.read(
146 &mut reads,
147 "noise control",
148 codec::NOISE_CONTROL,
149 &codec::NOISE_MODE_QUERY,
150 codec::decode_noise_mode,
151 )?;
152 let battery = self.read(&mut reads, "battery", codec::BATTERY, &[], codec::decode_battery)?;
153 let eq = self.read(&mut reads, "EQ", codec::EQ_PRESET, &[], codec::decode_eq)?;
154 // No bass-level read: the T310 does not answer an empty 0x0124 (HW1). It returns with W6.
155 // Spec: realme_t310.md#bass-read
156 let codec = self.read(&mut reads, "codec", codec::CODEC, &[], codec::decode_codec)?;
157 let firmware = self.read(&mut reads, "firmware", codec::FIRMWARE, &[], codec::decode_firmware)?;
158
159 if reads.answered == 0 && !reads.warnings.is_empty() {
160 return Err(Error::Timeout(reads.warnings.join("; ")));
161 }
162 Ok(Status {
163 battery: battery.unwrap_or_default(),
164 noise_control,
165 eq,
166 bass_level: None,
167 switches: switches.unwrap_or_default(),
168 codec,
169 firmware,
170 warnings: reads.warnings,
171 })
172 }
173
174 /// Spec: realme_t310.md#anc-write, #write-read-back
175 fn set_noise_control(&mut self, mode: NoiseMode) -> Result<NoiseControl> {
176 if !self.commands.allows(codec::NOISE_CONTROL_WRITE) || !self.commands.allows(codec::NOISE_CONTROL) {
177 return Err(Error::Unsupported(
178 "these buds do not offer noise-control settings".to_owned(),
179 ));
180 }
181 let reply = self
182 .link
183 .request(codec::NOISE_CONTROL_WRITE, &codec::noise_write_payload(mode))?;
184 codec::check_write_reply(&reply, "the noise-control change")?;
185
186 // Accepted is not applied: read back until the new mode shows, or give up.
187 let wanted = codec::noise_write_mask(mode);
188 let give_up = Instant::now() + READ_BACK_WINDOW;
189 loop {
190 let reply = self.link.request(codec::NOISE_CONTROL, &codec::NOISE_MODE_QUERY)?;
191 if codec::decode_noise_mask(&reply)? == wanted {
192 return codec::decode_noise_mode(&reply);
193 }
194 if Instant::now() >= give_up {
195 let now = codec::decode_noise_mode(&reply)?;
196 let wanted = match mode {
197 NoiseMode::Anc(level) => format!("anc ({})", level.name()),
198 _ => mode.name().to_owned(),
199 };
200 let reported = match &now.level {
201 Some(level) => format!("{} ({level})", now.mode),
202 None => now.mode,
203 };
204 return Err(Error::NotApplied(format!(
205 "the buds accepted noise control {wanted}, but still report {reported}"
206 )));
207 }
208 self.link.pump_until(Instant::now() + READ_BACK_PAUSE)?;
209 }
210 }
211
212 fn touch_controls(&mut self) -> Result<TouchControls> {
213 if !self.commands.allows(codec::GESTURES) {
214 return Err(Error::Unsupported(
215 "these buds do not offer touch-control settings".to_owned(),
216 ));
217 }
218 let gestures = codec::decode_gestures(&self.link.request(codec::GESTURES, &[])?)?;
219 let mut reads = Reads::default();
220 let hold_cycle = self.read(
221 &mut reads,
222 "hold cycle",
223 codec::NOISE_CONTROL,
224 &codec::HOLD_CYCLE_QUERY,
225 codec::decode_hold_cycle,
226 )?;
227 Ok(TouchControls {
228 gestures,
229 hold_cycle,
230 warnings: reads.warnings,
231 })
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::status::{AncLevel, Charge};
239 use crate::transport::mock::{Script, Step, hex};
240
241 fn tx(cmd: u16, seq: u8, payload: &str) -> Step {
242 Step::Tx(frame::encode(cmd, seq, &hex(payload)).unwrap())
243 }
244
245 fn rx(cmd: u16, seq: u8, payload: &str) -> Step {
246 Step::Rx(frame::encode(cmd, seq, &hex(payload)).unwrap())
247 }
248
249 fn fixed_clock() -> u32 {
250 1_750_000_000
251 }
252
253 fn open(script: &Script) -> Result<T310Session> {
254 T310Session::open(Link::new(script.transport(), fixed_clock))
255 }
256
257 /// The handshake of the owner's T310 (HW1); seq 01-04 are used up.
258 fn handshake() -> Vec<Step> {
259 vec![
260 tx(0x0100, 0x01, ""),
261 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
262 tx(0x0103, 0x02, ""),
263 rx(0x8103, 0x02, "00 12 3C 06"),
264 tx(0x0200, 0x03, ""),
265 rx(0x8200, 0x03, "00 09 01 02 03 04 05 06 0D F1 F4"),
266 tx(0x0205, 0x04, "04 01 02 03 04"),
267 rx(0x8205, 0x04, "00 04 01 00 02 00 03 00 04 00"),
268 ]
269 }
270
271 #[test]
272 fn full_status() {
273 let mut steps = handshake();
274 let mut firmware = String::from("00 03");
275 for byte in b"1,2,120,2,2,120,3,2,105" {
276 firmware.push_str(&format!(" {byte:02X}"));
277 }
278 steps.extend([
279 tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"),
280 rx(0x810D, 0x05, "00 06 05 00 06 00 11 01 1A 00 1B 01 1D 01"),
281 tx(0x010C, 0x06, "01 01"),
282 rx(0x810C, 0x06, "00 01 01 08"),
283 tx(0x0106, 0x07, ""),
284 rx(0x8106, 0x07, "00 03 01 50 02 E4 03 3C"),
285 tx(0x010F, 0x08, ""),
286 rx(0x810F, 0x08, "00 01"),
287 tx(0x0114, 0x09, ""),
288 rx(0x8114, 0x09, "00 02"),
289 tx(0x0105, 0x0A, ""),
290 rx(0x8105, 0x0A, &firmware),
291 ]);
292 let script = Script::new(steps);
293 let status = open(&script).unwrap().status().unwrap();
294 script.assert_done();
295
296 assert_eq!(
297 status.battery.left,
298 Some(Charge {
299 level: 80,
300 charging: false
301 })
302 );
303 assert_eq!(
304 status.battery.right,
305 Some(Charge {
306 level: 100,
307 charging: true
308 })
309 );
310 assert_eq!(
311 status.battery.case,
312 Some(Charge {
313 level: 60,
314 charging: false
315 })
316 );
317 let noise = status.noise_control.unwrap();
318 assert_eq!((noise.mode.as_str(), noise.level.as_deref()), ("anc", Some("deep")));
319 assert_eq!(status.eq.as_deref(), Some("deep-bass"));
320 assert_eq!(status.bass_level, None);
321 assert_eq!(status.codec.as_deref(), Some("AAC"));
322 assert_eq!(status.firmware.as_deref(), Some("120.120.105"));
323 let game = status.switches.iter().find(|s| s.key == "game").unwrap();
324 assert!(!game.on);
325 assert_eq!(status.switches.len(), 5);
326 assert!(status.warnings.is_empty());
327 }
328
329 #[test]
330 fn answers_the_time_request_during_the_settle() {
331 let mut steps = vec![
332 rx(0x0500, 0x01, ""),
333 Step::Tx(hex("AA 0C 00 00 00 85 01 05 00 00 80 E1 4E 68")),
334 ];
335 steps.extend(handshake());
336 // No 0x8205 ack this time: it may never arrive, and `open` does not wait for it.
337 // Spec: realme_t310.md#subscribe
338 steps.pop();
339 let script = Script::new(steps);
340 open(&script).unwrap();
341 script.assert_done();
342 }
343
344 #[test]
345 fn a_silent_handshake_reports_a_busy_channel() {
346 let script = Script::new(vec![tx(0x0100, 0x01, "")]);
347 assert!(matches!(open(&script), Err(Error::ChannelBusy(_))));
348 }
349
350 #[test]
351 fn other_models_are_refused() {
352 let script = Script::new(vec![
353 tx(0x0100, 0x01, ""),
354 rx(0x8100, 0x01, codec::T310_BITMAP_REPLY),
355 tx(0x0103, 0x02, ""),
356 rx(0x8103, 0x02, "00 14 54 06"),
357 ]);
358 match open(&script) {
359 Err(Error::WrongModel { found, .. }) => assert_eq!(found, "065414"),
360 other => panic!("expected WrongModel, got {:?}", other.err()),
361 }
362 }
363
364 #[test]
365 fn commands_missing_from_the_bitmap_are_never_sent() {
366 // A bitmap with no bits: only always-allowed commands go out, and no subscription.
367 let script = Script::new(vec![
368 tx(0x0100, 0x01, ""),
369 rx(0x8100, 0x01, "00"),
370 tx(0x0103, 0x02, ""),
371 rx(0x8103, 0x02, "00 12 3C 06"),
372 tx(0x0200, 0x03, ""),
373 rx(0x8200, 0x03, "00 02 01 02"),
374 tx(0x010D, 0x04, "06 05 06 11 1A 1B 1D"),
375 rx(0x810D, 0x04, "00 01 06 01"),
376 tx(0x0106, 0x05, ""),
377 // no battery reply: becomes a warning
378 ]);
379 let status = open(&script).unwrap().status().unwrap();
380 script.assert_done();
381 assert_eq!(status.switches.len(), 1);
382 assert!(status.noise_control.is_none() && status.firmware.is_none());
383 assert_eq!(status.warnings.len(), 1);
384 assert!(status.warnings.first().unwrap().starts_with("battery"));
385 }
386
387 #[test]
388 fn a_lost_link_stops_the_status() {
389 let mut steps = handshake();
390 steps.extend([tx(0x010D, 0x05, "06 05 06 11 1A 1B 1D"), Step::Close]);
391 let script = Script::new(steps);
392 assert!(matches!(open(&script).unwrap().status(), Err(Error::NotConnected(_))));
393 }
394
395 #[test]
396 fn touch_controls_and_hold_cycle() {
397 let mut steps = handshake();
398 steps.extend([
399 tx(0x0108, 0x05, ""),
400 rx(0x8108, 0x05, "00 02 01 01 02 01 02 01 04 08"),
401 tx(0x010C, 0x06, "02 01"),
402 rx(0x810C, 0x06, "00 02 01 0A"),
403 ]);
404 let script = Script::new(steps);
405 let touch = open(&script).unwrap().touch_controls().unwrap();
406 script.assert_done();
407 assert_eq!(touch.gestures.len(), 2);
408 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
409 }
410
411 /// The owner's `budctl --debug touch` (HW1), byte for byte. The buds' own frames (slice
412 /// byte 08) arrive glued to replies and need no answer.
413 #[test]
414 fn replays_the_hw1_touch_session() {
415 let script = Script::new(vec![
416 Step::Tx(hex("AA 07 00 00 00 01 01 00 00")),
417 Step::Rx(hex(
418 "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",
419 )),
420 Step::Tx(hex("AA 07 00 00 03 01 02 00 00")),
421 Step::Rx(hex("AA 0B 00 00 03 81 02 04 00 00 12 3C 06")),
422 Step::Tx(hex("AA 07 00 00 00 02 03 00 00")),
423 Step::Rx(hex("AA 12 00 00 00 82 03 0B 00 00 09 01 02 03 04 05 06 0D F1 F4")),
424 Step::Tx(hex("AA 0C 00 00 05 02 04 05 00 04 01 02 03 04")),
425 Step::Rx(hex(
426 "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",
427 )),
428 Step::Tx(hex("AA 07 00 00 08 01 05 00 00")),
429 Step::Rx(hex(
430 "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",
431 )),
432 Step::Tx(hex("AA 09 00 00 0C 01 06 02 00 02 01")),
433 Step::Rx(hex("AA 0B 00 00 0C 81 06 04 00 00 02 01 0A")),
434 ]);
435 let touch = open(&script).unwrap().touch_controls().unwrap();
436 script.assert_done();
437 let table: Vec<(&str, &str, &str)> = touch
438 .gestures
439 .iter()
440 .map(|g| (g.side.as_str(), g.gesture.as_str(), g.action.as_str()))
441 .collect();
442 assert_eq!(
443 table,
444 [
445 ("left", "double-tap", "play-pause"),
446 ("left", "triple-tap", "next"),
447 ("left", "hold", "noise-cycle"),
448 ("right", "double-tap", "play-pause"),
449 ("right", "triple-tap", "next"),
450 ("right", "hold", "noise-cycle"),
451 ]
452 );
453 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
454 assert!(touch.warnings.is_empty());
455 }
456
457 #[test]
458 fn sets_noise_control_and_reads_it_back() {
459 let mut steps = handshake();
460 steps.extend([
461 tx(0x0404, 0x05, "01 01 02"),
462 rx(0x8404, 0x05, "00"),
463 // The buds announce the change; it needs no answer.
464 rx(0x0204, 0x10, "03 01 01 02"),
465 tx(0x010C, 0x06, "01 01"),
466 rx(0x810C, 0x06, "00 01 01 02"),
467 ]);
468 let script = Script::new(steps);
469 let now = open(&script)
470 .unwrap()
471 .set_noise_control(NoiseMode::Transparency)
472 .unwrap();
473 script.assert_done();
474 assert_eq!((now.mode.as_str(), now.level), ("transparency", None));
475 }
476
477 #[test]
478 fn reads_back_again_until_the_change_shows() {
479 let mut steps = handshake();
480 steps.extend([
481 tx(0x0404, 0x05, "01 01 01"),
482 rx(0x8404, 0x05, "00"),
483 tx(0x010C, 0x06, "01 01"),
484 rx(0x810C, 0x06, "00 01 01 08"),
485 tx(0x010C, 0x07, "01 01"),
486 rx(0x810C, 0x07, "00 01 01 01"),
487 ]);
488 let script = Script::new(steps);
489 let now = open(&script).unwrap().set_noise_control(NoiseMode::Off).unwrap();
490 script.assert_done();
491 assert_eq!(now.mode, "off");
492 }
493
494 #[test]
495 fn sets_a_noise_cancelling_level() {
496 let mut steps = handshake();
497 steps.extend([
498 tx(0x0404, 0x05, "01 01 04"),
499 rx(0x8404, 0x05, "00"),
500 tx(0x010C, 0x06, "01 01"),
501 rx(0x810C, 0x06, "00 01 01 04"),
502 ]);
503 let script = Script::new(steps);
504 let now = open(&script)
505 .unwrap()
506 .set_noise_control(NoiseMode::Anc(AncLevel::Light))
507 .unwrap();
508 script.assert_done();
509 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
510 }
511
512 #[test]
513 fn a_rejected_noise_control_change_is_not_read_back() {
514 let mut steps = handshake();
515 steps.extend([tx(0x0404, 0x05, "01 01 08"), rx(0x8404, 0x05, "0E")]);
516 let script = Script::new(steps);
517 let result = open(&script).unwrap().set_noise_control(NoiseMode::Anc(AncLevel::Deep));
518 script.assert_done();
519 assert!(matches!(result, Err(Error::Rejected { status: 0x0E, .. })));
520 }
521
522 #[test]
523 fn noise_control_is_not_written_without_its_bitmap_bit() {
524 let script = Script::new(vec![
525 tx(0x0100, 0x01, ""),
526 rx(0x8100, 0x01, "00"),
527 tx(0x0103, 0x02, ""),
528 rx(0x8103, 0x02, "00 12 3C 06"),
529 tx(0x0200, 0x03, ""),
530 rx(0x8200, 0x03, "00 00"),
531 ]);
532 let result = open(&script).unwrap().set_noise_control(NoiseMode::Off);
533 script.assert_done();
534 assert!(matches!(result, Err(Error::Unsupported(_))));
535 }
536}