Krolyxon
Commit 9f993c9

Branches

Refs →

budctl9f993c9

budctl anc light|medium|deep (W2)

This adds the noise-cancelling levels: light 04, medium 10, deep 08.
They are written with 0x0404 01 01 <mask> and read back like W1.
`anc on` still writes 08 (deep).

Verified on the owner's T310 (W2, 2026-10-27): each level was acked
and read back on the first try. The buds remember the chosen level
across a hold-gesture cycle. No known command turns noise cancelling
on at that saved level, so `on` stays deep until a realme Link capture
shows how the app does it (spec open question 11).

No new dependencies.
Author
krolyxon
Date
2026-10-27 17:14 +0530
Committer
krolyxon · 2026-10-27 17:15 +0530
Parent
e9c4031
Commit
9f993c9435f0c9f3bc577cf9301ce88d1d1afb45

+82 −18

5 files changed

  1. M crates/budctl-cli/src/main.rs +9 −3
  2. M crates/budctl-core/src/devices/realme_t310/codec.rs +15 −4
  3. M crates/budctl-core/src/devices/realme_t310/mod.rs +29 −5
  4. M crates/budctl-core/src/status.rs +22 −3
  5. M docs/protocol/realme_t310.md +7 −3
M crates/budctl-cli/src/main.rs View
@@ -9,7 +9,7 @@ use std::io::{self, Write};
99 use std::process::ExitCode;
1010
1111 use budctl_core::bt::BdAddr;
12−use budctl_core::status::NoiseMode;
12+use budctl_core::status::{AncLevel, NoiseMode};
1313 use budctl_core::{Error, Kind};
1414 use clap::{Parser, Subcommand, ValueEnum};
1515
@@ -51,15 +51,21 @@ enum Command {
5151 #[derive(Clone, Copy, ValueEnum)]
5252 enum AncArg {
5353 Off,
54− On,
5554 Transparency,
55+ /// Noise cancelling at the deep level (the buds use the same value for both).
56+ On,
57+ Light,
58+ Medium,
59+ Deep,
5660 }
5761
5862 impl From<AncArg> for NoiseMode {
5963 fn from(arg: AncArg) -> Self {
6064 match arg {
6165 AncArg::Off => NoiseMode::Off,
62− AncArg::On => NoiseMode::Anc,
66+ AncArg::On | AncArg::Deep => NoiseMode::Anc(AncLevel::Deep),
67+ AncArg::Light => NoiseMode::Anc(AncLevel::Light),
68+ AncArg::Medium => NoiseMode::Anc(AncLevel::Medium),
6369 AncArg::Transparency => NoiseMode::Transparency,
6470 }
6571 }
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, NoiseMode, Switch};
6+use crate::status::{AncLevel, Battery, Charge, Gesture, NoiseControl, NoiseMode, Switch};
77
88 /// Spec: realme_t310.md#bitmap
99 pub(super) const HANDSHAKE: u16 = 0x0100;
@@ -300,8 +300,10 @@ pub(super) fn noise_write_mask(mode: NoiseMode) -> u32 {
300300 match mode {
301301 NoiseMode::Off => 0x01,
302302 NoiseMode::Transparency => 0x02,
303− // Plain "noise cancelling on" is the deep level.
304− NoiseMode::Anc => 0x08,
303+ NoiseMode::Anc(AncLevel::Light) => 0x04,
304+ NoiseMode::Anc(AncLevel::Medium) => 0x10,
305+ // Also plain "noise cancelling on".
306+ NoiseMode::Anc(AncLevel::Deep) => 0x08,
305307 }
306308 }
307309
@@ -518,7 +520,16 @@ mod tests {
518520 fn noise_control_write_payloads() {
519521 assert_eq!(noise_write_payload(NoiseMode::Off), hex("01 01 01"));
520522 assert_eq!(noise_write_payload(NoiseMode::Transparency), hex("01 01 02"));
521− assert_eq!(noise_write_payload(NoiseMode::Anc), hex("01 01 08"));
523+ assert_eq!(noise_write_payload(NoiseMode::Anc(AncLevel::Light)), hex("01 01 04"));
524+ assert_eq!(noise_write_payload(NoiseMode::Anc(AncLevel::Medium)), hex("01 01 10"));
525+ assert_eq!(noise_write_payload(NoiseMode::Anc(AncLevel::Deep)), hex("01 01 08"));
526+ // What is written is what a read reports: every write mask decodes to its own mode.
527+ for level in [AncLevel::Light, AncLevel::Medium, AncLevel::Deep] {
528+ let mut reply = hex("00 01 01");
529+ reply.extend(noise_write_payload(NoiseMode::Anc(level)).iter().skip(2));
530+ let read = decode_noise_mode(&reply).unwrap();
531+ assert_eq!((read.mode.as_str(), read.level.as_deref()), ("anc", Some(level.name())));
532+ }
522533 // The family's documented frame for "off" (realme_t310.md#frame-whole).
523534 let frame =
524535 crate::devices::realme_t310::frame::encode(NOISE_CONTROL_WRITE, 0x06, &noise_write_payload(NoiseMode::Off))
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -193,10 +193,16 @@ impl Session for T310Session {
193193 }
194194 if Instant::now() >= give_up {
195195 let now = codec::decode_noise_mode(&reply)?;
196+ let wanted = match mode {
197+ NoiseMode::Anc(level) => format!("anc ({})", level.name()),
198+ _ => mode.name().to_owned(),
199+ };
200+ let reported = match &now.level {
201+ Some(level) => format!("{} ({level})", now.mode),
202+ None => now.mode,
203+ };
196204 return Err(Error::NotApplied(format!(
197− "the buds accepted noise control {}, but still report {}",
198− mode.name(),
199− now.mode
205+ "the buds accepted noise control {wanted}, but still report {reported}"
200206 )));
201207 }
202208 self.link.pump_until(Instant::now() + READ_BACK_PAUSE)?;
@@ -229,7 +235,7 @@ impl Session for T310Session {
229235 #[cfg(test)]
230236 mod tests {
231237 use super::*;
232− use crate::status::Charge;
238+ use crate::status::{AncLevel, Charge};
233239 use crate::transport::mock::{Script, Step, hex};
234240
235241 fn tx(cmd: u16, seq: u8, payload: &str) -> Step {
@@ -485,12 +491,30 @@ mod tests {
485491 assert_eq!(now.mode, "off");
486492 }
487493
494+ #[test]
495+ fn sets_a_noise_cancelling_level() {
496+ let mut steps = handshake();
497+ steps.extend([
498+ tx(0x0404, 0x05, "01 01 04"),
499+ rx(0x8404, 0x05, "00"),
500+ tx(0x010C, 0x06, "01 01"),
501+ rx(0x810C, 0x06, "00 01 01 04"),
502+ ]);
503+ let script = Script::new(steps);
504+ let now = open(&script)
505+ .unwrap()
506+ .set_noise_control(NoiseMode::Anc(AncLevel::Light))
507+ .unwrap();
508+ script.assert_done();
509+ assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
510+ }
511+
488512 #[test]
489513 fn a_rejected_noise_control_change_is_not_read_back() {
490514 let mut steps = handshake();
491515 steps.extend([tx(0x0404, 0x05, "01 01 08"), rx(0x8404, 0x05, "0E")]);
492516 let script = Script::new(steps);
493− let result = open(&script).unwrap().set_noise_control(NoiseMode::Anc);
517+ let result = open(&script).unwrap().set_noise_control(NoiseMode::Anc(AncLevel::Deep));
494518 script.assert_done();
495519 assert!(matches!(result, Err(Error::Rejected { status: 0x0E, .. })));
496520 }
M crates/budctl-core/src/status.rs View
@@ -45,12 +45,31 @@ pub struct NoiseControl {
4545 pub level: Option<String>,
4646 }
4747
48−/// A noise-control mode to set. Levels come with W2.
48+/// A noise-control mode to set.
4949 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5050 pub enum NoiseMode {
5151 Off,
5252 Transparency,
53− Anc,
53+ Anc(AncLevel),
54+}
55+
56+/// How strongly noise cancelling works.
57+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58+pub enum AncLevel {
59+ Light,
60+ Medium,
61+ Deep,
62+}
63+
64+impl AncLevel {
65+ /// The name [`NoiseControl::level`] uses for it.
66+ pub fn name(self) -> &'static str {
67+ match self {
68+ AncLevel::Light => "light",
69+ AncLevel::Medium => "medium",
70+ AncLevel::Deep => "deep",
71+ }
72+ }
5473 }
5574
5675 impl NoiseMode {
@@ -59,7 +78,7 @@ impl NoiseMode {
5978 match self {
6079 NoiseMode::Off => "off",
6180 NoiseMode::Transparency => "transparency",
62− NoiseMode::Anc => "anc",
81+ NoiseMode::Anc(_) => "anc",
6382 }
6483 }
6584 }
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*). 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*), and the light/medium/deep levels (W2, 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.
@@ -375,6 +375,8 @@ Evidence: *vendor*
375375 - **Reply:** `0x8404 <status>`.
376376 - Status `0E` means no bud is in an ear (*buds4*). The T310 has no in-ear sensor (*t310*), so it may never send it.
377377 - **Check:** read `0x010C 01 01` again.
378+- **`anc on`.** budctl writes `08` (deep), because no "on at the saved level" value is known, and budctl keeps no device state of its own. The buds do remember the level (*ours-2026-10-27*, W2): after `anc light`, `anc transparency` and a hold gesture, noise cancelling came back as light (`04`). How an app turns noise cancelling on at the saved level is unknown. Writing `08` forces deep, and the saved level cannot be read while noise cancelling is off. Finding out needs an Android HCI snoop of realme Link turning noise cancelling on after a level was chosen ([open questions](#open)).
379+- **Levels on the T310** (*ours-2026-10-27*, W2): `01 01 04` (light), `01 01 10` (medium) and `01 01 08` (deep) were each acked with `00` within 15-30 ms and read back on the first try.
378380 - **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.
379381
380382 <a id="switch-write"></a>
@@ -468,12 +470,13 @@ Evidence: *vendor*
468470 2. ~~The real bitmap bytes, the product-id reply and the offered events.~~ Answered at HW1; see [§5](#bitmap-t310) and [§6](#events-offered).
469471 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.
470472 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).
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).
473+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).
472474 6. ~~Whether the T310 answers id `1A` (wind).~~ It does (HW1). What id `05` means is still open: it reads `00`.
473475 7. How long after the game-mode ack the new value shows up in `0x010D`.
474476 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.
475−10. Why the T310 ignores `0x0124` although bit 38 is set ([§9.7](#bass-read)). Needs a realme Link capture before W6.
476477 9. Whether the minimum 200 ms spacing between sends is needed.
478+10. Why the T310 ignores `0x0124` although bit 38 is set ([§9.7](#bass-read)). Needs a realme Link capture before W6.
479+11. How to turn noise cancelling on at the level the buds saved, without forcing deep ([§10.1](#anc-write)). Needs a realme Link snoop.
477480
478481 <a id="roadmap"></a>
479482 ## 13. Roadmap and hardware checkpoints
@@ -507,6 +510,7 @@ Ask for an Android HCI snoop of realme Link before W11, and for any write that d
507510
508511 | Date | Checkpoint | Result | Notes |
509512 |---|---|---|---|
513+| 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) |
510514 | 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) |
511515 | 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 |
512516