Krolyxon
Commit 54dede1

Branches

Refs →

budctl54dede1

budctl eq lists the presets, custom ones too (W11a); no bass read (W6a)

A plain `budctl eq` now reads the current preset (0x010F) and the
custom EQ list (0x0122, bitmap bit 34). It prints the built-in presets
and each custom one as custom-<id> with its stored name and bands, and
marks the preset in use.

Verified on the owner's T310 (2026-10-27):
- W11a: the list holds one flat preset, "Custom1", id 04. Its 6 bands
  start at 50 Hz, not the 62 Hz of the vendor default, and it is the
  EQ in use.
- W6a: 0x0124 gets no reply with bass boost on either. It is still not
  sent, and its unused decoder is removed.

No new dependencies.
Author
krolyxon
Date
2026-10-27 20:40 +0530
Parent
9c570ea
Commit
54dede165ae9f8333f0fb7971a8d6e662cfc5465

+232 −29

7 files changed

  1. M crates/budctl-cli/src/main.rs +9 −3
  2. M crates/budctl-cli/src/output.rs +42 −1
  3. M crates/budctl-core/src/devices/mod.rs +8 −1
  4. M crates/budctl-core/src/devices/realme_t310/codec.rs +61 −15
  5. M crates/budctl-core/src/devices/realme_t310/mod.rs +59 −3
  6. M crates/budctl-core/src/status.rs +29 −0
  7. M docs/protocol/realme_t310.md +24 −6
