budctle9c4031
budctl anc off|on|transparency (W1)
This is the first write command. It sends 0x0404 01 01 <mask> (off 01,
transparency 02, on 08) and waits for the 0x8404 00 ack. Then it reads
0x010C back, again every 300 ms for up to 2 s, until the new mode shows.
If it never shows, the command fails with exit 4 ("accepted but not
applied").
Verified on the owner's T310 (W1, 2026-10-27): every mode was acked,
the first read-back already showed it, and the buds' voice prompts
matched. The spec records the read-back policy (#write-read-back) and
the result.
No new dependencies.
+244 −8
8 files changed
- M crates/budctl-cli/src/main.rs +28 −1
- M crates/budctl-cli/src/output.rs +24 −1
- M crates/budctl-core/src/devices/mod.rs +8 −1
- M crates/budctl-core/src/devices/realme_t310/codec.rs +55 −1
- M crates/budctl-core/src/devices/realme_t310/mod.rs +101 −1
- M crates/budctl-core/src/error.rs +4 −1
- M crates/budctl-core/src/status.rs +19 −0
- M docs/protocol/realme_t310.md +5 −2
| @@ -9,8 +9,9 @@ use std::io::{self, Write}; | |||
| 9 | 9 | use std::process::ExitCode; | |
| 10 | 10 | ||
| 11 | 11 | use budctl_core::bt::BdAddr; | |
| 12 | + | use budctl_core::status::NoiseMode; | |
| 12 | 13 | use budctl_core::{Error, Kind}; | |
| 13 | − | use clap::{Parser, Subcommand}; | |
| 14 | + | use clap::{Parser, Subcommand, ValueEnum}; | |
| 14 | 15 | ||
| 15 | 16 | /// Control wireless earbuds from Linux, without the vendor app. | |
| 16 | 17 | #[derive(Parser)] | |
| @@ -40,6 +41,28 @@ enum Command { | |||
| 40 | 41 | Status, | |
| 41 | 42 | /// Touch controls (gesture table) of each bud. | |
| 42 | 43 | Touch, | |
| 44 | + | /// Set noise control, then read it back. | |
| 45 | + | Anc { | |
| 46 | + | #[arg(value_enum)] | |
| 47 | + | mode: AncArg, | |
| 48 | + | }, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | #[derive(Clone, Copy, ValueEnum)] | |
| 52 | + | enum AncArg { | |
| 53 | + | Off, | |
| 54 | + | On, | |
| 55 | + | Transparency, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl From<AncArg> for NoiseMode { | |
| 59 | + | fn from(arg: AncArg) -> Self { | |
| 60 | + | match arg { | |
| 61 | + | AncArg::Off => NoiseMode::Off, | |
| 62 | + | AncArg::On => NoiseMode::Anc, | |
| 63 | + | AncArg::Transparency => NoiseMode::Transparency, | |
| 64 | + | } | |
| 65 | + | } | |
| 43 | 66 | } | |
| 44 | 67 | ||
| 45 | 68 | fn main() -> ExitCode { | |
| @@ -92,6 +115,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 92 | 115 | let touch = session.touch_controls()?; | |
| 93 | 116 | output::touch(out, &device, model, &touch, cli.json) | |
| 94 | 117 | } | |
| 118 | + | Command::Anc { mode } => { | |
| 119 | + | let now = session.set_noise_control(mode.into())?; | |
| 120 | + | output::noise_control(out, &device, model, &now, cli.json) | |
| 121 | + | } | |
| 95 | 122 | } | |
| 96 | 123 | } | |
| 97 | 124 | ||
| @@ -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, Status, TouchControls}; | |
| 7 | + | use budctl_core::status::{Charge, JSON_SCHEMA, NoiseControl, Status, TouchControls}; | |
| 8 | 8 | use budctl_core::{Error, Found}; | |
| 9 | 9 | use serde_json::Value; | |
| 10 | 10 | ||
| @@ -185,3 +185,26 @@ pub fn touch( | |||
| 185 | 185 | warnings(out, &touch.warnings)?; | |
| 186 | 186 | Ok(()) | |
| 187 | 187 | } | |
| 188 | + | ||
| 189 | + | /// The noise-control mode after a change, as the buds report it on reading back. | |
| 190 | + | pub fn noise_control( | |
| 191 | + | out: &mut impl Write, | |
| 192 | + | device: &PairedDevice, | |
| 193 | + | model: &dyn Device, | |
| 194 | + | now: &NoiseControl, | |
| 195 | + | as_json: bool, | |
| 196 | + | ) -> Result<(), Failure> { | |
| 197 | + | if as_json { | |
| 198 | + | return json( | |
| 199 | + | out, | |
| 200 | + | Some((device, model)), | |
| 201 | + | serde_json::json!({ "noise_control": serde_json::to_value(now)? }), | |
| 202 | + | ); | |
| 203 | + | } | |
| 204 | + | header(out, device, model)?; | |
| 205 | + | match &now.level { | |
| 206 | + | Some(level) => writeln!(out, " Noise control {} ({level})", now.mode)?, | |
| 207 | + | None => writeln!(out, " Noise control {}", now.mode)?, | |
| 208 | + | } | |
| 209 | + | Ok(()) | |
| 210 | + | } | |
| @@ -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::{Status, TouchControls}; | |
| 9 | + | use crate::status::{NoiseControl, NoiseMode, Status, TouchControls}; | |
| 10 | 10 | use crate::transport::Transport; | |
| 11 | 11 | ||
| 12 | 12 | pub mod realme_t310; | |
| @@ -40,6 +40,13 @@ pub trait Session: Send { | |||
| 40 | 40 | "these buds have no touch-control settings budctl can read".to_owned(), | |
| 41 | 41 | )) | |
| 42 | 42 | } | |
| 43 | + | ||
| 44 | + | /// Set the noise-control mode, then read it back. Returns the mode the buds now report. | |
| 45 | + | fn set_noise_control(&mut self, _mode: NoiseMode) -> Result<NoiseControl> { | |
| 46 | + | Err(Error::Unsupported( | |
| 47 | + | "these buds have no noise-control setting budctl can change".to_owned(), | |
| 48 | + | )) | |
| 49 | + | } | |
| 43 | 50 | } | |
| 44 | 51 | ||
| 45 | 52 | /// The supported model a paired device is, if any. | |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use crate::error::{Error, Result}; | |
| 5 | 5 | use crate::reader::Reader; | |
| 6 | − | use crate::status::{Battery, Charge, Gesture, NoiseControl, Switch}; | |
| 6 | + | use crate::status::{Battery, Charge, Gesture, NoiseControl, NoiseMode, Switch}; | |
| 7 | 7 | ||
| 8 | 8 | /// Spec: realme_t310.md#bitmap | |
| 9 | 9 | pub(super) const HANDSHAKE: u16 = 0x0100; | |
| @@ -19,6 +19,8 @@ pub(super) const GESTURES: u16 = 0x0108; | |||
| 19 | 19 | pub(super) const NOISE_CONTROL: u16 = 0x010C; | |
| 20 | 20 | /// Spec: realme_t310.md#switches | |
| 21 | 21 | pub(super) const SWITCHES: u16 = 0x010D; | |
| 22 | + | /// Spec: realme_t310.md#anc-write | |
| 23 | + | pub(super) const NOISE_CONTROL_WRITE: u16 = 0x0404; | |
| 22 | 24 | /// Spec: realme_t310.md#eq-read | |
| 23 | 25 | pub(super) const EQ_PRESET: u16 = 0x010F; | |
| 24 | 26 | /// Spec: realme_t310.md#codec | |
| @@ -293,6 +295,36 @@ pub(super) fn decode_noise_mode(reply: &[u8]) -> Result<NoiseControl> { | |||
| 293 | 295 | }) | |
| 294 | 296 | } | |
| 295 | 297 | ||
| 298 | + | /// The mask that selects `mode`, as written and as read back. Spec: realme_t310.md#anc-write | |
| 299 | + | pub(super) fn noise_write_mask(mode: NoiseMode) -> u32 { | |
| 300 | + | match mode { | |
| 301 | + | NoiseMode::Off => 0x01, | |
| 302 | + | NoiseMode::Transparency => 0x02, | |
| 303 | + | // Plain "noise cancelling on" is the deep level. | |
| 304 | + | NoiseMode::Anc => 0x08, | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | /// `01 01 <mask>`: the noise-mode kind, then the mask in the fewest bytes (all fit in one). | |
| 309 | + | /// Spec: realme_t310.md#anc-write | |
| 310 | + | pub(super) fn noise_write_payload(mode: NoiseMode) -> Vec<u8> { | |
| 311 | + | let mut payload = NOISE_MODE_QUERY.to_vec(); | |
| 312 | + | let mask = noise_write_mask(mode).to_le_bytes(); | |
| 313 | + | let used = mask.iter().rposition(|&b| b != 0).map_or(1, |last| last + 1); | |
| 314 | + | payload.extend(mask.iter().take(used)); | |
| 315 | + | payload | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | /// The mask in a noise-control read reply. Spec: realme_t310.md#anc-read | |
| 319 | + | pub(super) fn decode_noise_mask(reply: &[u8]) -> Result<u32> { | |
| 320 | + | noise_mask(reply, NOISE_MODE_QUERY, "the noise-control reply") | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /// A write reply: `00` means accepted. Spec: realme_t310.md#writes | |
| 324 | + | pub(super) fn check_write_reply(reply: &[u8], what: &'static str) -> Result<()> { | |
| 325 | + | ok_reply(reply, what).map(|_| ()) | |
| 326 | + | } | |
| 327 | + | ||
| 296 | 328 | /// Spec: realme_t310.md#hold-cycle | |
| 297 | 329 | pub(super) fn decode_hold_cycle(reply: &[u8]) -> Result<Vec<String>> { | |
| 298 | 330 | let mask = noise_mask(reply, HOLD_CYCLE_QUERY, "the hold-cycle reply")?; | |
| @@ -482,6 +514,28 @@ mod tests { | |||
| 482 | 514 | )); | |
| 483 | 515 | } | |
| 484 | 516 | ||
| 517 | + | #[test] | |
| 518 | + | fn noise_control_write_payloads() { | |
| 519 | + | assert_eq!(noise_write_payload(NoiseMode::Off), hex("01 01 01")); | |
| 520 | + | assert_eq!(noise_write_payload(NoiseMode::Transparency), hex("01 01 02")); | |
| 521 | + | assert_eq!(noise_write_payload(NoiseMode::Anc), hex("01 01 08")); | |
| 522 | + | // The family's documented frame for "off" (realme_t310.md#frame-whole). | |
| 523 | + | let frame = | |
| 524 | + | crate::devices::realme_t310::frame::encode(NOISE_CONTROL_WRITE, 0x06, &noise_write_payload(NoiseMode::Off)) | |
| 525 | + | .unwrap(); | |
| 526 | + | assert_eq!(frame, hex("AA 0A 00 00 04 04 06 03 00 01 01 01")); | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | #[test] | |
| 530 | + | fn write_replies() { | |
| 531 | + | assert!(check_write_reply(&hex("00"), "the write").is_ok()); | |
| 532 | + | assert!(matches!( | |
| 533 | + | check_write_reply(&hex("0E"), "the write"), | |
| 534 | + | Err(Error::Rejected { status: 0x0E, .. }) | |
| 535 | + | )); | |
| 536 | + | assert!(check_write_reply(&[], "the write").is_err()); | |
| 537 | + | } | |
| 538 | + | ||
| 485 | 539 | #[test] | |
| 486 | 540 | fn hold_cycle_lists_modes() { | |
| 487 | 541 | assert_eq!(decode_hold_cycle(&hex("00 02 01 0A")).unwrap(), ["transparency", "anc"]); | |
| @@ -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::{Status, TouchControls}; | |
| 12 | + | use crate::status::{NoiseControl, NoiseMode, Status, TouchControls}; | |
| 13 | 13 | use crate::transport::Transport; | |
| 14 | 14 | ||
| 15 | 15 | mod codec; | |
| @@ -19,6 +19,12 @@ 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. | |
| 23 | + | /// Spec: realme_t310.md#write-read-back | |
| 24 | + | const READ_BACK_WINDOW: Duration = Duration::from_secs(2); | |
| 25 | + | /// Spec: realme_t310.md#write-read-back | |
| 26 | + | const READ_BACK_PAUSE: Duration = Duration::from_millis(300); | |
| 27 | + | ||
| 22 | 28 | /// Spec: realme_t310.md#identity | |
| 23 | 29 | const BLUETOOTH_NAME: &str = "realme Buds T310"; | |
| 24 | 30 | ||
| @@ -165,6 +171,38 @@ impl Session for T310Session { | |||
| 165 | 171 | }) | |
| 166 | 172 | } | |
| 167 | 173 | ||
| 174 | + | /// Spec: realme_t310.md#anc-write, #write-read-back | |
| 175 | + | fn set_noise_control(&mut self, mode: NoiseMode) -> Result<NoiseControl> { | |
| 176 | + | if !self.commands.allows(codec::NOISE_CONTROL_WRITE) || !self.commands.allows(codec::NOISE_CONTROL) { | |
| 177 | + | return Err(Error::Unsupported( | |
| 178 | + | "these buds do not offer noise-control settings".to_owned(), | |
| 179 | + | )); | |
| 180 | + | } | |
| 181 | + | let reply = self | |
| 182 | + | .link | |
| 183 | + | .request(codec::NOISE_CONTROL_WRITE, &codec::noise_write_payload(mode))?; | |
| 184 | + | codec::check_write_reply(&reply, "the noise-control change")?; | |
| 185 | + | ||
| 186 | + | // Accepted is not applied: read back until the new mode shows, or give up. | |
| 187 | + | 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)?; | |
| 196 | + | return Err(Error::NotApplied(format!( | |
| 197 | + | "the buds accepted noise control {}, but still report {}", | |
| 198 | + | mode.name(), | |
| 199 | + | now.mode | |
| 200 | + | ))); | |
| 201 | + | } | |
| 202 | + | self.link.pump_until(Instant::now() + READ_BACK_PAUSE)?; | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 168 | 206 | fn touch_controls(&mut self) -> Result<TouchControls> { | |
| 169 | 207 | if !self.commands.allows(codec::GESTURES) { | |
| 170 | 208 | return Err(Error::Unsupported( | |
| @@ -409,4 +447,66 @@ mod tests { | |||
| 409 | 447 | assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]); | |
| 410 | 448 | assert!(touch.warnings.is_empty()); | |
| 411 | 449 | } | |
| 450 | + | ||
| 451 | + | #[test] | |
| 452 | + | fn sets_noise_control_and_reads_it_back() { | |
| 453 | + | let mut steps = handshake(); | |
| 454 | + | steps.extend([ | |
| 455 | + | tx(0x0404, 0x05, "01 01 02"), | |
| 456 | + | rx(0x8404, 0x05, "00"), | |
| 457 | + | // The buds announce the change; it needs no answer. | |
| 458 | + | rx(0x0204, 0x10, "03 01 01 02"), | |
| 459 | + | tx(0x010C, 0x06, "01 01"), | |
| 460 | + | rx(0x810C, 0x06, "00 01 01 02"), | |
| 461 | + | ]); | |
| 462 | + | let script = Script::new(steps); | |
| 463 | + | let now = open(&script) | |
| 464 | + | .unwrap() | |
| 465 | + | .set_noise_control(NoiseMode::Transparency) | |
| 466 | + | .unwrap(); | |
| 467 | + | script.assert_done(); | |
| 468 | + | assert_eq!((now.mode.as_str(), now.level), ("transparency", None)); | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | #[test] | |
| 472 | + | fn reads_back_again_until_the_change_shows() { | |
| 473 | + | let mut steps = handshake(); | |
| 474 | + | steps.extend([ | |
| 475 | + | tx(0x0404, 0x05, "01 01 01"), | |
| 476 | + | rx(0x8404, 0x05, "00"), | |
| 477 | + | tx(0x010C, 0x06, "01 01"), | |
| 478 | + | rx(0x810C, 0x06, "00 01 01 08"), | |
| 479 | + | tx(0x010C, 0x07, "01 01"), | |
| 480 | + | rx(0x810C, 0x07, "00 01 01 01"), | |
| 481 | + | ]); | |
| 482 | + | let script = Script::new(steps); | |
| 483 | + | let now = open(&script).unwrap().set_noise_control(NoiseMode::Off).unwrap(); | |
| 484 | + | script.assert_done(); | |
| 485 | + | assert_eq!(now.mode, "off"); | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | #[test] | |
| 489 | + | fn a_rejected_noise_control_change_is_not_read_back() { | |
| 490 | + | let mut steps = handshake(); | |
| 491 | + | steps.extend([tx(0x0404, 0x05, "01 01 08"), rx(0x8404, 0x05, "0E")]); | |
| 492 | + | let script = Script::new(steps); | |
| 493 | + | let result = open(&script).unwrap().set_noise_control(NoiseMode::Anc); | |
| 494 | + | script.assert_done(); | |
| 495 | + | assert!(matches!(result, Err(Error::Rejected { status: 0x0E, .. }))); | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | #[test] | |
| 499 | + | fn noise_control_is_not_written_without_its_bitmap_bit() { | |
| 500 | + | let script = Script::new(vec![ | |
| 501 | + | tx(0x0100, 0x01, ""), | |
| 502 | + | rx(0x8100, 0x01, "00"), | |
| 503 | + | tx(0x0103, 0x02, ""), | |
| 504 | + | rx(0x8103, 0x02, "00 12 3C 06"), | |
| 505 | + | tx(0x0200, 0x03, ""), | |
| 506 | + | rx(0x8200, 0x03, "00 00"), | |
| 507 | + | ]); | |
| 508 | + | let result = open(&script).unwrap().set_noise_control(NoiseMode::Off); | |
| 509 | + | script.assert_done(); | |
| 510 | + | assert!(matches!(result, Err(Error::Unsupported(_)))); | |
| 511 | + | } | |
| 412 | 512 | } | |
| @@ -29,6 +29,8 @@ pub enum Error { | |||
| 29 | 29 | Timeout(String), | |
| 30 | 30 | /// The buds answered with a non-zero status byte. | |
| 31 | 31 | Rejected { what: String, status: u8 }, | |
| 32 | + | /// The buds accepted a write, but reading back shows the old value. | |
| 33 | + | NotApplied(String), | |
| 32 | 34 | /// The buds sent something budctl cannot parse. | |
| 33 | 35 | Protocol(String), | |
| 34 | 36 | /// Any other operating-system error. | |
| @@ -59,7 +61,7 @@ impl Error { | |||
| 59 | 61 | | Error::Bluez(_) | |
| 60 | 62 | | Error::Os { .. } => Kind::NotFound, | |
| 61 | 63 | Error::ChannelBusy(_) => Kind::Busy, | |
| 62 | − | Error::Timeout(_) | Error::Rejected { .. } | Error::Protocol(_) => Kind::Failed, | |
| 64 | + | Error::Timeout(_) | Error::Rejected { .. } | Error::NotApplied(_) | Error::Protocol(_) => Kind::Failed, | |
| 63 | 65 | } | |
| 64 | 66 | } | |
| 65 | 67 | } | |
| @@ -84,6 +86,7 @@ impl fmt::Display for Error { | |||
| 84 | 86 | Error::Rejected { what, status } => { | |
| 85 | 87 | write!(f, "the buds rejected {what} (status 0x{status:02X})") | |
| 86 | 88 | } | |
| 89 | + | Error::NotApplied(msg) => f.write_str(msg), | |
| 87 | 90 | Error::Protocol(msg) => write!(f, "unexpected data from the buds: {msg}"), | |
| 88 | 91 | Error::Os { context, source } => write!(f, "{context}: {source}"), | |
| 89 | 92 | } | |
| @@ -45,6 +45,25 @@ pub struct NoiseControl { | |||
| 45 | 45 | pub level: Option<String>, | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | + | /// A noise-control mode to set. Levels come with W2. | |
| 49 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 50 | + | pub enum NoiseMode { | |
| 51 | + | Off, | |
| 52 | + | Transparency, | |
| 53 | + | Anc, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | impl NoiseMode { | |
| 57 | + | /// The name [`NoiseControl::mode`] uses for it. | |
| 58 | + | pub fn name(self) -> &'static str { | |
| 59 | + | match self { | |
| 60 | + | NoiseMode::Off => "off", | |
| 61 | + | NoiseMode::Transparency => "transparency", | |
| 62 | + | NoiseMode::Anc => "anc", | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 48 | 67 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] | |
| 49 | 68 | pub struct Switch { | |
| 50 | 69 | /// Stable name used on the command line and in JSON (`game`, `dual`…). | |
| @@ -361,11 +361,12 @@ Evidence: *vendor* | |||
| 361 | 361 | <a id="writes"></a> | |
| 362 | 362 | ## 10. Writes | |
| 363 | 363 | ||
| 364 | − | **Not sent by budctl yet.** Each write is enabled only after its hardware checkpoint passes (see the [roadmap](#roadmap)). | |
| 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 others are not sent yet. | |
| 365 | 365 | ||
| 366 | 366 | **General rule**: | |
| 367 | 367 | - A write reply is `0x84xx <status …>`, and `00` means accepted. | |
| 368 | 368 | - Accepted is not the same as applied. **Always read the state back** and compare it. | |
| 369 | + | - <a id="write-read-back"></a>**How budctl reads back** (budctl's own policy, not a protocol fact): it reads once right after the ack. If the old value is still there, it reads again every 300 ms for up to 2 s. If the value has still not changed, the command fails with exit 4 ("accepted but not applied"). A write that has its own timing, such as game mode ([§10.2](#switch-write)), says so in its section. | |
| 369 | 370 | ||
| 370 | 371 | <a id="anc-write"></a> | |
| 371 | 372 | ### 10.1 Noise control: `0x0404 01 01 <mask>` | |
| @@ -374,6 +375,7 @@ Evidence: *vendor* | |||
| 374 | 375 | - **Reply:** `0x8404 <status>`. | |
| 375 | 376 | - Status `0E` means no bud is in an ear (*buds4*). The T310 has no in-ear sensor (*t310*), so it may never send it. | |
| 376 | 377 | - **Check:** read `0x010C 01 01` again. | |
| 378 | + | - **On the T310** (*ours-2026-10-27*, W1): payloads `01 01 01`, `01 01 02` and `01 01 08` each got `0x8404 00` within 20-80 ms. The first read-back, about 200 ms later, already showed the new mask. The buds played their own voice prompts ("Normal Mode", "Transparent Mode", "Noise Cancelling"). They sent no event-`03` push for the change, although it was subscribed. | |
| 377 | 379 | ||
| 378 | 380 | <a id="switch-write"></a> | |
| 379 | 381 | ### 10.2 Switches: `0x0403 <id> <01|00>` | |
| @@ -466,7 +468,7 @@ Evidence: *vendor* | |||
| 466 | 468 | 2. ~~The real bitmap bytes, the product-id reply and the offered events.~~ Answered at HW1; see [§5](#bitmap-t310) and [§6](#events-offered). | |
| 467 | 469 | 3. Partly answered at HW1: no `0x0500`, but an unanswered `0x0501` with no reset ([§7](#buds-requests)). `F2`/`F4` were offered but not subscribed, so none arrived. What `0x0501` asks for is unknown. | |
| 468 | 470 | 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). | |
| 469 | − | 5. The width of the noise-control value, and whether `08` reads back as deep. | |
| 471 | + | 5. The width of the noise-control value: one byte so far, on reads and writes. `08` reads back as `08` after writing it (W1), but whether `08` is the deep level or plain "on" is still open (W2). | |
| 470 | 472 | 6. ~~Whether the T310 answers id `1A` (wind).~~ It does (HW1). What id `05` means is still open: it reads `00`. | |
| 471 | 473 | 7. How long after the game-mode ack the new value shows up in `0x010D`. | |
| 472 | 474 | 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. | |
| @@ -505,6 +507,7 @@ Ask for an Android HCI snoop of realme Link before W11, and for any write that d | |||
| 505 | 507 | ||
| 506 | 508 | | Date | Checkpoint | Result | Notes | | |
| 507 | 509 | |---|---|---|---| | |
| 510 | + | | 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) | | |
| 508 | 511 | | 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 | | |
| 509 | 512 | ||
| 510 | 513 | **HW1, 2026-10-27** (budctl 0.1.0, `--debug` output pasted by the owner): | |