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