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.
+232 −29
7 files changed
- M crates/budctl-cli/src/main.rs +9 −3
- M crates/budctl-cli/src/output.rs +42 −1
- M crates/budctl-core/src/devices/mod.rs +8 −1
- M crates/budctl-core/src/devices/realme_t310/codec.rs +61 −15
- M crates/budctl-core/src/devices/realme_t310/mod.rs +59 −3
- M crates/budctl-core/src/status.rs +29 −0
- M docs/protocol/realme_t310.md +24 −6
| @@ -65,8 +65,8 @@ enum Command { | |||
| 65 | 65 | #[arg(value_enum)] | |
| 66 | 66 | mode: AncArg, | |
| 67 | 67 | }, | |
| 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> }, | |
| 70 | 70 | /// Turn an on/off setting on or off, then read it back. Switches: game, bass-boost, spatial (3D sound), wind, dual. | |
| 71 | 71 | Set { | |
| 72 | 72 | /// The switch's name, as `status --json` lists it. | |
| @@ -254,7 +254,13 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 254 | 254 | let now = session.set_noise_control(mode.into())?; | |
| 255 | 255 | output::noise_control(out, &device, model, &now, cli.json) | |
| 256 | 256 | } | |
| 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 | + | } => { | |
| 258 | 264 | let now = session.set_eq(preset)?; | |
| 259 | 265 | output::eq(out, &device, model, &now, cli.json) | |
| 260 | 266 | } | |
| @@ -4,7 +4,7 @@ use std::io::{self, Write}; | |||
| 4 | 4 | ||
| 5 | 5 | use budctl_core::bluez::PairedDevice; | |
| 6 | 6 | 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}; | |
| 8 | 8 | use budctl_core::{Error, Found}; | |
| 9 | 9 | use serde_json::Value; | |
| 10 | 10 | ||
| @@ -278,6 +278,47 @@ pub fn switch( | |||
| 278 | 278 | Ok(()) | |
| 279 | 279 | } | |
| 280 | 280 | ||
| 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 | + | ||
| 281 | 322 | /// The EQ preset after a change, as the buds report it on reading back. | |
| 282 | 323 | pub fn eq( | |
| 283 | 324 | out: &mut impl Write, | |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | use crate::bluez::PairedDevice; | |
| 7 | 7 | use crate::bt::Uuid; | |
| 8 | 8 | 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}; | |
| 10 | 10 | use crate::transport::Transport; | |
| 11 | 11 | ||
| 12 | 12 | pub mod realme_t310; | |
| @@ -48,6 +48,13 @@ pub trait Session: Send { | |||
| 48 | 48 | )) | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 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 | + | ||
| 51 | 58 | /// Select a built-in EQ preset by name, then read it back. Returns the preset now in use. | |
| 52 | 59 | fn set_eq(&mut self, _preset: &str) -> Result<String> { | |
| 53 | 60 | Err(Error::Unsupported( | |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use crate::error::{Error, Result}; | |
| 5 | 5 | 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}; | |
| 7 | 7 | ||
| 8 | 8 | /// Spec: realme_t310.md#bitmap | |
| 9 | 9 | pub(super) const HANDSHAKE: u16 = 0x0100; | |
| @@ -38,9 +38,8 @@ pub(super) fn find_payload(ring: bool) -> [u8; 1] { | |||
| 38 | 38 | pub(super) const EQ_PRESET: u16 = 0x010F; | |
| 39 | 39 | /// Spec: realme_t310.md#codec | |
| 40 | 40 | 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; | |
| 44 | 43 | /// Spec: realme_t310.md#events-offered | |
| 45 | 44 | pub(super) const EVENTS_OFFERED: u16 = 0x0200; | |
| 46 | 45 | /// Spec: realme_t310.md#subscribe | |
| @@ -454,15 +453,44 @@ pub(super) fn decode_eq(reply: &[u8]) -> Result<String> { | |||
| 454 | 453 | Ok(name_of(&EQ_PRESETS, id).map_or_else(|| format!("custom-{id}"), str::to_owned)) | |
| 455 | 454 | } | |
| 456 | 455 | ||
| 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 | + | }); | |
| 464 | 492 | } | |
| 465 | − | Ok(level) | |
| 493 | + | Ok(presets) | |
| 466 | 494 | } | |
| 467 | 495 | ||
| 468 | 496 | /// One gesture-table entry: `<dev> <btn> <act> <fn>`. Spec: realme_t310.md#gestures | |
| @@ -786,14 +814,12 @@ mod tests { | |||
| 786 | 814 | } | |
| 787 | 815 | ||
| 788 | 816 | #[test] | |
| 789 | − | fn eq_codec_and_bass_level() { | |
| 817 | + | fn eq_and_codec() { | |
| 790 | 818 | assert_eq!(decode_eq(&hex("00 00")).unwrap(), "pure-raw"); | |
| 791 | 819 | assert_eq!(decode_eq(&hex("00 03")).unwrap(), "pure-bass"); | |
| 792 | 820 | assert_eq!(decode_eq(&hex("00 05")).unwrap(), "custom-5"); | |
| 793 | 821 | assert_eq!(decode_codec(&hex("00 02")).unwrap(), "AAC"); | |
| 794 | 822 | 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()); | |
| 797 | 823 | } | |
| 798 | 824 | ||
| 799 | 825 | #[test] | |
| @@ -902,4 +928,24 @@ mod tests { | |||
| 902 | 928 | .unwrap(); | |
| 903 | 929 | assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 04 04 06 03 00 02 01 0B"); | |
| 904 | 930 | } | |
| 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 | + | } | |
| 905 | 951 | } | |
| @@ -9,7 +9,9 @@ use crate::bluez::PairedDevice; | |||
| 9 | 9 | use crate::bt::Uuid; | |
| 10 | 10 | use crate::devices::{Device, Session}; | |
| 11 | 11 | 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 | + | }; | |
| 13 | 15 | use crate::transport::Transport; | |
| 14 | 16 | ||
| 15 | 17 | mod codec; | |
| @@ -214,8 +216,8 @@ impl Session for T310Session { | |||
| 214 | 216 | )?; | |
| 215 | 217 | let battery = self.read(&mut reads, "battery", codec::BATTERY, &[], codec::decode_battery)?; | |
| 216 | 218 | 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 | |
| 219 | 221 | let codec = self.read(&mut reads, "codec", codec::CODEC, &[], codec::decode_codec)?; | |
| 220 | 222 | let firmware = self.read(&mut reads, "firmware", codec::FIRMWARE, &[], codec::decode_firmware)?; | |
| 221 | 223 | ||
| @@ -271,6 +273,26 @@ impl Session for T310Session { | |||
| 271 | 273 | ) | |
| 272 | 274 | } | |
| 273 | 275 | ||
| 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 | + | ||
| 274 | 296 | /// Built-in presets only; custom EQs come with W11. Spec: realme_t310.md#eq-write | |
| 275 | 297 | fn set_eq(&mut self, preset: &str) -> Result<String> { | |
| 276 | 298 | let Some(id) = codec::eq_id(preset) else { | |
| @@ -1013,4 +1035,38 @@ mod tests { | |||
| 1013 | 1035 | script.assert_done(); | |
| 1014 | 1036 | assert!(matches!(result, Err(Error::Usage(_)))); | |
| 1015 | 1037 | } | |
| 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 | + | } | |
| 1016 | 1072 | } | |
| @@ -113,6 +113,35 @@ pub struct Gesture { | |||
| 113 | 113 | pub group: Option<u8>, | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 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 | + | ||
| 116 | 145 | /// A touch-control change: what changes, the write that does it and, once sent, the table the | |
| 117 | 146 | /// buds report afterwards. | |
| 118 | 147 | #[derive(Debug, Serialize)] | |
| @@ -310,7 +310,8 @@ Preset names are the vendor app's (*vendor*). | |||
| 310 | 310 | Evidence: *vendor*; the T310's command set lists it. | |
| 311 | 311 | ||
| 312 | 312 | - **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. | |
| 314 | 315 | ||
| 315 | 316 | <a id="gestures"></a> | |
| 316 | 317 | ### 9.8 Touch controls: `0x0108` | |
| @@ -478,6 +479,21 @@ Evidence: *vendor* | |||
| 478 | 479 | - `03` deletes it. | |
| 479 | 480 | - **Ids are renumbered** after a create or delete, so read the list again each time. | |
| 480 | 481 | - **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. | |
| 481 | 497 | ||
| 482 | 498 | <a id="absent"></a> | |
| 483 | 499 | ## 11. Not on the T310 | |
| @@ -498,8 +514,8 @@ Evidence: *vendor* | |||
| 498 | 514 | 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. | |
| 499 | 515 | 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. | |
| 500 | 516 | 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. | |
| 503 | 519 | ||
| 504 | 520 | <a id="roadmap"></a> | |
| 505 | 521 | ## 13. Roadmap and hardware checkpoints | |
| @@ -519,20 +535,22 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go | |||
| 519 | 535 | | W3 | `game on/off` | | |
| 520 | 536 | | W4 | `eq <preset>` | | |
| 521 | 537 | | 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>` | | |
| 523 | 539 | | W7 | `find` | | |
| 524 | 540 | | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) | | |
| 525 | 541 | | W9 | `touch cycle` | | |
| 526 | 542 | | 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 | | |
| 528 | 544 | ||
| 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. | |
| 530 | 546 | ||
| 531 | 547 | <a id="verification-log"></a> | |
| 532 | 548 | ## 14. Verification log | |
| 533 | 549 | ||
| 534 | 550 | | Date | Checkpoint | Result | Notes | | |
| 535 | 551 | |---|---|---|---| | |
| 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) | | |
| 536 | 554 | | 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) | | |
| 537 | 555 | | 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) | | |
| 538 | 556 | | 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) | | |