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.
+82 −18
5 files changed
- M crates/budctl-cli/src/main.rs +9 −3
- M crates/budctl-core/src/devices/realme_t310/codec.rs +15 −4
- M crates/budctl-core/src/devices/realme_t310/mod.rs +29 −5
- M crates/budctl-core/src/status.rs +22 −3
- M docs/protocol/realme_t310.md +7 −3
| @@ -9,7 +9,7 @@ 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 | + | use budctl_core::status::{AncLevel, NoiseMode}; | |
| 13 | 13 | use budctl_core::{Error, Kind}; | |
| 14 | 14 | use clap::{Parser, Subcommand, ValueEnum}; | |
| 15 | 15 | ||
| @@ -51,15 +51,21 @@ enum Command { | |||
| 51 | 51 | #[derive(Clone, Copy, ValueEnum)] | |
| 52 | 52 | enum AncArg { | |
| 53 | 53 | Off, | |
| 54 | − | On, | |
| 55 | 54 | Transparency, | |
| 55 | + | /// Noise cancelling at the deep level (the buds use the same value for both). | |
| 56 | + | On, | |
| 57 | + | Light, | |
| 58 | + | Medium, | |
| 59 | + | Deep, | |
| 56 | 60 | } | |
| 57 | 61 | ||
| 58 | 62 | impl From<AncArg> for NoiseMode { | |
| 59 | 63 | fn from(arg: AncArg) -> Self { | |
| 60 | 64 | match arg { | |
| 61 | 65 | 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), | |
| 63 | 69 | AncArg::Transparency => NoiseMode::Transparency, | |
| 64 | 70 | } | |
| 65 | 71 | } | |
| @@ -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, NoiseMode, Switch}; | |
| 6 | + | use crate::status::{AncLevel, Battery, Charge, Gesture, NoiseControl, NoiseMode, Switch}; | |
| 7 | 7 | ||
| 8 | 8 | /// Spec: realme_t310.md#bitmap | |
| 9 | 9 | pub(super) const HANDSHAKE: u16 = 0x0100; | |
| @@ -300,8 +300,10 @@ pub(super) fn noise_write_mask(mode: NoiseMode) -> u32 { | |||
| 300 | 300 | match mode { | |
| 301 | 301 | NoiseMode::Off => 0x01, | |
| 302 | 302 | 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, | |
| 305 | 307 | } | |
| 306 | 308 | } | |
| 307 | 309 | ||
| @@ -518,7 +520,16 @@ mod tests { | |||
| 518 | 520 | fn noise_control_write_payloads() { | |
| 519 | 521 | assert_eq!(noise_write_payload(NoiseMode::Off), hex("01 01 01")); | |
| 520 | 522 | 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 | + | } | |
| 522 | 533 | // The family's documented frame for "off" (realme_t310.md#frame-whole). | |
| 523 | 534 | let frame = | |
| 524 | 535 | crate::devices::realme_t310::frame::encode(NOISE_CONTROL_WRITE, 0x06, &noise_write_payload(NoiseMode::Off)) | |
| @@ -193,10 +193,16 @@ impl Session for T310Session { | |||
| 193 | 193 | } | |
| 194 | 194 | if Instant::now() >= give_up { | |
| 195 | 195 | 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 | + | }; | |
| 196 | 204 | 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}" | |
| 200 | 206 | ))); | |
| 201 | 207 | } | |
| 202 | 208 | self.link.pump_until(Instant::now() + READ_BACK_PAUSE)?; | |
| @@ -229,7 +235,7 @@ impl Session for T310Session { | |||
| 229 | 235 | #[cfg(test)] | |
| 230 | 236 | mod tests { | |
| 231 | 237 | use super::*; | |
| 232 | − | use crate::status::Charge; | |
| 238 | + | use crate::status::{AncLevel, Charge}; | |
| 233 | 239 | use crate::transport::mock::{Script, Step, hex}; | |
| 234 | 240 | ||
| 235 | 241 | fn tx(cmd: u16, seq: u8, payload: &str) -> Step { | |
| @@ -485,12 +491,30 @@ mod tests { | |||
| 485 | 491 | assert_eq!(now.mode, "off"); | |
| 486 | 492 | } | |
| 487 | 493 | ||
| 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 | + | ||
| 488 | 512 | #[test] | |
| 489 | 513 | fn a_rejected_noise_control_change_is_not_read_back() { | |
| 490 | 514 | let mut steps = handshake(); | |
| 491 | 515 | steps.extend([tx(0x0404, 0x05, "01 01 08"), rx(0x8404, 0x05, "0E")]); | |
| 492 | 516 | 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)); | |
| 494 | 518 | script.assert_done(); | |
| 495 | 519 | assert!(matches!(result, Err(Error::Rejected { status: 0x0E, .. }))); | |
| 496 | 520 | } | |
| @@ -45,12 +45,31 @@ pub struct NoiseControl { | |||
| 45 | 45 | pub level: Option<String>, | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | − | /// A noise-control mode to set. Levels come with W2. | |
| 48 | + | /// A noise-control mode to set. | |
| 49 | 49 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 50 | 50 | pub enum NoiseMode { | |
| 51 | 51 | Off, | |
| 52 | 52 | 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 | + | } | |
| 54 | 73 | } | |
| 55 | 74 | ||
| 56 | 75 | impl NoiseMode { | |
| @@ -59,7 +78,7 @@ impl NoiseMode { | |||
| 59 | 78 | match self { | |
| 60 | 79 | NoiseMode::Off => "off", | |
| 61 | 80 | NoiseMode::Transparency => "transparency", | |
| 62 | − | NoiseMode::Anc => "anc", | |
| 81 | + | NoiseMode::Anc(_) => "anc", | |
| 63 | 82 | } | |
| 64 | 83 | } | |
| 65 | 84 | } | |
| @@ -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*). 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. | |
| 365 | 365 | ||
| 366 | 366 | **General rule**: | |
| 367 | 367 | - A write reply is `0x84xx <status …>`, and `00` means accepted. | |
| @@ -375,6 +375,8 @@ Evidence: *vendor* | |||
| 375 | 375 | - **Reply:** `0x8404 <status>`. | |
| 376 | 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. | |
| 377 | 377 | - **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. | |
| 378 | 380 | - **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. | |
| 379 | 381 | ||
| 380 | 382 | <a id="switch-write"></a> | |
| @@ -468,12 +470,13 @@ Evidence: *vendor* | |||
| 468 | 470 | 2. ~~The real bitmap bytes, the product-id reply and the offered events.~~ Answered at HW1; see [§5](#bitmap-t310) and [§6](#events-offered). | |
| 469 | 471 | 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. | |
| 470 | 472 | 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). | |
| 472 | 474 | 6. ~~Whether the T310 answers id `1A` (wind).~~ It does (HW1). What id `05` means is still open: it reads `00`. | |
| 473 | 475 | 7. How long after the game-mode ack the new value shows up in `0x010D`. | |
| 474 | 476 | 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. | |
| 476 | 477 | 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. | |
| 477 | 480 | ||
| 478 | 481 | <a id="roadmap"></a> | |
| 479 | 482 | ## 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 | |||
| 507 | 510 | ||
| 508 | 511 | | Date | Checkpoint | Result | Notes | | |
| 509 | 512 | |---|---|---|---| | |
| 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) | | |
| 510 | 514 | | 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) | | |
| 511 | 515 | | 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 | | |
| 512 | 516 | ||