Krolyxon
Commit 65fed26

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-28 02:12 +0530
Parent
6e8f058
Commit
65fed26c5db00a902b3699f47966da129e7197a3

+447 −15

16 files changed

  1. M CLAUDE.md +1 −0
  2. M README.md +2 −1
  3. M crates/budctl-cli/src/main.rs +12 −0
  4. M crates/budctl-cli/src/output.rs +89 −1
  5. M crates/budctl-core/src/bt.rs +12 −0
  6. M crates/budctl-core/src/devices/mod.rs +10 −1
  7. M crates/budctl-core/src/devices/realme_t310/codec.rs +80 −5
  8. M crates/budctl-core/src/devices/realme_t310/mod.rs +45 −2
  9. M crates/budctl-core/src/reader.rs +2 −1
  10. M crates/budctl-core/src/status.rs +16 −0
  11. M crates/budctl-gui/src/buds.rs +24 −1
  12. M crates/budctl-gui/src/earbuds.rs +68 −1
  13. M crates/budctl-gui/src/icons.rs +10 −0
  14. M crates/budctl-gui/src/main.rs +2 −0
  15. M crates/budctl-gui/src/worker.rs +43 −1
  16. M docs/protocol/realme_t310.md +31 −1
M CLAUDE.md View
@@ -79,6 +79,7 @@ budctl touch # touch controls (gesture table) per bud
7979 budctl anc off|on|transparency
8080 budctl eq <preset>
8181 budctl game on|off
82+budctl dual # dual connection: the devices the buds keep (read-only)
8283 budctl --device <MAC> ... # when more than one supported device is paired
8384 budctl --json status # machine-readable output for scripts/status bars ("schema": 1)
8485 budctl --debug ... # hex-dump every frame sent/received (stderr)
M README.md View
@@ -33,6 +33,7 @@ budctl eq deep-bass # select a preset
3333 budctl eq custom-4 50=+3 16000=-2 # change bands of a custom preset (dB)
3434 budctl game on|off # game mode (low latency)
3535 budctl set bass-boost|spatial|wind|dual on|off
36+budctl dual # the phones and computers dual connection keeps
3637 budctl bass 2 # bass level, -5 to +5
3738 budctl touch # what each tap and hold does
3839 budctl touch left triple-tap previous # give a gesture an action (--dry-run first, if you like)
@@ -62,7 +63,7 @@ Exit codes:
6263 - **Overview:** battery, noise control, the switches.
6364 - **Equalizer:** presets, the bands of your custom presets, the bass level.
6465 - **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.
6667 - **App settings.**
6768
6869 The app connects when it needs to, and frees the buds' control channel 10 s after your last
M crates/budctl-cli/src/main.rs View
@@ -105,6 +105,8 @@ enum Command {
105105 #[arg(value_enum)]
106106 state: OnOff,
107107 },
108+ /// The phones and computers the buds keep for dual connection, and which are connected.
109+ Dual,
108110 }
109111
110112 #[derive(Clone, Copy, ValueEnum)]
@@ -353,6 +355,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
353355 let now = session.set_switch("game", matches!(state, OnOff::On))?;
354356 output::switch(out, &device, model, &now, cli.json)
355357 }
358+ Command::Dual => {
359+ let devices = session.dual_devices()?;
360+ output::dual_devices(out, &device, model, &devices, cli.json)
361+ }
356362 }
357363 }
358364
@@ -399,6 +405,12 @@ mod tests {
399405 .command
400406 }
401407
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+
402414 #[test]
403415 fn eq_takes_a_preset_bands_or_new() {
404416 assert!(matches!(
M crates/budctl-cli/src/output.rs View
@@ -5,7 +5,7 @@ use std::io::{self, Write};
55 use budctl_core::bluez::PairedDevice;
66 use budctl_core::devices::Device;
77 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,
99 };
1010 use budctl_core::{Error, Found};
1111 use serde_json::Value;
@@ -452,6 +452,38 @@ pub fn bass_level(
452452 Ok(())
453453 }
454454
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+
455487 /// After `find start` or `find stop`: the buds acked; there is nothing to read back.
456488 pub fn find(
457489 out: &mut impl Write,
@@ -471,3 +503,59 @@ pub fn find(
471503 }
472504 Ok(())
473505 }
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+}
M crates/budctl-core/src/bt.rs View
@@ -12,6 +12,18 @@ use crate::error::{Error, Result};
1212 pub struct BdAddr([u8; 6]);
1313
1414 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+
1527 /// The address as the kernel's `bdaddr_t` stores it: reversed.
1628 /// Spec: transport.md#sockets
1729 pub(crate) fn kernel_order(self) -> [u8; 6] {
M crates/budctl-core/src/devices/mod.rs View
@@ -6,7 +6,9 @@
66 use crate::bluez::PairedDevice;
77 use crate::bt::Uuid;
88 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+};
1012 use crate::transport::Transport;
1113
1214 pub mod realme_t310;
@@ -121,6 +123,13 @@ pub trait Session: Send {
121123 Err(Error::Unsupported("these buds cannot ring through budctl".to_owned()))
122124 }
123125
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+
124133 /// Turn an on/off setting ([`Switch::key`]) on or off, then read it back.
125134 fn set_switch(&mut self, key: &str, _on: bool) -> Result<Switch> {
126135 Err(Error::Unsupported(format!("budctl cannot change {key} on these buds")))
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -1,10 +1,11 @@
11 //! realme Buds T310 commands, tables and pure decoders. No I/O here, so every function can be
22 //! tested with bytes from the spec. Each constant cites docs/protocol/realme_t310.md.
33
4+use crate::bt::BdAddr;
45 use crate::error::{Error, Result};
56 use crate::reader::Reader;
67 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,
89 };
910
1011 /// Spec: realme_t310.md#bitmap
@@ -56,6 +57,16 @@ pub(super) fn find_payload(ring: bool) -> [u8; 1] {
5657 pub(super) const EQ_PRESET: u16 = 0x010F;
5758 /// Spec: realme_t310.md#codec
5859 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;
5970 /// Spec: realme_t310.md#custom-eq
6071 pub(super) const CUSTOM_EQ_LIST: u16 = 0x0122;
6172 /// Spec: realme_t310.md#custom-eq
@@ -196,6 +207,7 @@ fn gate(cmd: u16) -> Option<Gate> {
196207 0x010F | 0x0406 => Gate::Bit(10),
197208 0x0114 => Gate::Bit(17),
198209 0x0205 => Gate::Bit(22),
210+ 0x0112 => Gate::Bit(29),
199211 0x0122 | 0x0418 => Gate::Bit(34),
200212 0x0124 | 0x041B => Gate::Bit(38),
201213 _ => return None,
@@ -328,6 +340,32 @@ pub(super) fn decode_codec(reply: &[u8]) -> Result<String> {
328340 Ok(name_of(&CODECS, codec).map_or_else(|| format!("unknown (0x{codec:02X})"), str::to_owned))
329341 }
330342
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+
331369 /// Spec: realme_t310.md#anc-read
332370 pub(super) fn decode_noise_mode(reply: &[u8]) -> Result<NoiseControl> {
333371 let mask = noise_mask(reply, NOISE_MODE_QUERY, "the noise-control reply")?;
@@ -863,13 +901,16 @@ mod tests {
863901 let commands = Commands::parse(&hex(T310_BITMAP_REPLY)).unwrap();
864902 // Every gated command in the T310's logged command set (realme_t310.md#bitmap-t310).
865903 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,
868906 ] {
869907 assert!(commands.allows(cmd), "0x{cmd:04X} should be allowed");
870908 }
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+ ] {
873914 assert!(!commands.allows(cmd), "0x{cmd:04X} should be refused");
874915 }
875916 }
@@ -1054,6 +1095,40 @@ mod tests {
10541095 assert_eq!(decode_codec(&hex("00 09")).unwrap(), "unknown (0x09)");
10551096 }
10561097
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+
10571132 #[test]
10581133 fn gesture_table_names_known_values() {
10591134 let gestures = decode_gestures(&hex("00 03 01 01 02 01 02 01 04 08 04 06 02 1D")).unwrap();
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -10,8 +10,8 @@ use crate::bt::Uuid;
1010 use crate::devices::{Device, Session};
1111 use crate::error::{Error, Kind, Result};
1212 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,
1515 };
1616 use crate::transport::Transport;
1717
@@ -522,6 +522,16 @@ impl Session for T310Session {
522522 }
523523
524524 /// 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+
525535 fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> {
526536 let Some(id) = codec::switch_id(key) else {
527537 return Err(Error::Unsupported(format!("these buds have no switch called {key}")));
@@ -1155,6 +1165,39 @@ mod tests {
11551165 assert!(matches!(result, Err(Error::Unsupported(_))));
11561166 }
11571167
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+
11581201 /// The owner's gesture table (HW1) and the same table with the left triple tap on volume up.
11591202 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";
11601203 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";
M crates/budctl-core/src/reader.rs View
@@ -52,7 +52,8 @@ impl<'a> Reader<'a> {
5252 self.bytes.is_empty()
5353 }
5454
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]> {
5657 let (head, tail) = self.bytes.split_first_chunk::<N>().ok_or_else(|| self.too_short())?;
5758 self.bytes = tail;
5859 Ok(*head)
M crates/budctl-core/src/status.rs View
@@ -3,6 +3,8 @@
33
44 use serde::{Serialize, Serializer};
55
6+use crate::bt::BdAddr;
7+
68 /// Bumped whenever the JSON shape changes incompatibly.
79 pub const JSON_SCHEMA: u32 = 1;
810
@@ -158,6 +160,20 @@ pub struct EqBand {
158160 pub gain: i8,
159161 }
160162
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+
161177 /// A touch-control change: what changes, the write that does it and, once sent, the table the
162178 /// buds report afterwards.
163179 #[derive(Debug, Serialize)]
M crates/budctl-gui/src/buds.rs View
@@ -1,8 +1,10 @@
11 //! The buds the window shows: made-up ones for `--demo` (roughly what the owner's T310 reported
22 //! at the last checkpoints), or an empty pair until the real buds answer.
33
4+use budctl_core::bt::BdAddr;
45 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,
68 };
79
810 /// The buds the window shows.
@@ -18,6 +20,8 @@ pub struct Buds {
1820 pub bass_range: Option<(i8, i8)>,
1921 /// The bass level last set from this window. The buds do not report it.
2022 pub bass: Option<i8>,
23+ /// The devices dual connection keeps, when the buds list them.
24+ pub dual: Option<Vec<DualDevice>>,
2125 }
2226
2327 impl Buds {
@@ -182,6 +186,24 @@ pub fn demo() -> Buds {
182186 }),
183187 bass_range: Some((-5, 5)),
184188 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+ ]),
185207 }
186208 }
187209
@@ -195,6 +217,7 @@ pub fn unknown() -> Buds {
195217 touch: None,
196218 bass_range: None,
197219 bass: None,
220+ dual: None,
198221 }
199222 }
200223
M crates/budctl-gui/src/earbuds.rs View
@@ -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.
22
3+use budctl_core::status::DualDevice;
34 use iced::Element;
45 use iced::widget::{column, text};
56
@@ -85,8 +86,74 @@ pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Elem
8586 ];
8687 column![ui::title(look, "Earbud settings")]
8788 .push(dual)
89+ .push(buds.dual.as_deref().map(|list| devices(look, list)))
8890 .push(column![ui::heading(look, "Find my earbuds"), ui::card(look, vec![find])].spacing(10))
8991 .push(column![ui::heading(look, "About these buds"), ui::card(look, facts)].spacing(10))
9092 .spacing(18)
9193 .into()
9294 }
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+}
M crates/budctl-gui/src/icons.rs View
@@ -30,6 +30,8 @@ pub enum Icon {
3030 Keys,
3131 Pencil,
3232 Trash,
33+ Laptop,
34+ Phone,
3335 }
3436
3537 /// `icon` at `size` pixels, in `color`.
@@ -291,6 +293,14 @@ fn draw_icon(pen: &mut Pen, icon: Icon) {
291293 pen.line((8.0, 14.0), (16.0, 14.0));
292294 }
293295 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+ }
294304 }
295305 }
296306
M crates/budctl-gui/src/main.rs View
@@ -749,6 +749,7 @@ impl App {
749749 self.buds.eq = reading.eq;
750750 self.buds.touch = reading.touch;
751751 self.buds.bass_range = reading.bass_range;
752+ self.buds.dual = reading.dual;
752753 if let Some(level) = reading
753754 .status
754755 .noise_control
@@ -1628,6 +1629,7 @@ mod tests {
16281629 eq: buds::demo().eq,
16291630 touch: buds::demo().touch,
16301631 bass_range: buds::demo().bass_range,
1632+ dual: buds::demo().dual,
16311633 }
16321634 }
16331635
M crates/budctl-gui/src/worker.rs View
@@ -13,7 +13,7 @@ use std::time::{Duration, Instant};
1313 use budctl_core::bt::BdAddr;
1414 use budctl_core::devices::Session;
1515 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,
1717 };
1818 use budctl_core::{Error, Kind, Result};
1919 use iced::futures::channel::mpsc as channel;
@@ -99,6 +99,8 @@ pub struct Reading {
9999 pub touch: Option<TouchControls>,
100100 /// The levels the bass level takes, when the buds have one to set.
101101 pub bass_range: Option<(i8, i8)>,
102+ /// The devices dual connection keeps, when the buds list them.
103+ pub dual: Option<Vec<DualDevice>>,
102104 }
103105
104106 /// The worker, as a stream of events for an iced subscription. `device` picks the buds, as
@@ -147,6 +149,9 @@ struct Worker {
147149 last_command: Instant,
148150 /// How long the session stays open after the last command.
149151 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,
150155 }
151156
152157 impl Worker {
@@ -157,6 +162,7 @@ impl Worker {
157162 events,
158163 last_command: Instant::now(),
159164 idle,
165+ dual_unanswered: false,
160166 }
161167 }
162168
@@ -268,16 +274,26 @@ impl Worker {
268274 }
269275
270276 fn read(&mut self) -> Result<Reading> {
277+ let ask_dual = !self.dual_unanswered;
271278 let session = self.session()?;
272279 let status = session.status()?;
273280 let eq = session.eq_presets().ok();
274281 let touch = session.touch_controls().ok();
275282 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+ };
276291 Ok(Reading {
277292 status,
278293 eq,
279294 touch,
280295 bass_range,
296+ dual,
281297 })
282298 }
283299
@@ -316,6 +332,8 @@ mod tests {
316332 closed: Arc<AtomicBool>,
317333 /// Make `serve` fail, as when the buds drop the link.
318334 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>,
319337 }
320338
321339 struct Fake(Probe);
@@ -325,6 +343,11 @@ mod tests {
325343 Ok(Status::default())
326344 }
327345
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+
328351 fn serve(&mut self) -> Result<()> {
329352 self.0.served.fetch_add(1, Ordering::SeqCst);
330353 if self.0.drop_link.load(Ordering::SeqCst) {
@@ -396,6 +419,25 @@ mod tests {
396419 thread.join().unwrap();
397420 }
398421
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+
399441 #[test]
400442 fn a_dropped_link_is_reported_once() {
401443 let probe = Probe::default();
M docs/protocol/realme_t310.md View
@@ -131,6 +131,7 @@ Gate table for every command budctl uses or documents here:
131131 | `0x010F` EQ read, `0x0406` EQ select | bit 10 |
132132 | `0x0114` current codec | bit 17 |
133133 | `0x0205` subscribe | bit 22 |
134+| `0x0112` dual-connection devices | bit 29 |
134135 | `0x0122` custom EQ list, `0x0418` custom EQ write | bit 34 |
135136 | `0x0124` bass level read, `0x041B` bass level write | bit 38 |
136137
@@ -150,6 +151,7 @@ Gate table for every command budctl uses or documents here:
150151 - `0x0133`/`0x042D` tap sensitivity (bit 61).
151152 - `0x0423` game sound (bit 49).
152153 - `0x012A`/`0x0422` spatial type (bit 47).
154+ - `0x0429`/`0x0132` dual-connection device manager (bit 59): connect, disconnect, unpair, preferred device ([§9.10](#dual-manager)).
153155
154156 <a id="init"></a>
155157 ## 6. Session start
@@ -207,6 +209,7 @@ budctl reads none of these yet. It skips them while waiting for replies (and pri
207209 | `03` | noise control | `01 01 <mask LE>` = new mode; `02 <kind> <mask>` = new hold cycle (not a mode) |
208210 | `04` | fit-test result | `(<dev> <result>)…` |
209211 | `05` | game mode | `<state>`. **The T310 never sends this** (*t310*) |
212+ | `06` | dual-connection devices | `<count> <entries…>`, as in [§9.10](#dual-devices) (*buds4*) |
210213 | `F1` | a gesture fired | `<side> <button> <act> <fn> …`. Diagnostic only |
211214
212215 - **`0x0503`** `<count> (<id> <value>)…`: switch values changed (*vendor*, other realme models).
@@ -359,6 +362,31 @@ Evidence: *vendor*
359362 - **Both buds.** On realme buds one setting covers both buds (kind `01`).
360363 - **When it applies.** The cycle is used only where a bud's hold gesture is `fn 08`.
361364
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+
362390 <a id="writes"></a>
363391 ## 10. Writes
364392
@@ -527,7 +555,7 @@ Evidence: *vendor*
527555
528556 - **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.
529557 - **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)).
531559
532560 <a id="open"></a>
533561 ## 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
553581 - Run `budctl list`, `budctl --debug status`, `budctl --json status` and `budctl --debug touch`.
554582 - 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).
555583
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+
556586 **Writes, one per checkpoint, in this order:**
557587
558588 | Step | Write |