Krolyxon
Commit 39f4a39

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-27 17:23 +0530
Parent
801bf1e
Commit
39f4a396932ac6fc1b4e8865c770ad3ee7bcb4f7

+111 −1

6 files changed

  1. M crates/budctl-cli/src/main.rs +6 −0
  2. M crates/budctl-cli/src/output.rs +16 −0
  3. M crates/budctl-core/src/devices/mod.rs +7 −0
  4. M crates/budctl-core/src/devices/realme_t310/codec.rs +25 −0
  5. M crates/budctl-core/src/devices/realme_t310/mod.rs +54 −0
  6. M docs/protocol/realme_t310.md +3 −1
M crates/budctl-cli/src/main.rs View
@@ -46,6 +46,8 @@ enum Command {
4646 #[arg(value_enum)]
4747 mode: AncArg,
4848 },
49+ /// Select an EQ preset (pure-raw, deep-bass, pure-vocals, pure-bass), then read it back.
50+ Eq { preset: String },
4951 /// Turn game mode (low latency) on or off, then read it back.
5052 Game {
5153 #[arg(value_enum)]
@@ -136,6 +138,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
136138 let now = session.set_noise_control(mode.into())?;
137139 output::noise_control(out, &device, model, &now, cli.json)
138140 }
141+ Command::Eq { ref preset } => {
142+ let now = session.set_eq(preset)?;
143+ output::eq(out, &device, model, &now, cli.json)
144+ }
139145 Command::Game { state } => {
140146 let now = session.set_switch("game", matches!(state, OnOff::On))?;
141147 output::switch(out, &device, model, &now, cli.json)
M crates/budctl-cli/src/output.rs View
@@ -229,3 +229,19 @@ pub fn switch(
229229 writeln!(out, " {} {}", now.label, if now.on { "on" } else { "off" })?;
230230 Ok(())
231231 }
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+}
M crates/budctl-core/src/devices/mod.rs View
@@ -48,6 +48,13 @@ pub trait Session: Send {
4848 ))
4949 }
5050
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+
5158 /// Turn an on/off setting ([`Switch::key`]) on or off, then read it back.
5259 fn set_switch(&mut self, key: &str, _on: bool) -> Result<Switch> {
5360 Err(Error::Unsupported(format!("budctl cannot change {key} on these buds")))
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -23,6 +23,8 @@ pub(super) const SWITCHES: u16 = 0x010D;
2323 pub(super) const NOISE_CONTROL_WRITE: u16 = 0x0404;
2424 /// Spec: realme_t310.md#switch-write
2525 pub(super) const SWITCH_WRITE: u16 = 0x0403;
26+/// Spec: realme_t310.md#eq-write
27+pub(super) const EQ_SELECT: u16 = 0x0406;
2628 /// Spec: realme_t310.md#eq-read
2729 pub(super) const EQ_PRESET: u16 = 0x010F;
2830 /// Spec: realme_t310.md#codec
@@ -385,6 +387,16 @@ pub(super) fn switch_write_payload(id: u8, on: bool) -> [u8; 2] {
385387 [id, u8::from(on)]
386388 }
387389
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+
388400 /// Spec: realme_t310.md#eq-read
389401 pub(super) fn decode_eq(reply: &[u8]) -> Result<String> {
390402 let id = ok_reply(reply, "the EQ reply")?.u8()?;
@@ -558,6 +570,19 @@ mod tests {
558570 assert_eq!(switch_write_payload(0x06, false), [0x06, 0x00]);
559571 }
560572
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+
561586 #[test]
562587 fn write_replies() {
563588 assert!(check_write_reply(&hex("00"), "the write").is_ok());
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -260,6 +260,32 @@ impl Session for T310Session {
260260 )
261261 }
262262
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+
263289 /// Spec: realme_t310.md#switch-write, #write-read-back
264290 fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> {
265291 let Some(id) = codec::switch_id(key) else {
@@ -667,4 +693,32 @@ mod tests {
667693 script.assert_done();
668694 assert!(matches!(result, Err(Error::Rejected { status: 0x01, .. })));
669695 }
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+ }
670724 }
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 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.
365365
366366 **General rule**:
367367 - A write reply is `0x84xx <status …>`, and `00` means accepted.
@@ -398,6 +398,7 @@ Evidence: *vendor*
398398
399399 - **Reply:** `0x8406 <status>`.
400400 - **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.
401402
402403 <a id="bass-write"></a>
403404 ### 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
511512
512513 | Date | Checkpoint | Result | Notes |
513514 |---|---|---|---|
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) |
514516 | 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) |
515517 | 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) |
516518 | 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) |