Krolyxon
Commit 801bf1e

Branches

Refs →

budctl801bf1e

budctl game on|off (W3)

This is the first switch write: 0x0403 06 <01|00>, gated by bitmap
bit 7. Read-back is now one helper with per-write timing. Noise
control reads at once and gives up after 2 s. Game mode reads 1 s
after the ack, then every 300 ms until 3 s, because the buds apply it
late and never push it.

Session::set_switch is generic, but only switches on the
WRITABLE_SWITCHES list (just `game` for now) are written. Each other
switch is added at its own checkpoint (W5).

Verified on the owner's T310 (W3, 2026-10-27): both writes were acked
within 13 ms, and the 1 s read-back already showed the change. There
was no push and no prompt.

No new dependencies.
Author
krolyxon
Date
2026-10-27 17:20 +0530
Parent
9f993c9
Commit
801bf1e5c64fca6367cdcac43e9c1e110338bdd9

+220 −22

6 files changed

  1. M crates/budctl-cli/src/main.rs +15 −0
  2. M crates/budctl-cli/src/output.rs +22 −1
  3. M crates/budctl-core/src/devices/mod.rs +6 −1
  4. M crates/budctl-core/src/devices/realme_t310/codec.rs +21 −0
  5. M crates/budctl-core/src/devices/realme_t310/mod.rs +151 −17
  6. M docs/protocol/realme_t310.md +5 −3
