budctl65fed26
budctl dual: list the devices dual connection keeps (0x0112, read-only)
The buds report the phones and computers they keep for dual connection
with 0x0112, gated by bit 29, which the owner's T310 sets: each one's
address, name, whether it is connected, and flags for the device that
asked, the main audio device and playing. Spec §9.10 has the layout and
its evidence. The layout is not yet seen on a T310: checkpoint HW2.
- core: Session::dual_devices, status::DualDevice, the decoder, gate
bit 29; BdAddr::new and Reader::array become usable crate-wide.
- cli: `budctl dual`, with --json ("devices").
- gui: the Earbud settings page lists the devices under dual connection.
If the buds leave the list unanswered once, the window stops asking
for it, so a reading does not wait 3 s every time.
Managing these devices (connect, disconnect, unpair, preferred device:
0x0429 and the 0x0132 read) belongs to bit 59, which the T310 leaves
clear, and the vendor data gives the T310 no device manager. budctl never
sends them; the spec now says so.
No new dependency.
+447 −15
16 files changed
- M CLAUDE.md +1 −0
- M README.md +2 −1
- M crates/budctl-cli/src/main.rs +12 −0
- M crates/budctl-cli/src/output.rs +89 −1
- M crates/budctl-core/src/bt.rs +12 −0
- M crates/budctl-core/src/devices/mod.rs +10 −1
- M crates/budctl-core/src/devices/realme_t310/codec.rs +80 −5
- M crates/budctl-core/src/devices/realme_t310/mod.rs +45 −2
- M crates/budctl-core/src/reader.rs +2 −1
- M crates/budctl-core/src/status.rs +16 −0
- M crates/budctl-gui/src/buds.rs +24 −1
- M crates/budctl-gui/src/earbuds.rs +68 −1
- M crates/budctl-gui/src/icons.rs +10 −0
- M crates/budctl-gui/src/main.rs +2 −0
- M crates/budctl-gui/src/worker.rs +43 −1
- M docs/protocol/realme_t310.md +31 −1
| @@ -79,6 +79,7 @@ budctl touch # touch controls (gesture table) per bud | |||
| 79 | 79 | budctl anc off|on|transparency | |
| 80 | 80 | budctl eq <preset> | |
| 81 | 81 | budctl game on|off | |
| 82 | + | budctl dual # dual connection: the devices the buds keep (read-only) | |
| 82 | 83 | budctl --device <MAC> ... # when more than one supported device is paired | |
| 83 | 84 | budctl --json status # machine-readable output for scripts/status bars ("schema": 1) | |
| 84 | 85 | budctl --debug ... # hex-dump every frame sent/received (stderr) | |
| @@ -33,6 +33,7 @@ budctl eq deep-bass # select a preset | |||
| 33 | 33 | budctl eq custom-4 50=+3 16000=-2 # change bands of a custom preset (dB) | |
| 34 | 34 | budctl game on|off # game mode (low latency) | |
| 35 | 35 | budctl set bass-boost|spatial|wind|dual on|off | |
| 36 | + | budctl dual # the phones and computers dual connection keeps | |
| 36 | 37 | budctl bass 2 # bass level, -5 to +5 | |
| 37 | 38 | budctl touch # what each tap and hold does | |
| 38 | 39 | budctl touch left triple-tap previous # give a gesture an action (--dry-run first, if you like) | |
| @@ -62,7 +63,7 @@ Exit codes: | |||
| 62 | 63 | - **Overview:** battery, noise control, the switches. | |
| 63 | 64 | - **Equalizer:** presets, the bands of your custom presets, the bass level. | |
| 64 | 65 | - **Gestures:** what each tap and hold does on each bud, and what a hold steps through. | |
| 65 | − | - **Earbud settings:** find my earbuds, firmware, audio codec. | |
| 66 | + | - **Earbud settings:** dual connection and the devices it keeps, find my earbuds, firmware, audio codec. | |
| 66 | 67 | - **App settings.** | |
| 67 | 68 | ||
| 68 | 69 | The app connects when it needs to, and frees the buds' control channel 10 s after your last | |
| @@ -105,6 +105,8 @@ enum Command { | |||
| 105 | 105 | #[arg(value_enum)] | |
| 106 | 106 | state: OnOff, | |
| 107 | 107 | }, | |
| 108 | + | /// The phones and computers the buds keep for dual connection, and which are connected. | |
| 109 | + | Dual, | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | 112 | #[derive(Clone, Copy, ValueEnum)] | |
| @@ -353,6 +355,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 353 | 355 | let now = session.set_switch("game", matches!(state, OnOff::On))?; | |
| 354 | 356 | output::switch(out, &device, model, &now, cli.json) | |
| 355 | 357 | } | |
| 358 | + | Command::Dual => { | |
| 359 | + | let devices = session.dual_devices()?; | |
| 360 | + | output::dual_devices(out, &device, model, &devices, cli.json) | |
| 361 | + | } | |
| 356 | 362 | } | |
| 357 | 363 | } | |
| 358 | 364 | ||
| @@ -399,6 +405,12 @@ mod tests { | |||
| 399 | 405 | .command | |
| 400 | 406 | } | |
| 401 | 407 | ||
| 408 | + | #[test] | |
| 409 | + | fn dual_takes_nothing() { | |
| 410 | + | assert!(matches!(parse(&["dual"]), Command::Dual)); | |
| 411 | + | assert!(Cli::try_parse_from(["budctl", "dual", "off"]).is_err()); | |
| 412 | + | } | |
| 413 | + | ||
| 402 | 414 | #[test] | |
| 403 | 415 | fn eq_takes_a_preset_bands_or_new() { | |
| 404 | 416 | assert!(matches!( | |
| @@ -5,7 +5,7 @@ use std::io::{self, Write}; | |||
| 5 | 5 | use budctl_core::bluez::PairedDevice; | |
| 6 | 6 | use budctl_core::devices::Device; | |
| 7 | 7 | use budctl_core::status::{ | |
| 8 | − | Charge, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls, | |
| 8 | + | Charge, DualDevice, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls, | |
| 9 | 9 | }; | |
| 10 | 10 | use budctl_core::{Error, Found}; | |
| 11 | 11 | use serde_json::Value; | |
| @@ -452,6 +452,38 @@ pub fn bass_level( | |||
| 452 | 452 | Ok(()) | |
| 453 | 453 | } | |
| 454 | 454 | ||
| 455 | + | /// The devices dual connection keeps: name, address, and whether each is connected. | |
| 456 | + | pub fn dual_devices( | |
| 457 | + | out: &mut impl Write, | |
| 458 | + | device: &PairedDevice, | |
| 459 | + | model: &dyn Device, | |
| 460 | + | devices: &[DualDevice], | |
| 461 | + | as_json: bool, | |
| 462 | + | ) -> Result<(), Failure> { | |
| 463 | + | if as_json { | |
| 464 | + | return json(out, Some((device, model)), serde_json::json!({ "devices": devices })); | |
| 465 | + | } | |
| 466 | + | header(out, device, model)?; | |
| 467 | + | if devices.is_empty() { | |
| 468 | + | writeln!(out, " No devices listed.")?; | |
| 469 | + | } | |
| 470 | + | let width = devices.iter().map(|d| d.name.chars().count()).max().unwrap_or(0); | |
| 471 | + | for d in devices { | |
| 472 | + | let mut state = vec![if d.connected { "connected" } else { "not connected" }]; | |
| 473 | + | for (on, what) in [ | |
| 474 | + | (d.this_computer, "this computer"), | |
| 475 | + | (d.main_audio, "main audio"), | |
| 476 | + | (d.playing, "playing"), | |
| 477 | + | ] { | |
| 478 | + | if on { | |
| 479 | + | state.push(what); | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | writeln!(out, " {:<width$} {} {}", d.name, d.address, state.join(", "))?; | |
| 483 | + | } | |
| 484 | + | Ok(()) | |
| 485 | + | } | |
| 486 | + | ||
| 455 | 487 | /// After `find start` or `find stop`: the buds acked; there is nothing to read back. | |
| 456 | 488 | pub fn find( | |
| 457 | 489 | out: &mut impl Write, | |
| @@ -471,3 +503,59 @@ pub fn find( | |||
| 471 | 503 | } | |
| 472 | 504 | Ok(()) | |
| 473 | 505 | } | |
| 506 | + | ||
| 507 | + | #[cfg(test)] | |
| 508 | + | mod tests { | |
| 509 | + | use super::*; | |
| 510 | + | use budctl_core::devices::REGISTRY; | |
| 511 | + | ||
| 512 | + | #[test] | |
| 513 | + | fn dual_devices_line_up_and_keep_their_json_names() { | |
| 514 | + | let buds = PairedDevice { | |
| 515 | + | address: "88:0E:85:46:FE:5C".parse().unwrap(), | |
| 516 | + | name: "realme Buds T310".to_owned(), | |
| 517 | + | alias: "realme Buds T310".to_owned(), | |
| 518 | + | connected: true, | |
| 519 | + | uuids: Vec::new(), | |
| 520 | + | }; | |
| 521 | + | let model = *REGISTRY.first().unwrap(); | |
| 522 | + | let devices = [ | |
| 523 | + | DualDevice { | |
| 524 | + | address: "2B:1A:00:AA:BB:CC".parse().unwrap(), | |
| 525 | + | name: "arch".to_owned(), | |
| 526 | + | connected: true, | |
| 527 | + | this_computer: true, | |
| 528 | + | main_audio: true, | |
| 529 | + | playing: false, | |
| 530 | + | }, | |
| 531 | + | DualDevice { | |
| 532 | + | address: "11:22:33:44:55:66".parse().unwrap(), | |
| 533 | + | name: "Pixel 8".to_owned(), | |
| 534 | + | connected: false, | |
| 535 | + | this_computer: false, | |
| 536 | + | main_audio: false, | |
| 537 | + | playing: false, | |
| 538 | + | }, | |
| 539 | + | ]; | |
| 540 | + | let mut text = Vec::new(); | |
| 541 | + | assert!(dual_devices(&mut text, &buds, model, &devices, false).is_ok()); | |
| 542 | + | assert_eq!( | |
| 543 | + | String::from_utf8(text).unwrap(), | |
| 544 | + | "realme Buds T310 (88:0E:85:46:FE:5C)\n\ | |
| 545 | + | \x20 arch 2B:1A:00:AA:BB:CC connected, this computer, main audio\n\ | |
| 546 | + | \x20 Pixel 8 11:22:33:44:55:66 not connected\n" | |
| 547 | + | ); | |
| 548 | + | ||
| 549 | + | let mut json_out = Vec::new(); | |
| 550 | + | assert!(dual_devices(&mut json_out, &buds, model, &devices, true).is_ok()); | |
| 551 | + | let value: Value = serde_json::from_slice(&json_out).unwrap(); | |
| 552 | + | assert_eq!(value["schema"], 1); | |
| 553 | + | assert_eq!(value["devices"][0]["this_computer"], true); | |
| 554 | + | assert_eq!(value["devices"][1]["address"], "11:22:33:44:55:66"); | |
| 555 | + | assert_eq!(value["devices"][1]["connected"], false); | |
| 556 | + | ||
| 557 | + | let mut empty = Vec::new(); | |
| 558 | + | assert!(dual_devices(&mut empty, &buds, model, &[], false).is_ok()); | |
| 559 | + | assert!(String::from_utf8(empty).unwrap().ends_with(" No devices listed.\n")); | |
| 560 | + | } | |
| 561 | + | } | |
| @@ -12,6 +12,18 @@ use crate::error::{Error, Result}; | |||
| 12 | 12 | pub struct BdAddr([u8; 6]); | |
| 13 | 13 | ||
| 14 | 14 | impl BdAddr { | |
| 15 | + | /// An address from its bytes, in the order people write it. | |
| 16 | + | pub const fn new(bytes: [u8; 6]) -> Self { | |
| 17 | + | BdAddr(bytes) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /// An address stored last byte first, as the kernel's `bdaddr_t` and some buds' replies | |
| 21 | + | /// store it. | |
| 22 | + | pub(crate) fn from_reversed(mut bytes: [u8; 6]) -> Self { | |
| 23 | + | bytes.reverse(); | |
| 24 | + | BdAddr(bytes) | |
| 25 | + | } | |
| 26 | + | ||
| 15 | 27 | /// The address as the kernel's `bdaddr_t` stores it: reversed. | |
| 16 | 28 | /// Spec: transport.md#sockets | |
| 17 | 29 | pub(crate) fn kernel_order(self) -> [u8; 6] { | |
| @@ -6,7 +6,9 @@ | |||
| 6 | 6 | use crate::bluez::PairedDevice; | |
| 7 | 7 | use crate::bt::Uuid; | |
| 8 | 8 | use crate::error::{Error, Result}; | |
| 9 | − | use crate::status::{EqBand, EqPreset, EqPresets, NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls}; | |
| 9 | + | use crate::status::{ | |
| 10 | + | DualDevice, EqBand, EqPreset, EqPresets, NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls, | |
| 11 | + | }; | |
| 10 | 12 | use crate::transport::Transport; | |
| 11 | 13 | ||
| 12 | 14 | pub mod realme_t310; | |
| @@ -121,6 +123,13 @@ pub trait Session: Send { | |||
| 121 | 123 | Err(Error::Unsupported("these buds cannot ring through budctl".to_owned())) | |
| 122 | 124 | } | |
| 123 | 125 | ||
| 126 | + | /// The phones and computers the buds keep for dual connection, and which are connected. | |
| 127 | + | fn dual_devices(&mut self) -> Result<Vec<DualDevice>> { | |
| 128 | + | Err(Error::Unsupported( | |
| 129 | + | "these buds do not list the devices they connect to".to_owned(), | |
| 130 | + | )) | |
| 131 | + | } | |
| 132 | + | ||
| 124 | 133 | /// Turn an on/off setting ([`Switch::key`]) on or off, then read it back. | |
| 125 | 134 | fn set_switch(&mut self, key: &str, _on: bool) -> Result<Switch> { | |
| 126 | 135 | Err(Error::Unsupported(format!("budctl cannot change {key} on these buds"))) | |
| @@ -1,10 +1,11 @@ | |||
| 1 | 1 | //! realme Buds T310 commands, tables and pure decoders. No I/O here, so every function can be | |
| 2 | 2 | //! tested with bytes from the spec. Each constant cites docs/protocol/realme_t310.md. | |
| 3 | 3 | ||
| 4 | + | use crate::bt::BdAddr; | |
| 4 | 5 | use crate::error::{Error, Result}; | |
| 5 | 6 | use crate::reader::Reader; | |
| 6 | 7 | use crate::status::{ | |
| 7 | − | AncLevel, Battery, Charge, EqBand, EqPreset, Gesture, GestureChoice, NoiseControl, NoiseMode, Switch, | |
| 8 | + | AncLevel, Battery, Charge, DualDevice, EqBand, EqPreset, Gesture, GestureChoice, NoiseControl, NoiseMode, Switch, | |
| 8 | 9 | }; | |
| 9 | 10 | ||
| 10 | 11 | /// Spec: realme_t310.md#bitmap | |
| @@ -56,6 +57,16 @@ pub(super) fn find_payload(ring: bool) -> [u8; 1] { | |||
| 56 | 57 | pub(super) const EQ_PRESET: u16 = 0x010F; | |
| 57 | 58 | /// Spec: realme_t310.md#codec | |
| 58 | 59 | pub(super) const CODEC: u16 = 0x0114; | |
| 60 | + | /// Spec: realme_t310.md#dual-devices | |
| 61 | + | pub(super) const DUAL_DEVICES: u16 = 0x0112; | |
| 62 | + | /// The state of a connected device. Spec: realme_t310.md#dual-devices | |
| 63 | + | const DUAL_CONNECTED: u8 = 0x02; | |
| 64 | + | /// Flag bits: the device that asked, the main audio device, playing. Spec: realme_t310.md#dual-devices | |
| 65 | + | const DUAL_THIS: u8 = 0x01; | |
| 66 | + | /// Spec: realme_t310.md#dual-devices | |
| 67 | + | const DUAL_MAIN_AUDIO: u8 = 0x02; | |
| 68 | + | /// Spec: realme_t310.md#dual-devices | |
| 69 | + | const DUAL_PLAYING: u8 = 0x04; | |
| 59 | 70 | /// Spec: realme_t310.md#custom-eq | |
| 60 | 71 | pub(super) const CUSTOM_EQ_LIST: u16 = 0x0122; | |
| 61 | 72 | /// Spec: realme_t310.md#custom-eq | |
| @@ -196,6 +207,7 @@ fn gate(cmd: u16) -> Option<Gate> { | |||
| 196 | 207 | 0x010F | 0x0406 => Gate::Bit(10), | |
| 197 | 208 | 0x0114 => Gate::Bit(17), | |
| 198 | 209 | 0x0205 => Gate::Bit(22), | |
| 210 | + | 0x0112 => Gate::Bit(29), | |
| 199 | 211 | 0x0122 | 0x0418 => Gate::Bit(34), | |
| 200 | 212 | 0x0124 | 0x041B => Gate::Bit(38), | |
| 201 | 213 | _ => return None, | |
| @@ -328,6 +340,32 @@ pub(super) fn decode_codec(reply: &[u8]) -> Result<String> { | |||
| 328 | 340 | Ok(name_of(&CODECS, codec).map_or_else(|| format!("unknown (0x{codec:02X})"), str::to_owned)) | |
| 329 | 341 | } | |
| 330 | 342 | ||
| 343 | + | /// Each entry: the address last byte first, `len`, then `len` bytes: state, flags, the name's | |
| 344 | + | /// length and the name. Spec: realme_t310.md#dual-devices | |
| 345 | + | pub(super) fn decode_dual_devices(reply: &[u8]) -> Result<Vec<DualDevice>> { | |
| 346 | + | let mut r = ok_reply(reply, "the dual-connection list")?; | |
| 347 | + | let count = r.u8()?; | |
| 348 | + | let mut devices = Vec::new(); | |
| 349 | + | for _ in 0..count { | |
| 350 | + | let address = r.array()?; | |
| 351 | + | let len = r.u8()?; | |
| 352 | + | let mut entry = Reader::new(r.take(usize::from(len))?, "a dual-connection entry"); | |
| 353 | + | let state = entry.u8()?; | |
| 354 | + | let flags = entry.u8()?; | |
| 355 | + | let name_len = entry.u8()?; | |
| 356 | + | let name = String::from_utf8_lossy(entry.take(usize::from(name_len))?).into_owned(); | |
| 357 | + | devices.push(DualDevice { | |
| 358 | + | address: BdAddr::from_reversed(address), | |
| 359 | + | name, | |
| 360 | + | connected: state == DUAL_CONNECTED, | |
| 361 | + | this_computer: flags & DUAL_THIS != 0, | |
| 362 | + | main_audio: flags & DUAL_MAIN_AUDIO != 0, | |
| 363 | + | playing: flags & DUAL_PLAYING != 0, | |
| 364 | + | }); | |
| 365 | + | } | |
| 366 | + | Ok(devices) | |
| 367 | + | } | |
| 368 | + | ||
| 331 | 369 | /// Spec: realme_t310.md#anc-read | |
| 332 | 370 | pub(super) fn decode_noise_mode(reply: &[u8]) -> Result<NoiseControl> { | |
| 333 | 371 | let mask = noise_mask(reply, NOISE_MODE_QUERY, "the noise-control reply")?; | |
| @@ -863,13 +901,16 @@ mod tests { | |||
| 863 | 901 | let commands = Commands::parse(&hex(T310_BITMAP_REPLY)).unwrap(); | |
| 864 | 902 | // Every gated command in the T310's logged command set (realme_t310.md#bitmap-t310). | |
| 865 | 903 | for cmd in [ | |
| 866 | − | 0x0100, 0x0103, 0x0105, 0x0106, 0x0108, 0x0109, 0x010C, 0x010D, 0x010F, 0x0114, 0x0122, 0x0124, 0x0205, | |
| 867 | − | 0x0400, 0x0401, 0x0403, 0x0404, 0x0405, 0x0406, 0x0418, 0x041B, 0x0200, | |
| 904 | + | 0x0100, 0x0103, 0x0105, 0x0106, 0x0108, 0x0109, 0x010C, 0x010D, 0x010F, 0x0112, 0x0114, 0x0122, 0x0124, | |
| 905 | + | 0x0205, 0x0400, 0x0401, 0x0403, 0x0404, 0x0405, 0x0406, 0x0418, 0x041B, 0x0200, | |
| 868 | 906 | ] { | |
| 869 | 907 | assert!(commands.allows(cmd), "0x{cmd:04X} should be allowed"); | |
| 870 | 908 | } | |
| 871 | − | // Commands the T310 lacks, and commands budctl has no gate for. | |
| 872 | − | for cmd in [0x012F, 0x041E, 0x0413, 0x0130, 0x0427, 0x0423, 0x012A, 0x0422, 0x9999] { | |
| 909 | + | // Commands the T310 lacks (the dual-connection device manager among them), and commands | |
| 910 | + | // budctl has no gate for. | |
| 911 | + | for cmd in [ | |
| 912 | + | 0x012F, 0x041E, 0x0413, 0x0130, 0x0427, 0x0423, 0x012A, 0x0422, 0x0429, 0x0132, 0x9999, | |
| 913 | + | ] { | |
| 873 | 914 | assert!(!commands.allows(cmd), "0x{cmd:04X} should be refused"); | |
| 874 | 915 | } | |
| 875 | 916 | } | |
| @@ -1054,6 +1095,40 @@ mod tests { | |||
| 1054 | 1095 | assert_eq!(decode_codec(&hex("00 09")).unwrap(), "unknown (0x09)"); | |
| 1055 | 1096 | } | |
| 1056 | 1097 | ||
| 1098 | + | #[test] | |
| 1099 | + | fn dual_devices_from_the_spec_example() { | |
| 1100 | + | let devices = decode_dual_devices(&hex( | |
| 1101 | + | "00 02 66 55 44 33 22 11 0A 02 06 07 50 69 78 65 6C 20 38 CC BB AA 00 1A 2B 07 02 01 04 61 72 63 68", | |
| 1102 | + | )) | |
| 1103 | + | .unwrap(); | |
| 1104 | + | let [phone, computer] = devices.as_slice() else { | |
| 1105 | + | panic!("two devices expected: {devices:?}"); | |
| 1106 | + | }; | |
| 1107 | + | assert_eq!(phone.address.to_string(), "11:22:33:44:55:66"); | |
| 1108 | + | assert_eq!(phone.name, "Pixel 8"); | |
| 1109 | + | assert!(phone.connected && phone.main_audio && phone.playing && !phone.this_computer); | |
| 1110 | + | assert_eq!(computer.address.to_string(), "2B:1A:00:AA:BB:CC"); | |
| 1111 | + | assert_eq!(computer.name, "arch"); | |
| 1112 | + | assert!(computer.connected && computer.this_computer && !computer.main_audio && !computer.playing); | |
| 1113 | + | } | |
| 1114 | + | ||
| 1115 | + | #[test] | |
| 1116 | + | fn dual_devices_skip_by_len_and_keep_disconnected_ones() { | |
| 1117 | + | // A device that left (state 00) with two bytes after its name, then an empty list. | |
| 1118 | + | let devices = decode_dual_devices(&hex("00 01 01 02 03 04 05 06 06 00 00 01 41 FF FF")).unwrap(); | |
| 1119 | + | let [gone] = devices.as_slice() else { | |
| 1120 | + | panic!("one device expected: {devices:?}"); | |
| 1121 | + | }; | |
| 1122 | + | assert_eq!(gone.address.to_string(), "06:05:04:03:02:01"); | |
| 1123 | + | assert_eq!(gone.name, "A"); | |
| 1124 | + | assert!(!gone.connected && !gone.this_computer); | |
| 1125 | + | assert!(decode_dual_devices(&hex("00 00")).unwrap().is_empty()); | |
| 1126 | + | // A name longer than its entry, an entry cut short, a refusal. | |
| 1127 | + | assert!(decode_dual_devices(&hex("00 01 01 02 03 04 05 06 04 02 00 05 41")).is_err()); | |
| 1128 | + | assert!(decode_dual_devices(&hex("00 01 01 02 03 04 05 06 07 02 00 04 61")).is_err()); | |
| 1129 | + | assert!(decode_dual_devices(&hex("01")).is_err()); | |
| 1130 | + | } | |
| 1131 | + | ||
| 1057 | 1132 | #[test] | |
| 1058 | 1133 | fn gesture_table_names_known_values() { | |
| 1059 | 1134 | let gestures = decode_gestures(&hex("00 03 01 01 02 01 02 01 04 08 04 06 02 1D")).unwrap(); | |
| @@ -10,8 +10,8 @@ use crate::bt::Uuid; | |||
| 10 | 10 | use crate::devices::{Device, Session}; | |
| 11 | 11 | use crate::error::{Error, Kind, Result}; | |
| 12 | 12 | use crate::status::{ | |
| 13 | − | EqBand, EqPreset, EqPresets, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, | |
| 14 | − | TouchControls, | |
| 13 | + | DualDevice, EqBand, EqPreset, EqPresets, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, | |
| 14 | + | TouchChange, TouchControls, | |
| 15 | 15 | }; | |
| 16 | 16 | use crate::transport::Transport; | |
| 17 | 17 | ||
| @@ -522,6 +522,16 @@ impl Session for T310Session { | |||
| 522 | 522 | } | |
| 523 | 523 | ||
| 524 | 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 | + | ||
| 525 | 535 | fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> { | |
| 526 | 536 | let Some(id) = codec::switch_id(key) else { | |
| 527 | 537 | return Err(Error::Unsupported(format!("these buds have no switch called {key}"))); | |
| @@ -1155,6 +1165,39 @@ mod tests { | |||
| 1155 | 1165 | assert!(matches!(result, Err(Error::Unsupported(_)))); | |
| 1156 | 1166 | } | |
| 1157 | 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 CC BB AA 00 1A 2B 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 | + | ||
| 1158 | 1201 | /// The owner's gesture table (HW1) and the same table with the left triple tap on volume up. | |
| 1159 | 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"; | |
| 1160 | 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"; | |
| @@ -52,7 +52,8 @@ impl<'a> Reader<'a> { | |||
| 52 | 52 | self.bytes.is_empty() | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | − | fn array<const N: usize>(&mut self) -> Result<[u8; N]> { | |
| 55 | + | /// The next `N` bytes, as an array. | |
| 56 | + | pub(crate) fn array<const N: usize>(&mut self) -> Result<[u8; N]> { | |
| 56 | 57 | let (head, tail) = self.bytes.split_first_chunk::<N>().ok_or_else(|| self.too_short())?; | |
| 57 | 58 | self.bytes = tail; | |
| 58 | 59 | Ok(*head) | |
| @@ -3,6 +3,8 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use serde::{Serialize, Serializer}; | |
| 5 | 5 | ||
| 6 | + | use crate::bt::BdAddr; | |
| 7 | + | ||
| 6 | 8 | /// Bumped whenever the JSON shape changes incompatibly. | |
| 7 | 9 | pub const JSON_SCHEMA: u32 = 1; | |
| 8 | 10 | ||
| @@ -158,6 +160,20 @@ pub struct EqBand { | |||
| 158 | 160 | pub gain: i8, | |
| 159 | 161 | } | |
| 160 | 162 | ||
| 163 | + | /// A phone or computer the buds keep for dual connection. | |
| 164 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] | |
| 165 | + | pub struct DualDevice { | |
| 166 | + | pub address: BdAddr, | |
| 167 | + | /// The name the device gave the buds. | |
| 168 | + | pub name: String, | |
| 169 | + | pub connected: bool, | |
| 170 | + | /// The device that asked: the computer budctl runs on. | |
| 171 | + | pub this_computer: bool, | |
| 172 | + | /// The device the buds take audio from. | |
| 173 | + | pub main_audio: bool, | |
| 174 | + | pub playing: bool, | |
| 175 | + | } | |
| 176 | + | ||
| 161 | 177 | /// A touch-control change: what changes, the write that does it and, once sent, the table the | |
| 162 | 178 | /// buds report afterwards. | |
| 163 | 179 | #[derive(Debug, Serialize)] | |
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | //! The buds the window shows: made-up ones for `--demo` (roughly what the owner's T310 reported | |
| 2 | 2 | //! at the last checkpoints), or an empty pair until the real buds answer. | |
| 3 | 3 | ||
| 4 | + | use budctl_core::bt::BdAddr; | |
| 4 | 5 | use budctl_core::status::{ | |
| 5 | − | Battery, Charge, EqBand, EqPreset, EqPresets, Gesture, GestureChoice, NoiseControl, Status, Switch, TouchControls, | |
| 6 | + | Battery, Charge, DualDevice, EqBand, EqPreset, EqPresets, Gesture, GestureChoice, NoiseControl, Status, Switch, | |
| 7 | + | TouchControls, | |
| 6 | 8 | }; | |
| 7 | 9 | ||
| 8 | 10 | /// The buds the window shows. | |
| @@ -18,6 +20,8 @@ pub struct Buds { | |||
| 18 | 20 | pub bass_range: Option<(i8, i8)>, | |
| 19 | 21 | /// The bass level last set from this window. The buds do not report it. | |
| 20 | 22 | pub bass: Option<i8>, | |
| 23 | + | /// The devices dual connection keeps, when the buds list them. | |
| 24 | + | pub dual: Option<Vec<DualDevice>>, | |
| 21 | 25 | } | |
| 22 | 26 | ||
| 23 | 27 | impl Buds { | |
| @@ -182,6 +186,24 @@ pub fn demo() -> Buds { | |||
| 182 | 186 | }), | |
| 183 | 187 | bass_range: Some((-5, 5)), | |
| 184 | 188 | bass: None, | |
| 189 | + | dual: Some(vec![ | |
| 190 | + | DualDevice { | |
| 191 | + | address: BdAddr::new([0x2B, 0x1A, 0x00, 0xAA, 0xBB, 0xCC]), | |
| 192 | + | name: "arch".to_owned(), | |
| 193 | + | connected: true, | |
| 194 | + | this_computer: true, | |
| 195 | + | main_audio: false, | |
| 196 | + | playing: false, | |
| 197 | + | }, | |
| 198 | + | DualDevice { | |
| 199 | + | address: BdAddr::new([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]), | |
| 200 | + | name: "Pixel 8".to_owned(), | |
| 201 | + | connected: true, | |
| 202 | + | this_computer: false, | |
| 203 | + | main_audio: true, | |
| 204 | + | playing: true, | |
| 205 | + | }, | |
| 206 | + | ]), | |
| 185 | 207 | } | |
| 186 | 208 | } | |
| 187 | 209 | ||
| @@ -195,6 +217,7 @@ pub fn unknown() -> Buds { | |||
| 195 | 217 | touch: None, | |
| 196 | 218 | bass_range: None, | |
| 197 | 219 | bass: None, | |
| 220 | + | dual: None, | |
| 198 | 221 | } | |
| 199 | 222 | } | |
| 200 | 223 | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | − | //! The Earbud settings page: dual connection, find the buds, and what they run. | |
| 1 | + | //! The Earbud settings page: dual connection and its devices, find the buds, and what they run. | |
| 2 | 2 | ||
| 3 | + | use budctl_core::status::DualDevice; | |
| 3 | 4 | use iced::Element; | |
| 4 | 5 | use iced::widget::{column, text}; | |
| 5 | 6 | ||
| @@ -85,8 +86,74 @@ pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Elem | |||
| 85 | 86 | ]; | |
| 86 | 87 | column![ui::title(look, "Earbud settings")] | |
| 87 | 88 | .push(dual) | |
| 89 | + | .push(buds.dual.as_deref().map(|list| devices(look, list))) | |
| 88 | 90 | .push(column![ui::heading(look, "Find my earbuds"), ui::card(look, vec![find])].spacing(10)) | |
| 89 | 91 | .push(column![ui::heading(look, "About these buds"), ui::card(look, facts)].spacing(10)) | |
| 90 | 92 | .spacing(18) | |
| 91 | 93 | .into() | |
| 92 | 94 | } | |
| 95 | + | ||
| 96 | + | /// The devices dual connection keeps, as the buds list them. Nothing here can be changed: the | |
| 97 | + | /// buds take no command to connect, disconnect or forget one. | |
| 98 | + | fn devices<'a>(look: Look, list: &'a [DualDevice]) -> Element<'a, Message> { | |
| 99 | + | let p = look.p; | |
| 100 | + | let rows: Vec<Element<'a, Message>> = list | |
| 101 | + | .iter() | |
| 102 | + | .map(|device| { | |
| 103 | + | let icon = if device.this_computer { | |
| 104 | + | Icon::Laptop | |
| 105 | + | } else { | |
| 106 | + | Icon::Phone | |
| 107 | + | }; | |
| 108 | + | let name = if device.name.is_empty() { | |
| 109 | + | "Unnamed device" | |
| 110 | + | } else { | |
| 111 | + | device.name.as_str() | |
| 112 | + | }; | |
| 113 | + | let address = text(device.address.to_string()).size(13).color(p.dim); | |
| 114 | + | ui::setting(look, icon, name, Some(state(device)), address.into(), None, false) | |
| 115 | + | }) | |
| 116 | + | .collect(); | |
| 117 | + | let list: Element<'a, Message> = if rows.is_empty() { | |
| 118 | + | text("The buds list no devices.").size(13).color(p.dim).into() | |
| 119 | + | } else { | |
| 120 | + | ui::card(look, rows) | |
| 121 | + | }; | |
| 122 | + | let note = text("These buds cannot connect, disconnect or forget a device from here.") | |
| 123 | + | .size(13) | |
| 124 | + | .color(p.dim); | |
| 125 | + | column![ui::heading(look, "Devices"), list, note].spacing(10).into() | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// "Connected · this computer", "Not connected"… | |
| 129 | + | fn state(device: &DualDevice) -> String { | |
| 130 | + | let mut words = vec![if device.connected { "Connected" } else { "Not connected" }]; | |
| 131 | + | for (on, what) in [ | |
| 132 | + | (device.this_computer, "this computer"), | |
| 133 | + | (device.main_audio, "main audio"), | |
| 134 | + | (device.playing, "playing"), | |
| 135 | + | ] { | |
| 136 | + | if on { | |
| 137 | + | words.push(what); | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | words.join(" · ") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[cfg(test)] | |
| 144 | + | mod tests { | |
| 145 | + | use super::*; | |
| 146 | + | use crate::buds; | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn device_states_in_words() { | |
| 150 | + | let dual = buds::demo().dual.unwrap(); | |
| 151 | + | let words: Vec<String> = dual.iter().map(state).collect(); | |
| 152 | + | assert_eq!(words, ["Connected · this computer", "Connected · main audio · playing"]); | |
| 153 | + | let mut gone = dual[1].clone(); | |
| 154 | + | gone.connected = false; | |
| 155 | + | gone.main_audio = false; | |
| 156 | + | gone.playing = false; | |
| 157 | + | assert_eq!(state(&gone), "Not connected"); | |
| 158 | + | } | |
| 159 | + | } | |
| @@ -30,6 +30,8 @@ pub enum Icon { | |||
| 30 | 30 | Keys, | |
| 31 | 31 | Pencil, | |
| 32 | 32 | Trash, | |
| 33 | + | Laptop, | |
| 34 | + | Phone, | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | /// `icon` at `size` pixels, in `color`. | |
| @@ -291,6 +293,14 @@ fn draw_icon(pen: &mut Pen, icon: Icon) { | |||
| 291 | 293 | pen.line((8.0, 14.0), (16.0, 14.0)); | |
| 292 | 294 | } | |
| 293 | 295 | Icon::Check => pen.polyline(&[(4.5, 12.5), (9.5, 17.5), (19.5, 7.0)], false), | |
| 296 | + | Icon::Laptop => { | |
| 297 | + | pen.rect(4.0, 5.0, 16.0, 11.0, 1.5); | |
| 298 | + | pen.line((2.0, 19.0), (22.0, 19.0)); | |
| 299 | + | } | |
| 300 | + | Icon::Phone => { | |
| 301 | + | pen.rect(7.0, 2.5, 10.0, 19.0, 2.0); | |
| 302 | + | pen.line((10.5, 18.5), (13.5, 18.5)); | |
| 303 | + | } | |
| 294 | 304 | } | |
| 295 | 305 | } | |
| 296 | 306 | ||
| @@ -749,6 +749,7 @@ impl App { | |||
| 749 | 749 | self.buds.eq = reading.eq; | |
| 750 | 750 | self.buds.touch = reading.touch; | |
| 751 | 751 | self.buds.bass_range = reading.bass_range; | |
| 752 | + | self.buds.dual = reading.dual; | |
| 752 | 753 | if let Some(level) = reading | |
| 753 | 754 | .status | |
| 754 | 755 | .noise_control | |
| @@ -1628,6 +1629,7 @@ mod tests { | |||
| 1628 | 1629 | eq: buds::demo().eq, | |
| 1629 | 1630 | touch: buds::demo().touch, | |
| 1630 | 1631 | bass_range: buds::demo().bass_range, | |
| 1632 | + | dual: buds::demo().dual, | |
| 1631 | 1633 | } | |
| 1632 | 1634 | } | |
| 1633 | 1635 | ||
| @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; | |||
| 13 | 13 | use budctl_core::bt::BdAddr; | |
| 14 | 14 | use budctl_core::devices::Session; | |
| 15 | 15 | use budctl_core::status::{ | |
| 16 | − | EqBand, EqPreset, EqPresets, Gesture, NoiseControl, NoiseMode, Status, Switch, TouchControls, | |
| 16 | + | DualDevice, EqBand, EqPreset, EqPresets, Gesture, NoiseControl, NoiseMode, Status, Switch, TouchControls, | |
| 17 | 17 | }; | |
| 18 | 18 | use budctl_core::{Error, Kind, Result}; | |
| 19 | 19 | use iced::futures::channel::mpsc as channel; | |
| @@ -99,6 +99,8 @@ pub struct Reading { | |||
| 99 | 99 | pub touch: Option<TouchControls>, | |
| 100 | 100 | /// The levels the bass level takes, when the buds have one to set. | |
| 101 | 101 | pub bass_range: Option<(i8, i8)>, | |
| 102 | + | /// The devices dual connection keeps, when the buds list them. | |
| 103 | + | pub dual: Option<Vec<DualDevice>>, | |
| 102 | 104 | } | |
| 103 | 105 | ||
| 104 | 106 | /// The worker, as a stream of events for an iced subscription. `device` picks the buds, as | |
| @@ -147,6 +149,9 @@ struct Worker { | |||
| 147 | 149 | last_command: Instant, | |
| 148 | 150 | /// How long the session stays open after the last command. | |
| 149 | 151 | idle: Duration, | |
| 152 | + | /// The buds once left the dual-connection list unanswered. They are not asked again, so | |
| 153 | + | /// a reading does not wait for that timeout every time. | |
| 154 | + | dual_unanswered: bool, | |
| 150 | 155 | } | |
| 151 | 156 | ||
| 152 | 157 | impl Worker { | |
| @@ -157,6 +162,7 @@ impl Worker { | |||
| 157 | 162 | events, | |
| 158 | 163 | last_command: Instant::now(), | |
| 159 | 164 | idle, | |
| 165 | + | dual_unanswered: false, | |
| 160 | 166 | } | |
| 161 | 167 | } | |
| 162 | 168 | ||
| @@ -268,16 +274,26 @@ impl Worker { | |||
| 268 | 274 | } | |
| 269 | 275 | ||
| 270 | 276 | fn read(&mut self) -> Result<Reading> { | |
| 277 | + | let ask_dual = !self.dual_unanswered; | |
| 271 | 278 | let session = self.session()?; | |
| 272 | 279 | let status = session.status()?; | |
| 273 | 280 | let eq = session.eq_presets().ok(); | |
| 274 | 281 | let touch = session.touch_controls().ok(); | |
| 275 | 282 | let bass_range = session.bass_range(); | |
| 283 | + | let dual = match ask_dual.then(|| session.dual_devices()) { | |
| 284 | + | Some(Ok(devices)) => Some(devices), | |
| 285 | + | Some(Err(Error::Timeout(_))) => { | |
| 286 | + | self.dual_unanswered = true; | |
| 287 | + | None | |
| 288 | + | } | |
| 289 | + | Some(Err(_)) | None => None, | |
| 290 | + | }; | |
| 276 | 291 | Ok(Reading { | |
| 277 | 292 | status, | |
| 278 | 293 | eq, | |
| 279 | 294 | touch, | |
| 280 | 295 | bass_range, | |
| 296 | + | dual, | |
| 281 | 297 | }) | |
| 282 | 298 | } | |
| 283 | 299 | ||
| @@ -316,6 +332,8 @@ mod tests { | |||
| 316 | 332 | closed: Arc<AtomicBool>, | |
| 317 | 333 | /// Make `serve` fail, as when the buds drop the link. | |
| 318 | 334 | drop_link: Arc<AtomicBool>, | |
| 335 | + | /// How often the dual-connection list was asked for. The fake buds never answer it. | |
| 336 | + | dual_asked: Arc<AtomicUsize>, | |
| 319 | 337 | } | |
| 320 | 338 | ||
| 321 | 339 | struct Fake(Probe); | |
| @@ -325,6 +343,11 @@ mod tests { | |||
| 325 | 343 | Ok(Status::default()) | |
| 326 | 344 | } | |
| 327 | 345 | ||
| 346 | + | fn dual_devices(&mut self) -> Result<Vec<DualDevice>> { | |
| 347 | + | self.0.dual_asked.fetch_add(1, Ordering::SeqCst); | |
| 348 | + | Err(Error::Timeout("the dual-connection list".to_owned())) | |
| 349 | + | } | |
| 350 | + | ||
| 328 | 351 | fn serve(&mut self) -> Result<()> { | |
| 329 | 352 | self.0.served.fetch_add(1, Ordering::SeqCst); | |
| 330 | 353 | if self.0.drop_link.load(Ordering::SeqCst) { | |
| @@ -396,6 +419,25 @@ mod tests { | |||
| 396 | 419 | thread.join().unwrap(); | |
| 397 | 420 | } | |
| 398 | 421 | ||
| 422 | + | #[test] | |
| 423 | + | fn a_dual_list_left_unanswered_is_not_asked_for_again() { | |
| 424 | + | let probe = Probe::default(); | |
| 425 | + | let (commands, mut events, thread) = start(&probe); | |
| 426 | + | commands.send(Command::Read).unwrap(); | |
| 427 | + | commands.send(Command::Read).unwrap(); | |
| 428 | + | assert!(matches!(block_on(events.next()), Some(Event::Found(_)))); | |
| 429 | + | for _ in 0..2 { | |
| 430 | + | let Some(Event::Read(reading)) = block_on(events.next()) else { | |
| 431 | + | panic!("a reading expected"); | |
| 432 | + | }; | |
| 433 | + | assert!(reading.dual.is_none()); | |
| 434 | + | } | |
| 435 | + | assert_eq!(probe.dual_asked.load(Ordering::SeqCst), 1); | |
| 436 | + | ||
| 437 | + | drop(commands); | |
| 438 | + | thread.join().unwrap(); | |
| 439 | + | } | |
| 440 | + | ||
| 399 | 441 | #[test] | |
| 400 | 442 | fn a_dropped_link_is_reported_once() { | |
| 401 | 443 | let probe = Probe::default(); | |
| @@ -131,6 +131,7 @@ Gate table for every command budctl uses or documents here: | |||
| 131 | 131 | | `0x010F` EQ read, `0x0406` EQ select | bit 10 | | |
| 132 | 132 | | `0x0114` current codec | bit 17 | | |
| 133 | 133 | | `0x0205` subscribe | bit 22 | | |
| 134 | + | | `0x0112` dual-connection devices | bit 29 | | |
| 134 | 135 | | `0x0122` custom EQ list, `0x0418` custom EQ write | bit 34 | | |
| 135 | 136 | | `0x0124` bass level read, `0x041B` bass level write | bit 38 | | |
| 136 | 137 | ||
| @@ -150,6 +151,7 @@ Gate table for every command budctl uses or documents here: | |||
| 150 | 151 | - `0x0133`/`0x042D` tap sensitivity (bit 61). | |
| 151 | 152 | - `0x0423` game sound (bit 49). | |
| 152 | 153 | - `0x012A`/`0x0422` spatial type (bit 47). | |
| 154 | + | - `0x0429`/`0x0132` dual-connection device manager (bit 59): connect, disconnect, unpair, preferred device ([§9.10](#dual-manager)). | |
| 153 | 155 | ||
| 154 | 156 | <a id="init"></a> | |
| 155 | 157 | ## 6. Session start | |
| @@ -207,6 +209,7 @@ budctl reads none of these yet. It skips them while waiting for replies (and pri | |||
| 207 | 209 | | `03` | noise control | `01 01 <mask LE>` = new mode; `02 <kind> <mask>` = new hold cycle (not a mode) | | |
| 208 | 210 | | `04` | fit-test result | `(<dev> <result>)…` | | |
| 209 | 211 | | `05` | game mode | `<state>`. **The T310 never sends this** (*t310*) | | |
| 212 | + | | `06` | dual-connection devices | `<count> <entries…>`, as in [§9.10](#dual-devices) (*buds4*) | | |
| 210 | 213 | | `F1` | a gesture fired | `<side> <button> <act> <fn> …`. Diagnostic only | | |
| 211 | 214 | ||
| 212 | 215 | - **`0x0503`** `<count> (<id> <value>)…`: switch values changed (*vendor*, other realme models). | |
| @@ -359,6 +362,31 @@ Evidence: *vendor* | |||
| 359 | 362 | - **Both buds.** On realme buds one setting covers both buds (kind `01`). | |
| 360 | 363 | - **When it applies.** The cycle is used only where a bud's hold gesture is `fn 08`. | |
| 361 | 364 | ||
| 365 | + | <a id="dual-devices"></a> | |
| 366 | + | ### 9.10 Dual-connection devices: `0x0112` | |
| 367 | + | ||
| 368 | + | The phones and computers the buds keep for dual connection, and which of them are connected now. budctl only reads this list. | |
| 369 | + | ||
| 370 | + | - **Gate:** bit 29 (*vendor*). The T310's command set lists `0112` (*t310*), and the owner's T310 sets bit 29 (*ours-2026-10-27*). | |
| 371 | + | - **Request:** no payload (*vendor*). | |
| 372 | + | - **Reply:** `00 <count>`, then `count` entries (*buds4*): | |
| 373 | + | ||
| 374 | + | | Bytes | Field | | |
| 375 | + | |---|---| | |
| 376 | + | | 6 | the device's Bluetooth address, last byte first | | |
| 377 | + | | 1 | `len`: how many bytes of the entry follow | | |
| 378 | + | | 1 | state: `02` connected, `00` not connected | | |
| 379 | + | | 1 | flags | | |
| 380 | + | | 1 | name length | | |
| 381 | + | | name length | the device's name, UTF-8 | | |
| 382 | + | ||
| 383 | + | - **`len`** covers the state, the flags, the name length and the name, so it is 3 more than the name length (*buds4*). budctl finds the next entry with `len`, and ignores any bytes after the name (*derived*). | |
| 384 | + | - **Flags** (*vendor*): bit 0 marks the device that asked (for budctl, the computer it runs on), bit 1 the main audio device, bit 2 a device playing audio. Bits 3-5 give the kind of device; their values are not known, so budctl does not show them. | |
| 385 | + | - **A device that disconnects stays in the list**, with state `00` (*buds4*). | |
| 386 | + | - **Example** (*derived* from the layout; not seen on a T310): `00 02 66 55 44 33 22 11 0A 02 06 07 50 69 78 65 6C 20 38 CC BB AA 00 1A 2B 07 02 01 04 61 72 63 68` lists two connected devices: "Pixel 8" at `11:22:33:44:55:66`, the main audio device and playing, and "arch" at `2B:1A:00:AA:BB:CC`, the device that asked. | |
| 387 | + | - **Push.** Event `06` of `0x0204` carries the same list, `06 <count> <entries…>`, when it changes (*buds4*). The T310 offers event `06` ([§6](#events-offered)), but budctl does not subscribe to it. | |
| 388 | + | - <a id="dual-manager"></a>**No device manager on the T310.** Connecting, disconnecting or unpairing one of these devices, and choosing a preferred one, are other commands (`0x0429`, read `0x0132`). They belong to bit 59, which the owner's T310 leaves clear (*ours-2026-10-27*, [§5](#bitmap-t310)), and the vendor data gives the T310 no device manager (*vendor*). budctl never sends them. | |
| 389 | + | ||
| 362 | 390 | <a id="writes"></a> | |
| 363 | 391 | ## 10. Writes | |
| 364 | 392 | ||
| @@ -527,7 +555,7 @@ Evidence: *vendor* | |||
| 527 | 555 | ||
| 528 | 556 | - **Wear detection.** Id `04` was refused with `01` (*t310*), and there is no in-ear sensor. A bud out of the case reads as "out" in every state. | |
| 529 | 557 | - **Hi-Res** `18`, **hearing profile** `0B`, **game sound** `27`, **hold volume** `35`: refused with `01` (*t310*). | |
| 530 | − | - **Not in the T310 bitmap** ([§5](#bitmap-t310)): alert volume, tap sensitivity, spatial type, 3D-audio source, game EQ, head gestures, personalised ANC and batch query. | |
| 558 | + | - **Not in the T310 bitmap** ([§5](#bitmap-t310)): alert volume, tap sensitivity, spatial type, 3D-audio source, game EQ, head gestures, personalised ANC, batch query, and the dual-connection device manager ([§9.10](#dual-manager)). | |
| 531 | 559 | ||
| 532 | 560 | <a id="open"></a> | |
| 533 | 561 | ## 12. Open questions (need a capture or a test on the owner's buds) | |
| @@ -553,6 +581,8 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go | |||
| 553 | 581 | - Run `budctl list`, `budctl --debug status`, `budctl --json status` and `budctl --debug touch`. | |
| 554 | 582 | - Then run `status` again twice: once while the phone app is connected (the busy path), and once with the case lid closed (the not-connected path). | |
| 555 | 583 | ||
| 584 | + | **HW2, read-only:** `budctl --debug dual` ([§9.10](#dual-devices)), first with only this computer connected, then with the phone connected too (dual connection on). | |
| 585 | + | ||
| 556 | 586 | **Writes, one per checkpoint, in this order:** | |
| 557 | 587 | ||
| 558 | 588 | | Step | Write | | |