Krolyxon
Commit e9c4031

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-27 17:09 +0530
Parent
90075f3
Commit
e9c4031a3173da290c9aa51a4b616eeddb187210

+244 −8

8 files changed

  1. M crates/budctl-cli/src/main.rs +28 −1
  2. M crates/budctl-cli/src/output.rs +24 −1
  3. M crates/budctl-core/src/devices/mod.rs +8 −1
  4. M crates/budctl-core/src/devices/realme_t310/codec.rs +55 −1
  5. M crates/budctl-core/src/devices/realme_t310/mod.rs +101 −1
  6. M crates/budctl-core/src/error.rs +4 −1
  7. M crates/budctl-core/src/status.rs +19 −0
  8. M docs/protocol/realme_t310.md +5 −2
M crates/budctl-cli/src/main.rs View
@@ -9,8 +9,9 @@ use std::io::{self, Write};
99 use std::process::ExitCode;
1010
1111 use budctl_core::bt::BdAddr;
12+use budctl_core::status::NoiseMode;
1213 use budctl_core::{Error, Kind};
13−use clap::{Parser, Subcommand};
14+use clap::{Parser, Subcommand, ValueEnum};
1415
1516 /// Control wireless earbuds from Linux, without the vendor app.
1617 #[derive(Parser)]
@@ -40,6 +41,28 @@ enum Command {
4041 Status,
4142 /// Touch controls (gesture table) of each bud.
4243 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+ }
4366 }
4467
4568 fn main() -> ExitCode {
@@ -92,6 +115,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
92115 let touch = session.touch_controls()?;
93116 output::touch(out, &device, model, &touch, cli.json)
94117 }
118+ Command::Anc { mode } => {
119+ let now = session.set_noise_control(mode.into())?;
120+ output::noise_control(out, &device, model, &now, cli.json)
121+ }
95122 }
96123 }
97124
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, Status, TouchControls};
7+use budctl_core::status::{Charge, JSON_SCHEMA, NoiseControl, Status, TouchControls};
88 use budctl_core::{Error, Found};
99 use serde_json::Value;
1010
@@ -185,3 +185,26 @@ pub fn touch(
185185 warnings(out, &touch.warnings)?;
186186 Ok(())
187187 }
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+}
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::{Status, TouchControls};
9+use crate::status::{NoiseControl, NoiseMode, Status, TouchControls};
1010 use crate::transport::Transport;
1111
1212 pub mod realme_t310;
@@ -40,6 +40,13 @@ pub trait Session: Send {
4040 "these buds have no touch-control settings budctl can read".to_owned(),
4141 ))
4242 }
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+ }
4350 }
4451
4552 /// The supported model a paired device is, if any.
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -3,7 +3,7 @@
33
44 use crate::error::{Error, Result};
55 use crate::reader::Reader;
6−use crate::status::{Battery, Charge, Gesture, NoiseControl, Switch};
6+use crate::status::{Battery, Charge, Gesture, NoiseControl, NoiseMode, Switch};
77
88 /// Spec: realme_t310.md#bitmap
99 pub(super) const HANDSHAKE: u16 = 0x0100;
@@ -19,6 +19,8 @@ pub(super) const GESTURES: u16 = 0x0108;
1919 pub(super) const NOISE_CONTROL: u16 = 0x010C;
2020 /// Spec: realme_t310.md#switches
2121 pub(super) const SWITCHES: u16 = 0x010D;
22+/// Spec: realme_t310.md#anc-write
23+pub(super) const NOISE_CONTROL_WRITE: u16 = 0x0404;
2224 /// Spec: realme_t310.md#eq-read
2325 pub(super) const EQ_PRESET: u16 = 0x010F;
2426 /// Spec: realme_t310.md#codec
@@ -293,6 +295,36 @@ pub(super) fn decode_noise_mode(reply: &[u8]) -> Result<NoiseControl> {
293295 })
294296 }
295297
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+
296328 /// Spec: realme_t310.md#hold-cycle
297329 pub(super) fn decode_hold_cycle(reply: &[u8]) -> Result<Vec<String>> {
298330 let mask = noise_mask(reply, HOLD_CYCLE_QUERY, "the hold-cycle reply")?;
@@ -482,6 +514,28 @@ mod tests {
482514 ));
483515 }
484516
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+
485539 #[test]
486540 fn hold_cycle_lists_modes() {
487541 assert_eq!(decode_hold_cycle(&hex("00 02 01 0A")).unwrap(), ["transparency", "anc"]);
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::{Status, TouchControls};
12+use crate::status::{NoiseControl, NoiseMode, Status, TouchControls};
1313 use crate::transport::Transport;
1414
1515 mod codec;
@@ -19,6 +19,12 @@ 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.
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+
2228 /// Spec: realme_t310.md#identity
2329 const BLUETOOTH_NAME: &str = "realme Buds T310";
2430
@@ -165,6 +171,38 @@ impl Session for T310Session {
165171 })
166172 }
167173
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+
168206 fn touch_controls(&mut self) -> Result<TouchControls> {
169207 if !self.commands.allows(codec::GESTURES) {
170208 return Err(Error::Unsupported(
@@ -409,4 +447,66 @@ mod tests {
409447 assert_eq!(touch.hold_cycle.unwrap(), ["transparency", "anc"]);
410448 assert!(touch.warnings.is_empty());
411449 }
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+ }
412512 }
M crates/budctl-core/src/error.rs View
@@ -29,6 +29,8 @@ pub enum Error {
2929 Timeout(String),
3030 /// The buds answered with a non-zero status byte.
3131 Rejected { what: String, status: u8 },
32+ /// The buds accepted a write, but reading back shows the old value.
33+ NotApplied(String),
3234 /// The buds sent something budctl cannot parse.
3335 Protocol(String),
3436 /// Any other operating-system error.
@@ -59,7 +61,7 @@ impl Error {
5961 | Error::Bluez(_)
6062 | Error::Os { .. } => Kind::NotFound,
6163 Error::ChannelBusy(_) => Kind::Busy,
62− Error::Timeout(_) | Error::Rejected { .. } | Error::Protocol(_) => Kind::Failed,
64+ Error::Timeout(_) | Error::Rejected { .. } | Error::NotApplied(_) | Error::Protocol(_) => Kind::Failed,
6365 }
6466 }
6567 }
@@ -84,6 +86,7 @@ impl fmt::Display for Error {
8486 Error::Rejected { what, status } => {
8587 write!(f, "the buds rejected {what} (status 0x{status:02X})")
8688 }
89+ Error::NotApplied(msg) => f.write_str(msg),
8790 Error::Protocol(msg) => write!(f, "unexpected data from the buds: {msg}"),
8891 Error::Os { context, source } => write!(f, "{context}: {source}"),
8992 }
M crates/budctl-core/src/status.rs View
@@ -45,6 +45,25 @@ pub struct NoiseControl {
4545 pub level: Option<String>,
4646 }
4747
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+
4867 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
4968 pub struct Switch {
5069 /// Stable name used on the command line and in JSON (`game`, `dual`…).
M docs/protocol/realme_t310.md View
@@ -361,11 +361,12 @@ Evidence: *vendor*
361361 <a id="writes"></a>
362362 ## 10. Writes
363363
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.
365365
366366 **General rule**:
367367 - A write reply is `0x84xx <status …>`, and `00` means accepted.
368368 - 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.
369370
370371 <a id="anc-write"></a>
371372 ### 10.1 Noise control: `0x0404 01 01 <mask>`
@@ -374,6 +375,7 @@ Evidence: *vendor*
374375 - **Reply:** `0x8404 <status>`.
375376 - Status `0E` means no bud is in an ear (*buds4*). The T310 has no in-ear sensor (*t310*), so it may never send it.
376377 - **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.
377379
378380 <a id="switch-write"></a>
379381 ### 10.2 Switches: `0x0403 <id> <01|00>`
@@ -466,7 +468,7 @@ Evidence: *vendor*
466468 2. ~~The real bitmap bytes, the product-id reply and the offered events.~~ Answered at HW1; see [§5](#bitmap-t310) and [§6](#events-offered).
467469 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.
468470 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).
470472 6. ~~Whether the T310 answers id `1A` (wind).~~ It does (HW1). What id `05` means is still open: it reads `00`.
471473 7. How long after the game-mode ack the new value shows up in `0x010D`.
472474 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
505507
506508 | Date | Checkpoint | Result | Notes |
507509 |---|---|---|---|
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) |
508511 | 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 |
509512
510513 **HW1, 2026-10-27** (budctl 0.1.0, `--debug` output pasted by the owner):