M crates/budctl-cli/src/main.rs View
@@ -46,6 +46,17 @@ enum Command {
4646 #[arg(value_enum)]
4747 mode: AncArg,
4848 },
49+ /// Turn game mode (low latency) on or off, then read it back.
50+ Game {
51+ #[arg(value_enum)]
52+ state: OnOff,
53+ },
54+}
55+
56+#[derive(Clone, Copy, ValueEnum)]
57+enum OnOff {
58+ On,
59+ Off,
4960 }
5061
5162 #[derive(Clone, Copy, ValueEnum)]
@@ -125,6 +136,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
125136 let now = session.set_noise_control(mode.into())?;
126137 output::noise_control(out, &device, model, &now, cli.json)
127138 }
139+ Command::Game { state } => {
140+ let now = session.set_switch("game", matches!(state, OnOff::On))?;
141+ output::switch(out, &device, model, &now, cli.json)
142+ }
128143 }
129144 }
130145
M crates/budctl-cli/src/output.rs View
@@ -4,7 +4,7 @@ use std::io::{self, Write};
44
55 use budctl_core::bluez::PairedDevice;
66 use budctl_core::devices::Device;
7−use budctl_core::status::{Charge, JSON_SCHEMA, NoiseControl, Status, TouchControls};
7+use budctl_core::status::{Charge, JSON_SCHEMA, NoiseControl, Status, Switch, TouchControls};
88 use budctl_core::{Error, Found};
99 use serde_json::Value;
1010
@@ -208,3 +208,24 @@ pub fn noise_control(
208208 }
209209 Ok(())
210210 }
211+
212+/// One switch after a change, as the buds report it on reading back. JSON uses the same
213+/// `switches` map as `status`.
214+pub fn switch(
215+ out: &mut impl Write,
216+ device: &PairedDevice,
217+ model: &dyn Device,
218+ now: &Switch,
219+ as_json: bool,
220+) -> Result<(), Failure> {
221+ if as_json {
222+ return json(
223+ out,
224+ Some((device, model)),
225+ serde_json::json!({ "switches": { now.key: now.on } }),
226+ );
227+ }
228+ header(out, device, model)?;
229+ writeln!(out, " {} {}", now.label, if now.on { "on" } else { "off" })?;
230+ Ok(())
231+}
M crates/budctl-core/src/devices/mod.rs View
@@ -6,7 +6,7 @@
66 use crate::bluez::PairedDevice;
77 use crate::bt::Uuid;
88 use crate::error::{Error, Result};
9−use crate::status::{NoiseControl, NoiseMode, Status, TouchControls};
9+use crate::status::{NoiseControl, NoiseMode, Status, Switch, TouchControls};
1010 use crate::transport::Transport;
1111
1212 pub mod realme_t310;
@@ -47,6 +47,11 @@ pub trait Session: Send {
4747 "these buds have no noise-control setting budctl can change".to_owned(),
4848 ))
4949 }
50+
51+ /// Turn an on/off setting ([`Switch::key`]) on or off, then read it back.
52+ fn set_switch(&mut self, key: &str, _on: bool) -> Result<Switch> {
53+ Err(Error::Unsupported(format!("budctl cannot change {key} on these buds")))
54+ }
5055 }
5156
5257 /// The supported model a paired device is, if any.
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -21,6 +21,8 @@ pub(super) const NOISE_CONTROL: u16 = 0x010C;
2121 pub(super) const SWITCHES: u16 = 0x010D;
2222 /// Spec: realme_t310.md#anc-write
2323 pub(super) const NOISE_CONTROL_WRITE: u16 = 0x0404;
24+/// Spec: realme_t310.md#switch-write
25+pub(super) const SWITCH_WRITE: u16 = 0x0403;
2426 /// Spec: realme_t310.md#eq-read
2527 pub(super) const EQ_PRESET: u16 = 0x010F;
2628 /// Spec: realme_t310.md#codec
@@ -373,6 +375,16 @@ pub(super) fn decode_switches(reply: &[u8]) -> Result<Vec<Switch>> {
373375 Ok(switches)
374376 }
375377
378+/// The id of a switch, by its key (`game`…). Spec: realme_t310.md#switches
379+pub(super) fn switch_id(key: &str) -> Option<u8> {
380+ SWITCH_NAMES.iter().find(|(_, k, _)| *k == key).map(|(id, _, _)| *id)
381+}
382+
383+/// `<id> <01|00>`. Spec: realme_t310.md#switch-write
384+pub(super) fn switch_write_payload(id: u8, on: bool) -> [u8; 2] {
385+ [id, u8::from(on)]
386+}
387+
376388 /// Spec: realme_t310.md#eq-read
377389 pub(super) fn decode_eq(reply: &[u8]) -> Result<String> {
378390 let id = ok_reply(reply, "the EQ reply")?.u8()?;
@@ -537,6 +549,15 @@ mod tests {
537549 assert_eq!(frame, hex("AA 0A 00 00 04 04 06 03 00 01 01 01"));
538550 }
539551
552+ #[test]
553+ fn switch_writes() {
554+ assert_eq!(switch_id("game"), Some(0x06));
555+ assert_eq!(switch_id("dual"), Some(0x11));
556+ assert_eq!(switch_id("turbo"), None);
557+ assert_eq!(switch_write_payload(0x06, true), [0x06, 0x01]);
558+ assert_eq!(switch_write_payload(0x06, false), [0x06, 0x00]);
559+ }
560+
540561 #[test]
541562 fn write_replies() {
542563 assert!(check_write_reply(&hex("00"), "the write").is_ok());
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -9,7 +9,7 @@ use crate::bluez::PairedDevice;
99 use crate::bt::Uuid;
1010 use crate::devices::{Device, Session};
1111 use crate::error::{Error, Kind, Result};
12−use crate::status::{NoiseControl, NoiseMode, Status, TouchControls};
12+use crate::status::{NoiseControl, NoiseMode, Status, Switch, TouchControls};
1313 use crate::transport::Transport;
1414
1515 mod codec;
@@ -19,12 +19,36 @@ mod link;
1919 use codec::Commands;
2020 use link::Link;
2121
22−/// After a write is acked: how long to keep reading back, and the pause between reads.
22+/// When to read a setting back after the buds ack a write. Spec: realme_t310.md#write-read-back
23+#[derive(Clone, Copy)]
24+struct ReadBack {
25+ /// How long after the ack the first read goes out.
26+ first: Duration,
27+ /// How long after the ack budctl gives up.
28+ window: Duration,
29+}
30+
2331 /// Spec: realme_t310.md#write-read-back
24−const READ_BACK_WINDOW: Duration = Duration::from_secs(2);
32+const READ_BACK: ReadBack = ReadBack {
33+ first: Duration::ZERO,
34+ window: Duration::from_secs(2),
35+};
36+/// Game mode lands about 1 s after the ack. Spec: realme_t310.md#switch-write
37+const GAME_READ_BACK: ReadBack = ReadBack {
38+ first: Duration::from_secs(1),
39+ window: Duration::from_secs(3),
40+};
2541 /// Spec: realme_t310.md#write-read-back
2642 const READ_BACK_PAUSE: Duration = Duration::from_millis(300);
2743
44+/// The switches budctl may write, each added after the previous one passed its hardware
45+/// checkpoint (CLAUDE.md rule 4). Spec: realme_t310.md#switch-write
46+const WRITABLE_SWITCHES: [(&str, ReadBack); 1] = [("game", GAME_READ_BACK)];
47+
48+fn on_off(on: bool) -> &'static str {
49+ if on { "on" } else { "off" }
50+}
51+
2852 /// Spec: realme_t310.md#identity
2953 const BLUETOOTH_NAME: &str = "realme Buds T310";
3054
@@ -132,6 +156,34 @@ impl T310Session {
132156 }
133157 }
134158
159+impl T310Session {
160+ /// After an acked write, read the setting back with `cmd query` until `check` reports the
161+ /// new value, following `timing`. `check` returns whether the value is the one written,
162+ /// and the value; `not_applied` describes a value that never changed.
163+ /// Spec: realme_t310.md#write-read-back
164+ fn read_back<T>(
165+ &mut self,
166+ timing: ReadBack,
167+ cmd: u16,
168+ query: &[u8],
169+ check: impl Fn(&[u8]) -> Result<(bool, T)>,
170+ not_applied: impl Fn(T) -> String,
171+ ) -> Result<T> {
172+ let acked = Instant::now();
173+ self.link.pump_until(acked + timing.first)?;
174+ loop {
175+ let (applied, value) = check(&self.link.request(cmd, query)?)?;
176+ if applied {
177+ return Ok(value);
178+ }
179+ if Instant::now() >= acked + timing.window {
180+ return Err(Error::NotApplied(not_applied(value)));
181+ }
182+ self.link.pump_until(Instant::now() + READ_BACK_PAUSE)?;
183+ }
184+ }
185+}
186+
135187 impl Session for T310Session {
136188 fn status(&mut self) -> Result<Status> {
137189 let mut reads = Reads::default();
@@ -183,16 +235,18 @@ impl Session for T310Session {
183235 .request(codec::NOISE_CONTROL_WRITE, &codec::noise_write_payload(mode))?;
184236 codec::check_write_reply(&reply, "the noise-control change")?;
185237
186− // Accepted is not applied: read back until the new mode shows, or give up.
187238 let wanted = codec::noise_write_mask(mode);
188− let give_up = Instant::now() + READ_BACK_WINDOW;
189− loop {
190− let reply = self.link.request(codec::NOISE_CONTROL, &codec::NOISE_MODE_QUERY)?;
191− if codec::decode_noise_mask(&reply)? == wanted {
192− return codec::decode_noise_mode(&reply);
193− }
194− if Instant::now() >= give_up {
195− let now = codec::decode_noise_mode(&reply)?;
239+ self.read_back(
240+ READ_BACK,
241+ codec::NOISE_CONTROL,
242+ &codec::NOISE_MODE_QUERY,
243+ |reply| {
244+ Ok((
245+ codec::decode_noise_mask(reply)? == wanted,
246+ codec::decode_noise_mode(reply)?,
247+ ))
248+ },
249+ |now| {
196250 let wanted = match mode {
197251 NoiseMode::Anc(level) => format!("anc ({})", level.name()),
198252 _ => mode.name().to_owned(),
@@ -201,12 +255,47 @@ impl Session for T310Session {
201255 Some(level) => format!("{} ({level})", now.mode),
202256 None => now.mode,
203257 };
204− return Err(Error::NotApplied(format!(
205− "the buds accepted noise control {wanted}, but still report {reported}"
206− )));
207− }
208− self.link.pump_until(Instant::now() + READ_BACK_PAUSE)?;
258+ format!("the buds accepted noise control {wanted}, but still report {reported}")
259+ },
260+ )
261+ }
262+
263+ /// Spec: realme_t310.md#switch-write, #write-read-back
264+ fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> {
265+ let Some(id) = codec::switch_id(key) else {
266+ return Err(Error::Unsupported(format!("these buds have no switch called {key}")));
267+ };
268+ let Some(&(_, timing)) = WRITABLE_SWITCHES.iter().find(|(k, _)| *k == key) else {
269+ return Err(Error::Unsupported(format!("budctl cannot change {key} yet")));
270+ };
271+ if !self.commands.allows(codec::SWITCH_WRITE) || !self.commands.allows(codec::SWITCHES) {
272+ return Err(Error::Unsupported("these buds do not offer switch settings".to_owned()));
209273 }
274+ let reply = self
275+ .link
276+ .request(codec::SWITCH_WRITE, &codec::switch_write_payload(id, on))?;
277+ codec::check_write_reply(&reply, "the switch change")?;
278+
279+ self.read_back(
280+ timing,
281+ codec::SWITCHES,
282+ &codec::SWITCH_QUERY,
283+ |reply| {
284+ let switch = codec::decode_switches(reply)?
285+ .into_iter()
286+ .find(|s| s.key == key)
287+ .ok_or_else(|| Error::Protocol(format!("the switch reply has no {key}")))?;
288+ Ok((switch.on == on, switch))
289+ },
290+ |now| {
291+ format!(
292+ "the buds accepted {} {}, but still report {}",
293+ now.label,
294+ on_off(on),
295+ on_off(now.on)
296+ )
297+ },
298+ )
210299 }
211300
212301 fn touch_controls(&mut self) -> Result<TouchControls> {
@@ -533,4 +622,49 @@ mod tests {
533622 script.assert_done();
534623 assert!(matches!(result, Err(Error::Unsupported(_))));
535624 }
625+
626+ #[test]
627+ fn sets_game_mode_and_reads_it_back() {
628+ let mut steps = handshake();
629+ steps.extend([
630+ tx(0x0403, 0x05, "06 01"),
631+ rx(0x8403, 0x05, "00"),
632+ // Still the old value: budctl reads again.
633+ tx(0x010D, 0x06, "06 05 06 11 1A 1B 1D"),
634+ rx(0x810D, 0x06, "00 06 05 00 06 00 11 00 1A 01 1B 00 1D 00"),
635+ tx(0x010D, 0x07, "06 05 06 11 1A 1B 1D"),
636+ rx(0x810D, 0x07, "00 06 05 00 06 01 11 00 1A 01 1B 00 1D 00"),
637+ ]);
638+ let script = Script::new(steps);
639+ let game = open(&script).unwrap().set_switch("game", true).unwrap();
640+ script.assert_done();
641+ assert_eq!((game.key, game.on), ("game", true));
642+ }
643+
644+ #[test]
645+ fn switches_not_yet_verified_are_never_written() {
646+ // Nothing is sent after the handshake, so the subscribe ack is left out.
647+ let mut steps = handshake();
648+ steps.pop();
649+ let script = Script::new(steps);
650+ let mut session = open(&script).unwrap();
651+ for key in ["dual", "wind", "spatial", "bass-boost"] {
652+ assert!(
653+ matches!(session.set_switch(key, true), Err(Error::Unsupported(_))),
654+ "{key}"
655+ );
656+ }
657+ assert!(matches!(session.set_switch("turbo", true), Err(Error::Unsupported(_))));
658+ script.assert_done();
659+ }
660+
661+ #[test]
662+ fn a_switch_the_buds_lack_is_rejected() {
663+ let mut steps = handshake();
664+ steps.extend([tx(0x0403, 0x05, "06 00"), rx(0x8403, 0x05, "01")]);
665+ let script = Script::new(steps);
666+ let result = open(&script).unwrap().set_switch("game", false);
667+ script.assert_done();
668+ assert!(matches!(result, Err(Error::Rejected { status: 0x01, .. })));
669+ }
536670 }
M docs/protocol/realme_t310.md View
@@ -361,7 +361,7 @@ Evidence: *vendor*
361361 <a id="writes"></a>
362362 ## 10. Writes
363363
364−**One at a time.** budctl adds a write only after the previous one passes its hardware checkpoint (see the [roadmap](#roadmap)). Sent so far: [§10.1](#anc-write) off/transparency/on (W1, verified *ours-2026-10-27*), and the light/medium/deep levels (W2, verified *ours-2026-10-27*). The others are not sent yet.
364+**One at a time.** budctl adds a write only after the previous one passes its hardware checkpoint (see the [roadmap](#roadmap)). Sent so far: [§10.1](#anc-write) off/transparency/on (W1, verified *ours-2026-10-27*), the light/medium/deep levels (W2, verified *ours-2026-10-27*), and [§10.2](#switch-write) game mode (W3, verified *ours-2026-10-27*). The others are not sent yet.
365365
366366 **General rule**:
367367 - A write reply is `0x84xx <status …>`, and `00` means accepted.
@@ -387,7 +387,8 @@ Evidence: *vendor*
387387 - `01` the buds lack this id
388388 - `02` or anything else: refused for now
389389 - **Check:** read `0x010D` again.
390−- **Game mode on the T310.** The buds never push the new value (*t310*). On Buds 4 the change lands about 1 s after the ack, and a read sooner still returns the old value (*buds4*). So budctl reads back after about 1 s, and again up to 3 s later.
390+- **Game mode on the T310.** The buds never push the new value (*t310*). On Buds 4 the change lands about 1 s after the ack, and a read sooner still returns the old value (*buds4*). So budctl reads back 1 s after the ack, then every 300 ms until 3 s after the ack.
391+- **Game mode on the owner's T310** (*ours-2026-10-27*, W3): `0x0403 06 01` and `06 00` were acked with `00` within 13 ms. The read 1 s later already showed the new value. No push came, and the buds played no prompt.
391392 - **Dual connection.** Other clients also send `0x0413` afterwards. The T310's bitmap lacks it, so budctl sends only `0x0403 11`. Turning dual connection off may drop the link to one host, so it is tested last.
392393
393394 <a id="eq-write"></a>
@@ -472,7 +473,7 @@ Evidence: *vendor*
472473 4. What "busy" looks like when the phone holds the channel: the connect is refused, the connect succeeds and the buds stay silent, or the buds drop the laptop's link. A local holder gives `EBUSY` (HW1). Until the phone case is tested, budctl also treats a silent handshake as "channel busy" (exit 3).
473474 5. The width of the noise-control value: one byte so far, on reads and writes. `08` is the deep level: light, medium and deep read back as `04`, `10` and `08` (W2).
474475 6. ~~Whether the T310 answers id `1A` (wind).~~ It does (HW1). What id `05` means is still open: it reads `00`.
475−7. How long after the game-mode ack the new value shows up in `0x010D`.
476+7. How long after the game-mode ack the new value shows up in `0x010D`: at most 1 s on the T310 (W3). Whether it is sooner was not measured.
476477 8. The shape of the T310 gesture table is answered at HW1: six entries, `<side> <group> <act> <fn>`, double tap, triple tap and hold for each bud. The names of the EQ presets in realme Link are still open.
477478 9. Whether the minimum 200 ms spacing between sends is needed.
478479 10. Why the T310 ignores `0x0124` although bit 38 is set ([§9.7](#bass-read)). Needs a realme Link capture before W6.
@@ -510,6 +511,7 @@ Ask for an Android HCI snoop of realme Link before W11, and for any write that d
510511
511512 | Date | Checkpoint | Result | Notes |
512513 |---|---|---|---|
514+| 2026-10-27 | W3 `game on`, `game off` | pass | Acked, and the 1 s read-back showed the change. No push or prompt. See [§10.2](#switch-write) |
513515 | 2026-10-27 | W2 `anc light`, `anc medium`, `anc deep`; level memory | pass | Each level acked and read back on the first try. The buds keep the level across a hold-gesture cycle. See [§10.1](#anc-write) |
514516 | 2026-10-27 | W1 `anc transparency`, `anc off`, `anc on` | pass | Acked, read back on the first try, and the buds' voice prompts matched. See [§10.1](#anc-write) |
515517 | 2026-10-27 | HW1 `list`, `status`, `--json status`, `touch`, not-connected path, busy path (local holder) | pass, with fixes | See below. Busy with the phone app as the holder is still untested |