M crates/budctl-cli/src/main.rs View
@@ -65,8 +65,8 @@ enum Command {
6565 #[arg(value_enum)]
6666 mode: AncArg,
6767 },
68− /// Select an EQ preset (pure-raw, deep-bass, pure-vocals, pure-bass), then read it back.
69− Eq { preset: String },
68+ /// List the EQ presets, or select one (pure-raw, deep-bass, pure-vocals, pure-bass) and read it back.
69+ Eq { preset: Option<String> },
7070 /// Turn an on/off setting on or off, then read it back. Switches: game, bass-boost, spatial (3D sound), wind, dual.
7171 Set {
7272 /// The switch's name, as `status --json` lists it.
@@ -254,7 +254,13 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
254254 let now = session.set_noise_control(mode.into())?;
255255 output::noise_control(out, &device, model, &now, cli.json)
256256 }
257− Command::Eq { ref preset } => {
257+ Command::Eq { preset: None } => {
258+ let presets = session.eq_presets()?;
259+ output::eq_presets(out, &device, model, &presets, cli.json)
260+ }
261+ Command::Eq {
262+ preset: Some(ref preset),
263+ } => {
258264 let now = session.set_eq(preset)?;
259265 output::eq(out, &device, model, &now, cli.json)
260266 }
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, NoiseControl, Status, Switch, TouchChange, TouchControls};
7+use budctl_core::status::{Charge, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls};
88 use budctl_core::{Error, Found};
99 use serde_json::Value;
1010
@@ -278,6 +278,47 @@ pub fn switch(
278278 Ok(())
279279 }
280280
281+/// Every EQ preset, with the one in use marked `*`.
282+pub fn eq_presets(
283+ out: &mut impl Write,
284+ device: &PairedDevice,
285+ model: &dyn Device,
286+ eq: &EqPresets,
287+ as_json: bool,
288+) -> Result<(), Failure> {
289+ if as_json {
290+ return json(out, Some((device, model)), serde_json::to_value(eq)?);
291+ }
292+ header(out, device, model)?;
293+ let current = eq.current.as_deref();
294+ for preset in eq.presets.iter().filter(|p| !p.custom) {
295+ let mark = if current == Some(preset.name.as_str()) {
296+ "*"
297+ } else {
298+ " "
299+ };
300+ writeln!(out, " {mark} {}", preset.name)?;
301+ }
302+ let custom: Vec<_> = eq.presets.iter().filter(|p| p.custom).collect();
303+ if custom.is_empty() {
304+ writeln!(out, " Custom presets: none")?;
305+ } else {
306+ writeln!(out, " Custom presets:")?;
307+ }
308+ for preset in custom {
309+ let name = format!("custom-{}", preset.id);
310+ let mark = if current == Some(name.as_str()) { "*" } else { " " };
311+ let bands: Vec<String> = preset
312+ .bands
313+ .iter()
314+ .map(|b| format!("{} Hz {:+}", b.hz, b.gain))
315+ .collect();
316+ writeln!(out, " {mark} {name} \"{}\": {}", preset.name, bands.join(", "))?;
317+ }
318+ warnings(out, &eq.warnings)?;
319+ Ok(())
320+}
321+
281322 /// The EQ preset after a change, as the buds report it on reading back.
282323 pub fn eq(
283324 out: &mut impl Write,
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::{NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls};
9+use crate::status::{EqPresets, NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls};
1010 use crate::transport::Transport;
1111
1212 pub mod realme_t310;
@@ -48,6 +48,13 @@ pub trait Session: Send {
4848 ))
4949 }
5050
51+ /// The EQ presets on the buds, built-in and custom, and the one in use.
52+ fn eq_presets(&mut self) -> Result<EqPresets> {
53+ Err(Error::Unsupported(
54+ "budctl cannot list the EQ presets of these buds".to_owned(),
55+ ))
56+ }
57+
5158 /// Select a built-in EQ preset by name, then read it back. Returns the preset now in use.
5259 fn set_eq(&mut self, _preset: &str) -> Result<String> {
5360 Err(Error::Unsupported(
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::{AncLevel, Battery, Charge, Gesture, NoiseControl, NoiseMode, Switch};
6+use crate::status::{AncLevel, Battery, Charge, EqBand, EqPreset, Gesture, NoiseControl, NoiseMode, Switch};
77
88 /// Spec: realme_t310.md#bitmap
99 pub(super) const HANDSHAKE: u16 = 0x0100;
@@ -38,9 +38,8 @@ pub(super) fn find_payload(ring: bool) -> [u8; 1] {
3838 pub(super) const EQ_PRESET: u16 = 0x010F;
3939 /// Spec: realme_t310.md#codec
4040 pub(super) const CODEC: u16 = 0x0114;
41−/// Not sent: the T310 does not answer it (HW1). Kept for W6. Spec: realme_t310.md#bass-read
42−#[allow(dead_code)]
43−pub(super) const BASS_LEVEL: u16 = 0x0124;
41+/// Spec: realme_t310.md#custom-eq
42+pub(super) const CUSTOM_EQ_LIST: u16 = 0x0122;
4443 /// Spec: realme_t310.md#events-offered
4544 pub(super) const EVENTS_OFFERED: u16 = 0x0200;
4645 /// Spec: realme_t310.md#subscribe
@@ -454,15 +453,44 @@ pub(super) fn decode_eq(reply: &[u8]) -> Result<String> {
454453 Ok(name_of(&EQ_PRESETS, id).map_or_else(|| format!("custom-{id}"), str::to_owned))
455454 }
456455
457−/// `00 <min> <max> <level>`, all signed. Not used until W6. Spec: realme_t310.md#bass-read
458−#[allow(dead_code)]
459−pub(super) fn decode_bass_level(reply: &[u8]) -> Result<i8> {
460− let mut r = ok_reply(reply, "the bass-level reply")?;
461− let (min, max, level) = (r.i8()?, r.i8()?, r.i8()?);
462− if !(min..=max).contains(&level) {
463− return Err(Error::Protocol(format!("bass level {level} outside {min}..={max}")));
456+/// The built-in EQ presets, in id order. Spec: realme_t310.md#eq-read
457+pub(super) fn builtin_eqs() -> Vec<EqPreset> {
458+ EQ_PRESETS
459+ .iter()
460+ .map(|&(id, name)| EqPreset {
461+ name: name.to_owned(),
462+ id,
463+ custom: false,
464+ bands: Vec::new(),
465+ })
466+ .collect()
467+}
468+
469+/// `00 <count>`, then per preset `<selected> <min> <max> <id> <nameLen> <name> <bands>
470+/// (<freq u16 LE> <gain s8>)…`. Spec: realme_t310.md#custom-eq
471+pub(super) fn decode_custom_eqs(reply: &[u8]) -> Result<Vec<EqPreset>> {
472+ let mut r = ok_reply(reply, "the custom EQ list")?;
473+ let mut presets = Vec::new();
474+ for _ in 0..r.u8()? {
475+ let (_selected, _min, _max) = (r.u8()?, r.i8()?, r.i8()?);
476+ let id = r.u8()?;
477+ let name_len = r.u8()?;
478+ let name = String::from_utf8_lossy(r.take(usize::from(name_len))?).into_owned();
479+ let mut bands = Vec::new();
480+ for _ in 0..r.u8()? {
481+ bands.push(EqBand {
482+ hz: r.u16_le()?,
483+ gain: r.i8()?,
484+ });
485+ }
486+ presets.push(EqPreset {
487+ name,
488+ id,
489+ custom: true,
490+ bands,
491+ });
464492 }
465− Ok(level)
493+ Ok(presets)
466494 }
467495
468496 /// One gesture-table entry: `<dev> <btn> <act> <fn>`. Spec: realme_t310.md#gestures
@@ -786,14 +814,12 @@ mod tests {
786814 }
787815
788816 #[test]
789− fn eq_codec_and_bass_level() {
817+ fn eq_and_codec() {
790818 assert_eq!(decode_eq(&hex("00 00")).unwrap(), "pure-raw");
791819 assert_eq!(decode_eq(&hex("00 03")).unwrap(), "pure-bass");
792820 assert_eq!(decode_eq(&hex("00 05")).unwrap(), "custom-5");
793821 assert_eq!(decode_codec(&hex("00 02")).unwrap(), "AAC");
794822 assert_eq!(decode_codec(&hex("00 09")).unwrap(), "unknown (0x09)");
795− assert_eq!(decode_bass_level(&hex("00 FB 05 FE")).unwrap(), -2);
796− assert!(decode_bass_level(&hex("00 FB 05 07")).is_err());
797823 }
798824
799825 #[test]
@@ -902,4 +928,24 @@ mod tests {
902928 .unwrap();
903929 assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 04 04 06 03 00 02 01 0B");
904930 }
931+
932+ #[test]
933+ fn custom_eq_list() {
934+ assert!(decode_custom_eqs(&hex("00 00")).unwrap().is_empty());
935+ // One preset "Me", selected, range FA 06, two bands: 62 Hz +3, 16 kHz -2.
936+ let reply = hex("00 01 01 FA 06 04 02 4D 65 02 3E 00 03 80 3E FE");
937+ let presets = decode_custom_eqs(&reply).unwrap();
938+ assert_eq!(
939+ presets,
940+ [EqPreset {
941+ name: "Me".to_owned(),
942+ id: 0x04,
943+ custom: true,
944+ bands: vec![EqBand { hz: 62, gain: 3 }, EqBand { hz: 16000, gain: -2 }],
945+ }]
946+ );
947+ // Cut short inside the bands.
948+ assert!(decode_custom_eqs(&hex("00 01 01 FA 06 04 02 4D 65 02 3E 00")).is_err());
949+ assert_eq!(builtin_eqs().len(), 4);
950+ }
905951 }
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -9,7 +9,9 @@ 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::{GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls};
12+use crate::status::{
13+ EqPresets, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls,
14+};
1315 use crate::transport::Transport;
1416
1517 mod codec;
@@ -214,8 +216,8 @@ impl Session for T310Session {
214216 )?;
215217 let battery = self.read(&mut reads, "battery", codec::BATTERY, &[], codec::decode_battery)?;
216218 let eq = self.read(&mut reads, "EQ", codec::EQ_PRESET, &[], codec::decode_eq)?;
217− // No bass-level read: the T310 does not answer an empty 0x0124 (HW1). It returns with W6.
218− // Spec: realme_t310.md#bass-read
219+ // No bass-level read: the T310 answers 0x0124 neither with bass boost off (HW1) nor on
220+ // (W6a). Spec: realme_t310.md#bass-read
219221 let codec = self.read(&mut reads, "codec", codec::CODEC, &[], codec::decode_codec)?;
220222 let firmware = self.read(&mut reads, "firmware", codec::FIRMWARE, &[], codec::decode_firmware)?;
221223
@@ -271,6 +273,26 @@ impl Session for T310Session {
271273 )
272274 }
273275
276+ /// Spec: realme_t310.md#eq-read, #custom-eq
277+ fn eq_presets(&mut self) -> Result<EqPresets> {
278+ let mut reads = Reads::default();
279+ let current = self.read(&mut reads, "EQ", codec::EQ_PRESET, &[], codec::decode_eq)?;
280+ let custom = self.read(
281+ &mut reads,
282+ "custom EQ list",
283+ codec::CUSTOM_EQ_LIST,
284+ &[],
285+ codec::decode_custom_eqs,
286+ )?;
287+ let mut presets = codec::builtin_eqs();
288+ presets.extend(custom.into_iter().flatten());
289+ Ok(EqPresets {
290+ current,
291+ presets,
292+ warnings: reads.warnings,
293+ })
294+ }
295+
274296 /// Built-in presets only; custom EQs come with W11. Spec: realme_t310.md#eq-write
275297 fn set_eq(&mut self, preset: &str) -> Result<String> {
276298 let Some(id) = codec::eq_id(preset) else {
@@ -1013,4 +1035,38 @@ mod tests {
10131035 script.assert_done();
10141036 assert!(matches!(result, Err(Error::Usage(_))));
10151037 }
1038+
1039+ /// The custom EQ list of the owner's T310 (W11a): one flat preset, "Custom1", id 04.
1040+ const OWNER_CUSTOM_EQS: &str =
1041+ "00 01 01 FA 06 04 07 43 75 73 74 6F 6D 31 06 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00";
1042+
1043+ #[test]
1044+ fn lists_built_in_and_custom_eq_presets() {
1045+ let mut steps = handshake();
1046+ steps.extend([
1047+ tx(0x010F, 0x05, ""),
1048+ rx(0x810F, 0x05, "00 04"),
1049+ tx(0x0122, 0x06, ""),
1050+ // The owner's T310 (W11a).
1051+ rx(0x8122, 0x06, OWNER_CUSTOM_EQS),
1052+ ]);
1053+ let script = Script::new(steps);
1054+ let eq = open(&script).unwrap().eq_presets().unwrap();
1055+ script.assert_done();
1056+ assert_eq!(eq.current.as_deref(), Some("custom-4"));
1057+ let names: Vec<(&str, bool)> = eq.presets.iter().map(|p| (p.name.as_str(), p.custom)).collect();
1058+ assert_eq!(
1059+ names,
1060+ [
1061+ ("pure-raw", false),
1062+ ("deep-bass", false),
1063+ ("pure-vocals", false),
1064+ ("pure-bass", false),
1065+ ("Custom1", true)
1066+ ]
1067+ );
1068+ let bands: Vec<u16> = eq.presets.last().unwrap().bands.iter().map(|b| b.hz).collect();
1069+ assert_eq!(bands, [50, 250, 1000, 4000, 8000, 16000]);
1070+ assert!(eq.warnings.is_empty());
1071+ }
10161072 }
M crates/budctl-core/src/status.rs View
@@ -113,6 +113,35 @@ pub struct Gesture {
113113 pub group: Option<u8>,
114114 }
115115
116+/// The EQ presets the buds have, and which one is in use.
117+#[derive(Debug, Default, Serialize)]
118+pub struct EqPresets {
119+ /// Name of the preset in use, as in [`Status::eq`].
120+ pub current: Option<String>,
121+ /// Built-in presets first, then custom ones.
122+ pub presets: Vec<EqPreset>,
123+ /// Reads that failed. The rest is still valid.
124+ pub warnings: Vec<String>,
125+}
126+
127+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128+pub struct EqPreset {
129+ /// Built-in presets: the name `eq <preset>` takes. Custom ones: the name stored on the buds.
130+ pub name: String,
131+ /// The buds' id for it.
132+ pub id: u8,
133+ pub custom: bool,
134+ /// Custom presets only: gain per band. Empty for built-in presets.
135+ pub bands: Vec<EqBand>,
136+}
137+
138+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
139+pub struct EqBand {
140+ pub hz: u16,
141+ /// dB.
142+ pub gain: i8,
143+}
144+
116145 /// A touch-control change: what changes, the write that does it and, once sent, the table the
117146 /// buds report afterwards.
118147 #[derive(Debug, Serialize)]
M docs/protocol/realme_t310.md View
@@ -310,7 +310,8 @@ Preset names are the vendor app's (*vendor*).
310310 Evidence: *vendor*; the T310's command set lists it.
311311
312312 - **Reply:** `00 <min s8> <max s8> <level s8>`. The usual range bytes are `FB 05`, meaning -5..+5.
313−- **The T310 does not answer it** (*ours-2026-10-27*): an empty `0x0124` got no reply within 3 s, although bit 38 is set. budctl no longer sends it. Bass level returns at W6, from an Android HCI snoop of realme Link reading it.
313+- **The T310 does not answer it with bass boost off** (*ours-2026-10-27*, HW1): an empty `0x0124` got no reply within 3 s, although bit 38 is set. Bass boost (switch `1D`) was off at the time.
314+- <a id="bass-read-w6"></a>**Not with bass boost on either** (*ours-2026-10-27*, W6a). The vendor data ties the bass level to the bass engine, so the read might have needed bass boost on. With `0x010D` reporting `1D 01`, an empty `0x0124` again got no reply within 3 s. budctl does not send it. Without a read, a bass-level write ([§10.4](#bass-write)) can only be checked by ear.
314315
315316 <a id="gestures"></a>
316317 ### 9.8 Touch controls: `0x0108`
@@ -478,6 +479,21 @@ Evidence: *vendor*
478479 - `03` deletes it.
479480 - **Ids are renumbered** after a create or delete, so read the list again each time.
480481 - **Defaults:** 6 bands at 62, 250, 1000, 4000, 8000 and 16000 Hz. Range bytes `FA 06` mean -6..+6 dB. At most 3 presets.
482+- **The owner's T310** (*ours-2026-10-27*, W11a) answered the list read within 26 ms with one preset:
483+
484+ ```
485+ 00 01 status, 1 preset
486+ 01 FA 06 04 selected, -6..+6 dB, id 04
487+ 07 43 75 73 74 6F 6D 31 name, 7 bytes: "Custom1"
488+ 06 6 bands
489+ 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00
490+ 50, 250, 1000, 4000, 8000, 16000 Hz, each 0 dB
491+ ```
492+
493+ - **The lowest band is 50 Hz, not 62.** The vendor default above does not hold on the T310; use the bands the buds report.
494+ - **The preset was already there** when budctl first read it. Its origin is unknown: it may be the factory state, or left over from realme Link.
495+ - **It was in use:** `0x010F` answered `00 04` at the same time, so `custom-4` is a valid current EQ, and the list's `selected` byte (`01`) agrees.
496+- **What budctl does** (W11, one step per checkpoint, no app capture): `budctl eq` with no preset reads `0x010F` and the list (W11a, read-only). A custom preset shows as `custom-<id>`, the name `0x010F` decoding uses, followed by its stored name and bands. Create, save and delete follow one at a time.
481497
482498 <a id="absent"></a>
483499 ## 11. Not on the T310
@@ -498,8 +514,8 @@ Evidence: *vendor*
498514 7. How long after the game-mode ack the new value shows up in `0x010D`: at most 1 s on the T310 (W3). Whether it is sooner was not measured.
499515 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.
500516 9. Whether the minimum 200 ms spacing between sends is needed.
501−10. Why the T310 ignores `0x0124` although bit 38 is set ([§9.7](#bass-read)). Needs a realme Link capture before W6.
502−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.
517+10. Why the T310 never answers `0x0124`, with bass boost off (HW1) or on (W6a), although bit 38 is set ([§9.7](#bass-read)).
518+11. How to turn noise cancelling on at the level the buds saved, without forcing deep ([§10.1](#anc-write)). Would need a realme Link snoop, which the owner declined.
503519
504520 <a id="roadmap"></a>
505521 ## 13. Roadmap and hardware checkpoints
@@ -519,20 +535,22 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go
519535 | W3 | `game on/off` |
520536 | W4 | `eq <preset>` |
521537 | W5 | `set bass-boost` → `set spatial` → `set wind` → `set dual`, each one checkpoint |
522−| W6 | `bass <level>` |
538+| W6 | W6a: read `0x0124` with bass boost on; W6b: `bass <level>` |
523539 | W7 | `find` |
524540 | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) |
525541 | W9 | `touch cycle` |
526542 | W10 | ~~`fit-test`~~: dropped, the T310 runs no test ([§10.8](#fit-test)) |
527−| W11 | custom EQ (create, then save, then delete) |
543+| W11 | W11a: `eq` lists the custom presets (read-only); then create, save and delete, one checkpoint each |
528544
529−Ask for an Android HCI snoop of realme Link before W11, and for any write that does nothing.
545+The owner will not install realme Link (2026-10-27), so there is no app capture. W6 and W11 use only the vendor-documented bytes in this spec, one checkpoint at a time.
530546
531547 <a id="verification-log"></a>
532548 ## 14. Verification log
533549
534550 | Date | Checkpoint | Result | Notes |
535551 |---|---|---|---|
552+| 2026-10-27 | W11a `eq` (custom list `0x0122`) | pass | One preset, "Custom1" (id `04`, 6 flat bands from 50 Hz), and it is the EQ in use. See [§10.9](#custom-eq) |
553+| 2026-10-27 | W6a `status` with bass boost on (`0x0124`) | fail: no reply | Still no answer in 3 s. budctl does not send it. See [§9.7](#bass-read) |
536554 | 2026-10-27 | W10 `fit-test` | fail: no test | Start and stop were acked, but no result push and no sound came in 15 s. The owner dropped the fit test. See [§10.8](#fit-test) |
537555 | 2026-10-27 | W9 `touch cycle off transparency anc`, then `off anc` | pass | Masks `0B` and `09` were acked and read back on the first try. The owner confirmed the new cycle with the hold gesture. The cycle is now off → anc. See [§10.7](#hold-cycle-write) |
538556 | 2026-10-27 | W8 `touch left triple-tap volume-up` (`--dry-run`, then sent), then `previous` | pass | The dry run's payload matched the derived frame. Both writes were acked and read back on the first try, 600 ms after the ack. The ack echoes the old table, cut short. The owner confirmed that the new gesture works. The left triple tap is now `previous`. See [§10.6](#gesture-write) |