budctl39f4a39
budctl eq <preset> (W4)
This selects one of the four built-in EQ presets with 0x0406 <id> (pure-raw 00, deep-bass 01, pure-vocals 02, pure-bass 03), gated by bitmap bit 10, and reads 0x010F back. An unknown name is a usage error (exit 1) that lists the presets. Custom EQs come with W11. Verified on the owner's T310 (W4, 2026-10-27): every preset was acked and read back on the first try, and they sound clearly different. No new dependencies.
+111 −1
6 files changed
- M crates/budctl-cli/src/main.rs +6 −0
- M crates/budctl-cli/src/output.rs +16 −0
- M crates/budctl-core/src/devices/mod.rs +7 −0
- M crates/budctl-core/src/devices/realme_t310/codec.rs +25 −0
- M crates/budctl-core/src/devices/realme_t310/mod.rs +54 −0
- M docs/protocol/realme_t310.md +3 −1
| @@ -46,6 +46,8 @@ enum Command { | |||
| 46 | 46 | #[arg(value_enum)] | |
| 47 | 47 | mode: AncArg, | |
| 48 | 48 | }, | |
| 49 | + | /// Select an EQ preset (pure-raw, deep-bass, pure-vocals, pure-bass), then read it back. | |
| 50 | + | Eq { preset: String }, | |
| 49 | 51 | /// Turn game mode (low latency) on or off, then read it back. | |
| 50 | 52 | Game { | |
| 51 | 53 | #[arg(value_enum)] | |
| @@ -136,6 +138,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 136 | 138 | let now = session.set_noise_control(mode.into())?; | |
| 137 | 139 | output::noise_control(out, &device, model, &now, cli.json) | |
| 138 | 140 | } | |
| 141 | + | Command::Eq { ref preset } => { | |
| 142 | + | let now = session.set_eq(preset)?; | |
| 143 | + | output::eq(out, &device, model, &now, cli.json) | |
| 144 | + | } | |
| 139 | 145 | Command::Game { state } => { | |
| 140 | 146 | let now = session.set_switch("game", matches!(state, OnOff::On))?; | |
| 141 | 147 | output::switch(out, &device, model, &now, cli.json) | |
| @@ -229,3 +229,19 @@ pub fn switch( | |||
| 229 | 229 | writeln!(out, " {} {}", now.label, if now.on { "on" } else { "off" })?; | |
| 230 | 230 | Ok(()) | |
| 231 | 231 | } | |
| 232 | + | ||
| 233 | + | /// The EQ preset after a change, as the buds report it on reading back. | |
| 234 | + | pub fn eq( | |
| 235 | + | out: &mut impl Write, | |
| 236 | + | device: &PairedDevice, | |
| 237 | + | model: &dyn Device, | |
| 238 | + | now: &str, | |
| 239 | + | as_json: bool, | |
| 240 | + | ) -> Result<(), Failure> { | |
| 241 | + | if as_json { | |
| 242 | + | return json(out, Some((device, model)), serde_json::json!({ "eq": now })); | |
| 243 | + | } | |
| 244 | + | header(out, device, model)?; | |
| 245 | + | writeln!(out, " EQ {now}")?; | |
| 246 | + | Ok(()) | |
| 247 | + | } | |
| @@ -48,6 +48,13 @@ pub trait Session: Send { | |||
| 48 | 48 | )) | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | /// Select a built-in EQ preset by name, then read it back. Returns the preset now in use. | |
| 52 | + | fn set_eq(&mut self, _preset: &str) -> Result<String> { | |
| 53 | + | Err(Error::Unsupported( | |
| 54 | + | "budctl cannot change the EQ on these buds".to_owned(), | |
| 55 | + | )) | |
| 56 | + | } | |
| 57 | + | ||
| 51 | 58 | /// Turn an on/off setting ([`Switch::key`]) on or off, then read it back. | |
| 52 | 59 | fn set_switch(&mut self, key: &str, _on: bool) -> Result<Switch> { | |
| 53 | 60 | Err(Error::Unsupported(format!("budctl cannot change {key} on these buds"))) | |
| @@ -23,6 +23,8 @@ pub(super) const SWITCHES: u16 = 0x010D; | |||
| 23 | 23 | pub(super) const NOISE_CONTROL_WRITE: u16 = 0x0404; | |
| 24 | 24 | /// Spec: realme_t310.md#switch-write | |
| 25 | 25 | pub(super) const SWITCH_WRITE: u16 = 0x0403; | |
| 26 | + | /// Spec: realme_t310.md#eq-write | |
| 27 | + | pub(super) const EQ_SELECT: u16 = 0x0406; | |
| 26 | 28 | /// Spec: realme_t310.md#eq-read | |
| 27 | 29 | pub(super) const EQ_PRESET: u16 = 0x010F; | |
| 28 | 30 | /// Spec: realme_t310.md#codec | |
| @@ -385,6 +387,16 @@ pub(super) fn switch_write_payload(id: u8, on: bool) -> [u8; 2] { | |||
| 385 | 387 | [id, u8::from(on)] | |
| 386 | 388 | } | |
| 387 | 389 | ||
| 390 | + | /// The id of a built-in EQ preset, by name. Spec: realme_t310.md#eq-read | |
| 391 | + | pub(super) fn eq_id(name: &str) -> Option<u8> { | |
| 392 | + | EQ_PRESETS.iter().find(|(_, n)| *n == name).map(|(id, _)| *id) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | /// The built-in EQ presets' names, in id order. Spec: realme_t310.md#eq-read | |
| 396 | + | pub(super) fn eq_names() -> Vec<&'static str> { | |
| 397 | + | EQ_PRESETS.iter().map(|(_, name)| *name).collect() | |
| 398 | + | } | |
| 399 | + | ||
| 388 | 400 | /// Spec: realme_t310.md#eq-read | |
| 389 | 401 | pub(super) fn decode_eq(reply: &[u8]) -> Result<String> { | |
| 390 | 402 | let id = ok_reply(reply, "the EQ reply")?.u8()?; | |
| @@ -558,6 +570,19 @@ mod tests { | |||
| 558 | 570 | assert_eq!(switch_write_payload(0x06, false), [0x06, 0x00]); | |
| 559 | 571 | } | |
| 560 | 572 | ||
| 573 | + | #[test] | |
| 574 | + | fn eq_presets_by_name() { | |
| 575 | + | assert_eq!(eq_id("pure-raw"), Some(0x00)); | |
| 576 | + | assert_eq!(eq_id("pure-bass"), Some(0x03)); | |
| 577 | + | assert_eq!(eq_id("custom-7"), None); | |
| 578 | + | assert_eq!(eq_names(), ["pure-raw", "deep-bass", "pure-vocals", "pure-bass"]); | |
| 579 | + | // Every preset reads back under the name it was selected by. | |
| 580 | + | for name in eq_names() { | |
| 581 | + | let reply = [0x00, eq_id(name).unwrap()]; | |
| 582 | + | assert_eq!(decode_eq(&reply).unwrap(), name); | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | ||
| 561 | 586 | #[test] | |
| 562 | 587 | fn write_replies() { | |
| 563 | 588 | assert!(check_write_reply(&hex("00"), "the write").is_ok()); | |
| @@ -260,6 +260,32 @@ impl Session for T310Session { | |||
| 260 | 260 | ) | |
| 261 | 261 | } | |
| 262 | 262 | ||
| 263 | + | /// Built-in presets only; custom EQs come with W11. Spec: realme_t310.md#eq-write | |
| 264 | + | fn set_eq(&mut self, preset: &str) -> Result<String> { | |
| 265 | + | let Some(id) = codec::eq_id(preset) else { | |
| 266 | + | return Err(Error::Usage(format!( | |
| 267 | + | "no EQ preset called {preset}; these buds have: {}", | |
| 268 | + | codec::eq_names().join(", ") | |
| 269 | + | ))); | |
| 270 | + | }; | |
| 271 | + | if !self.commands.allows(codec::EQ_SELECT) || !self.commands.allows(codec::EQ_PRESET) { | |
| 272 | + | return Err(Error::Unsupported("these buds do not offer EQ presets".to_owned())); | |
| 273 | + | } | |
| 274 | + | let reply = self.link.request(codec::EQ_SELECT, &[id])?; | |
| 275 | + | codec::check_write_reply(&reply, "the EQ change")?; | |
| 276 | + | ||
| 277 | + | self.read_back( | |
| 278 | + | READ_BACK, | |
| 279 | + | codec::EQ_PRESET, | |
| 280 | + | &[], | |
| 281 | + | |reply| { | |
| 282 | + | let now = codec::decode_eq(reply)?; | |
| 283 | + | Ok((now == preset, now)) | |
| 284 | + | }, | |
| 285 | + | |now| format!("the buds accepted EQ {preset}, but still report {now}"), | |
| 286 | + | ) | |
| 287 | + | } | |
| 288 | + | ||
| 263 | 289 | /// Spec: realme_t310.md#switch-write, #write-read-back | |
| 264 | 290 | fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> { | |
| 265 | 291 | let Some(id) = codec::switch_id(key) else { | |
| @@ -667,4 +693,32 @@ mod tests { | |||
| 667 | 693 | script.assert_done(); | |
| 668 | 694 | assert!(matches!(result, Err(Error::Rejected { status: 0x01, .. }))); | |
| 669 | 695 | } | |
| 696 | + | ||
| 697 | + | #[test] | |
| 698 | + | fn selects_an_eq_preset_and_reads_it_back() { | |
| 699 | + | let mut steps = handshake(); | |
| 700 | + | steps.extend([ | |
| 701 | + | tx(0x0406, 0x05, "01"), | |
| 702 | + | rx(0x8406, 0x05, "00"), | |
| 703 | + | tx(0x010F, 0x06, ""), | |
| 704 | + | rx(0x810F, 0x06, "00 01"), | |
| 705 | + | ]); | |
| 706 | + | let script = Script::new(steps); | |
| 707 | + | let now = open(&script).unwrap().set_eq("deep-bass").unwrap(); | |
| 708 | + | script.assert_done(); | |
| 709 | + | assert_eq!(now, "deep-bass"); | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | #[test] | |
| 713 | + | fn an_unknown_eq_preset_is_a_usage_error() { | |
| 714 | + | let mut steps = handshake(); | |
| 715 | + | steps.pop(); | |
| 716 | + | let script = Script::new(steps); | |
| 717 | + | let result = open(&script).unwrap().set_eq("loudness"); | |
| 718 | + | script.assert_done(); | |
| 719 | + | match result { | |
| 720 | + | Err(Error::Usage(message)) => assert!(message.contains("pure-raw, deep-bass, pure-vocals, pure-bass")), | |
| 721 | + | other => panic!("expected a usage error, got {other:?}"), | |
| 722 | + | } | |
| 723 | + | } | |
| 670 | 724 | } | |
| @@ -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 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. | |
| 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*), and [§10.3](#eq-write) the built-in EQ presets (W4, 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. | |
| @@ -398,6 +398,7 @@ Evidence: *vendor* | |||
| 398 | 398 | ||
| 399 | 399 | - **Reply:** `0x8406 <status>`. | |
| 400 | 400 | - **Check:** read `0x010F` again. | |
| 401 | + | - **On the owner's T310** (*ours-2026-10-27*, W4): ids `00`-`03` were each acked with `00` within 20-40 ms and read back on the first try. The owner heard a clear difference between them. The names come from the vendor app; the owner did not check by ear that each sound matched its name. | |
| 401 | 402 | ||
| 402 | 403 | <a id="bass-write"></a> | |
| 403 | 404 | ### 10.4 Bass level: `0x041B FB 05 <level s8>` | |
| @@ -511,6 +512,7 @@ Ask for an Android HCI snoop of realme Link before W11, and for any write that d | |||
| 511 | 512 | ||
| 512 | 513 | | Date | Checkpoint | Result | Notes | | |
| 513 | 514 | |---|---|---|---| | |
| 515 | + | | 2026-10-27 | W4 `eq pure-raw`, `deep-bass`, `pure-vocals`, `pure-bass` | pass | Acked and read back on the first try; the presets sound clearly different. See [§10.3](#eq-write) | | |
| 514 | 516 | | 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) | | |
| 515 | 517 | | 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) | | |
| 516 | 518 | | 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) | | |