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.
+220 −22
6 files changed
- M crates/budctl-cli/src/main.rs +15 −0
- M crates/budctl-cli/src/output.rs +22 −1
- M crates/budctl-core/src/devices/mod.rs +6 −1
- M crates/budctl-core/src/devices/realme_t310/codec.rs +21 −0
- M crates/budctl-core/src/devices/realme_t310/mod.rs +151 −17
- M docs/protocol/realme_t310.md +5 −3
| @@ -46,6 +46,17 @@ enum Command { | |||
| 46 | 46 | #[arg(value_enum)] | |
| 47 | 47 | mode: AncArg, | |
| 48 | 48 | }, | |
| 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, | |
| 49 | 60 | } | |
| 50 | 61 | ||
| 51 | 62 | #[derive(Clone, Copy, ValueEnum)] | |
| @@ -125,6 +136,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 125 | 136 | let now = session.set_noise_control(mode.into())?; | |
| 126 | 137 | output::noise_control(out, &device, model, &now, cli.json) | |
| 127 | 138 | } | |
| 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 | + | } | |
| 128 | 143 | } | |
| 129 | 144 | } | |
| 130 | 145 | ||
| @@ -4,7 +4,7 @@ use std::io::{self, Write}; | |||
| 4 | 4 | ||
| 5 | 5 | use budctl_core::bluez::PairedDevice; | |
| 6 | 6 | 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}; | |
| 8 | 8 | use budctl_core::{Error, Found}; | |
| 9 | 9 | use serde_json::Value; | |
| 10 | 10 | ||
| @@ -208,3 +208,24 @@ pub fn noise_control( | |||
| 208 | 208 | } | |
| 209 | 209 | Ok(()) | |
| 210 | 210 | } | |
| 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 | + | } | |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | use crate::bluez::PairedDevice; | |
| 7 | 7 | use crate::bt::Uuid; | |
| 8 | 8 | use crate::error::{Error, Result}; | |
| 9 | − | use crate::status::{NoiseControl, NoiseMode, Status, TouchControls}; | |
| 9 | + | use crate::status::{NoiseControl, NoiseMode, Status, Switch, TouchControls}; | |
| 10 | 10 | use crate::transport::Transport; | |
| 11 | 11 | ||
| 12 | 12 | pub mod realme_t310; | |
| @@ -47,6 +47,11 @@ pub trait Session: Send { | |||
| 47 | 47 | "these buds have no noise-control setting budctl can change".to_owned(), | |
| 48 | 48 | )) | |
| 49 | 49 | } | |
| 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 | + | } | |
| 50 | 55 | } | |
| 51 | 56 | ||
| 52 | 57 | /// The supported model a paired device is, if any. | |
| @@ -21,6 +21,8 @@ pub(super) const NOISE_CONTROL: u16 = 0x010C; | |||
| 21 | 21 | pub(super) const SWITCHES: u16 = 0x010D; | |
| 22 | 22 | /// Spec: realme_t310.md#anc-write | |
| 23 | 23 | pub(super) const NOISE_CONTROL_WRITE: u16 = 0x0404; | |
| 24 | + | /// Spec: realme_t310.md#switch-write | |
| 25 | + | pub(super) const SWITCH_WRITE: u16 = 0x0403; | |
| 24 | 26 | /// Spec: realme_t310.md#eq-read | |
| 25 | 27 | pub(super) const EQ_PRESET: u16 = 0x010F; | |
| 26 | 28 | /// Spec: realme_t310.md#codec | |
| @@ -373,6 +375,16 @@ pub(super) fn decode_switches(reply: &[u8]) -> Result<Vec<Switch>> { | |||
| 373 | 375 | Ok(switches) | |
| 374 | 376 | } | |
| 375 | 377 | ||
| 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 | + | ||
| 376 | 388 | /// Spec: realme_t310.md#eq-read | |
| 377 | 389 | pub(super) fn decode_eq(reply: &[u8]) -> Result<String> { | |
| 378 | 390 | let id = ok_reply(reply, "the EQ reply")?.u8()?; | |
| @@ -537,6 +549,15 @@ mod tests { | |||
| 537 | 549 | assert_eq!(frame, hex("AA 0A 00 00 04 04 06 03 00 01 01 01")); | |
| 538 | 550 | } | |
| 539 | 551 | ||
| 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 | + | ||
| 540 | 561 | #[test] | |
| 541 | 562 | fn write_replies() { | |
| 542 | 563 | assert!(check_write_reply(&hex("00"), "the write").is_ok()); | |
| @@ -9,7 +9,7 @@ use crate::bluez::PairedDevice; | |||
| 9 | 9 | use crate::bt::Uuid; | |
| 10 | 10 | use crate::devices::{Device, Session}; | |
| 11 | 11 | use crate::error::{Error, Kind, Result}; | |
| 12 | − | use crate::status::{NoiseControl, NoiseMode, Status, TouchControls}; | |
| 12 | + | use crate::status::{NoiseControl, NoiseMode, Status, Switch, TouchControls}; | |
| 13 | 13 | use crate::transport::Transport; | |
| 14 | 14 | ||
| 15 | 15 | mod codec; | |
| @@ -19,12 +19,36 @@ mod link; | |||
| 19 | 19 | use codec::Commands; | |
| 20 | 20 | use link::Link; | |
| 21 | 21 | ||
| 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 | + | ||
| 23 | 31 | /// 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 | + | }; | |
| 25 | 41 | /// Spec: realme_t310.md#write-read-back | |
| 26 | 42 | const READ_BACK_PAUSE: Duration = Duration::from_millis(300); | |
| 27 | 43 | ||
| 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 | + | ||
| 28 | 52 | /// Spec: realme_t310.md#identity | |
| 29 | 53 | const BLUETOOTH_NAME: &str = "realme Buds T310"; | |
| 30 | 54 | ||
| @@ -132,6 +156,34 @@ impl T310Session { | |||
| 132 | 156 | } | |
| 133 | 157 | } | |
| 134 | 158 | ||
| 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 | + | ||
| 135 | 187 | impl Session for T310Session { | |
| 136 | 188 | fn status(&mut self) -> Result<Status> { | |
| 137 | 189 | let mut reads = Reads::default(); | |
| @@ -183,16 +235,18 @@ impl Session for T310Session { | |||
| 183 | 235 | .request(codec::NOISE_CONTROL_WRITE, &codec::noise_write_payload(mode))?; | |
| 184 | 236 | codec::check_write_reply(&reply, "the noise-control change")?; | |
| 185 | 237 | ||
| 186 | − | // Accepted is not applied: read back until the new mode shows, or give up. | |
| 187 | 238 | 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| { | |
| 196 | 250 | let wanted = match mode { | |
| 197 | 251 | NoiseMode::Anc(level) => format!("anc ({})", level.name()), | |
| 198 | 252 | _ => mode.name().to_owned(), | |
| @@ -201,12 +255,47 @@ impl Session for T310Session { | |||
| 201 | 255 | Some(level) => format!("{} ({level})", now.mode), | |
| 202 | 256 | None => now.mode, | |
| 203 | 257 | }; | |
| 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())); | |
| 209 | 273 | } | |
| 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 | + | ) | |
| 210 | 299 | } | |
| 211 | 300 | ||
| 212 | 301 | fn touch_controls(&mut self) -> Result<TouchControls> { | |
| @@ -533,4 +622,49 @@ mod tests { | |||
| 533 | 622 | script.assert_done(); | |
| 534 | 623 | assert!(matches!(result, Err(Error::Unsupported(_)))); | |
| 535 | 624 | } | |
| 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 | + | } | |
| 536 | 670 | } | |
| @@ -361,7 +361,7 @@ Evidence: *vendor* | |||
| 361 | 361 | <a id="writes"></a> | |
| 362 | 362 | ## 10. Writes | |
| 363 | 363 | ||
| 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. | |
| 365 | 365 | ||
| 366 | 366 | **General rule**: | |
| 367 | 367 | - A write reply is `0x84xx <status …>`, and `00` means accepted. | |
| @@ -387,7 +387,8 @@ Evidence: *vendor* | |||
| 387 | 387 | - `01` the buds lack this id | |
| 388 | 388 | - `02` or anything else: refused for now | |
| 389 | 389 | - **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. | |
| 391 | 392 | - **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. | |
| 392 | 393 | ||
| 393 | 394 | <a id="eq-write"></a> | |
| @@ -472,7 +473,7 @@ Evidence: *vendor* | |||
| 472 | 473 | 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). | |
| 473 | 474 | 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). | |
| 474 | 475 | 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. | |
| 476 | 477 | 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. | |
| 477 | 478 | 9. Whether the minimum 200 ms spacing between sends is needed. | |
| 478 | 479 | 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 | |||
| 510 | 511 | ||
| 511 | 512 | | Date | Checkpoint | Result | Notes | | |
| 512 | 513 | |---|---|---|---| | |
| 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) | | |
| 513 | 515 | | 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) | | |
| 514 | 516 | | 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) | | |
| 515 | 517 | | 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 | | |