budctl00f061b
budctl bass <level> (W6b)
This sets the bass level with 0x041B FB 05 <level s8>, gated by bitmap bit 38. The range -5..+5 is checked by clap before connecting, and again by core. The T310 never answers the bass-level read (0x0124), so only the ack can be checked. Verified on the owner's T310 (W6b, 2026-10-27), with bass boost off: -5, +5 and 0 were each acked with 0x841B 00 within 35 ms. By ear the difference is slight at most. No new dependencies.
+116 −2
7 files changed
- M crates/budctl-cli/src/main.rs +10 −0
- M crates/budctl-cli/src/output.rs +19 −0
- M crates/budctl-cli/tests/cli.rs +15 −0
- M crates/budctl-core/src/devices/mod.rs +7 −0
- M crates/budctl-core/src/devices/realme_t310/codec.rs +30 −0
- M crates/budctl-core/src/devices/realme_t310/mod.rs +29 −0
- M docs/protocol/realme_t310.md +6 −2
| @@ -74,6 +74,12 @@ enum Command { | |||
| 74 | 74 | #[arg(value_enum)] | |
| 75 | 75 | state: OnOff, | |
| 76 | 76 | }, | |
| 77 | + | /// Set the bass level, -5 to +5. The buds cannot report it back, so only their ack is checked. | |
| 78 | + | Bass { | |
| 79 | + | /// Checked here, before connecting; core checks it again. | |
| 80 | + | #[arg(allow_negative_numbers = true, value_parser = clap::value_parser!(i8).range(-5..=5))] | |
| 81 | + | level: i8, | |
| 82 | + | }, | |
| 77 | 83 | /// Ring the buds so you can find them (loud: take them out of your ears first). | |
| 78 | 84 | Find { | |
| 79 | 85 | #[arg(value_enum)] | |
| @@ -268,6 +274,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 268 | 274 | let now = session.set_switch(switch, matches!(state, OnOff::On))?; | |
| 269 | 275 | output::switch(out, &device, model, &now, cli.json) | |
| 270 | 276 | } | |
| 277 | + | Command::Bass { level } => { | |
| 278 | + | session.set_bass_level(level)?; | |
| 279 | + | output::bass_level(out, &device, model, level, cli.json) | |
| 280 | + | } | |
| 271 | 281 | Command::Find { action, .. } => { | |
| 272 | 282 | let ring = matches!(action, FindAction::Start); | |
| 273 | 283 | session.find(ring)?; | |
| @@ -335,6 +335,25 @@ pub fn eq( | |||
| 335 | 335 | Ok(()) | |
| 336 | 336 | } | |
| 337 | 337 | ||
| 338 | + | /// After `bass <level>`: the buds acked; they cannot report the level back. | |
| 339 | + | pub fn bass_level( | |
| 340 | + | out: &mut impl Write, | |
| 341 | + | device: &PairedDevice, | |
| 342 | + | model: &dyn Device, | |
| 343 | + | level: i8, | |
| 344 | + | as_json: bool, | |
| 345 | + | ) -> Result<(), Failure> { | |
| 346 | + | if as_json { | |
| 347 | + | return json(out, Some((device, model)), serde_json::json!({ "bass_level": level })); | |
| 348 | + | } | |
| 349 | + | header(out, device, model)?; | |
| 350 | + | writeln!( | |
| 351 | + | out, | |
| 352 | + | " Bass level {level:+} (accepted; the buds cannot report it back)" | |
| 353 | + | )?; | |
| 354 | + | Ok(()) | |
| 355 | + | } | |
| 356 | + | ||
| 338 | 357 | /// After `find start` or `find stop`: the buds acked; there is nothing to read back. | |
| 339 | 358 | pub fn find( | |
| 340 | 359 | out: &mut impl Write, | |
| @@ -59,3 +59,18 @@ fn the_hold_cycle_needs_two_or_three_known_modes() { | |||
| 59 | 59 | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| 60 | 60 | } | |
| 61 | 61 | } | |
| 62 | + | ||
| 63 | + | #[test] | |
| 64 | + | fn bass_needs_a_level_from_minus_5_to_5() { | |
| 65 | + | // Out of range fails while parsing, before anything connects. | |
| 66 | + | for args in [ | |
| 67 | + | &["bass"][..], | |
| 68 | + | &["bass", "loud"], | |
| 69 | + | &["bass", "300"], | |
| 70 | + | &["bass", "6"], | |
| 71 | + | &["bass", "-6"], | |
| 72 | + | ] { | |
| 73 | + | let out = budctl(args); | |
| 74 | + | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| 75 | + | } | |
| 76 | + | } | |
| @@ -62,6 +62,13 @@ pub trait Session: Send { | |||
| 62 | 62 | )) | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | + | /// Set the bass level. Only the ack can be checked when the buds cannot report the level. | |
| 66 | + | fn set_bass_level(&mut self, _level: i8) -> Result<()> { | |
| 67 | + | Err(Error::Unsupported( | |
| 68 | + | "budctl cannot change the bass level on these buds".to_owned(), | |
| 69 | + | )) | |
| 70 | + | } | |
| 71 | + | ||
| 65 | 72 | /// Start (`true`) or stop ringing the buds so they can be found. Only the ack can be | |
| 66 | 73 | /// checked: there is no state to read back. | |
| 67 | 74 | fn find(&mut self, _ring: bool) -> Result<()> { | |
| @@ -27,6 +27,22 @@ pub(super) const SWITCH_WRITE: u16 = 0x0403; | |||
| 27 | 27 | pub(super) const EQ_SELECT: u16 = 0x0406; | |
| 28 | 28 | /// Spec: realme_t310.md#find | |
| 29 | 29 | pub(super) const FIND: u16 = 0x0400; | |
| 30 | + | /// Spec: realme_t310.md#bass-write | |
| 31 | + | pub(super) const BASS_LEVEL_WRITE: u16 = 0x041B; | |
| 32 | + | /// The bass-level range. Spec: realme_t310.md#bass-write | |
| 33 | + | pub(super) const BASS_RANGE: (i8, i8) = (-5, 5); | |
| 34 | + | ||
| 35 | + | /// `FB 05 <level s8>`: the range bytes, then the level. Spec: realme_t310.md#bass-write | |
| 36 | + | pub(super) fn bass_write_payload(level: i8) -> Result<[u8; 3]> { | |
| 37 | + | let (min, max) = BASS_RANGE; | |
| 38 | + | if !(min..=max).contains(&level) { | |
| 39 | + | return Err(Error::Usage(format!( | |
| 40 | + | "the bass level goes from {min} to +{max}; {level} is outside" | |
| 41 | + | ))); | |
| 42 | + | } | |
| 43 | + | let [min, max, level] = [min, max, level].map(|v| v.to_le_bytes()[0]); | |
| 44 | + | Ok([min, max, level]) | |
| 45 | + | } | |
| 30 | 46 | /// Spec: realme_t310.md#gesture-write | |
| 31 | 47 | pub(super) const GESTURE_WRITE: u16 = 0x0401; | |
| 32 | 48 | ||
| @@ -948,4 +964,18 @@ mod tests { | |||
| 948 | 964 | assert!(decode_custom_eqs(&hex("00 01 01 FA 06 04 02 4D 65 02 3E 00")).is_err()); | |
| 949 | 965 | assert_eq!(builtin_eqs().len(), 4); | |
| 950 | 966 | } | |
| 967 | + | ||
| 968 | + | #[test] | |
| 969 | + | fn bass_level_payloads() { | |
| 970 | + | assert_eq!(bass_write_payload(0).unwrap(), [0xFB, 0x05, 0x00]); | |
| 971 | + | assert_eq!(bass_write_payload(5).unwrap(), [0xFB, 0x05, 0x05]); | |
| 972 | + | assert_eq!(bass_write_payload(-2).unwrap(), [0xFB, 0x05, 0xFE]); | |
| 973 | + | assert_eq!(bass_write_payload(-5).unwrap(), [0xFB, 0x05, 0xFB]); | |
| 974 | + | assert!(matches!(bass_write_payload(6), Err(Error::Usage(_)))); | |
| 975 | + | assert!(matches!(bass_write_payload(-6), Err(Error::Usage(_)))); | |
| 976 | + | // The frame the spec gives (realme_t310.md#bass-write). | |
| 977 | + | let frame = crate::devices::realme_t310::frame::encode(BASS_LEVEL_WRITE, 0x05, &bass_write_payload(3).unwrap()) | |
| 978 | + | .unwrap(); | |
| 979 | + | assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 1B 04 05 03 00 FB 05 03"); | |
| 980 | + | } | |
| 951 | 981 | } | |
| @@ -319,6 +319,16 @@ impl Session for T310Session { | |||
| 319 | 319 | ) | |
| 320 | 320 | } | |
| 321 | 321 | ||
| 322 | + | /// Ack-only: the T310 never answers the bass-level read. Spec: realme_t310.md#bass-write | |
| 323 | + | fn set_bass_level(&mut self, level: i8) -> Result<()> { | |
| 324 | + | let payload = codec::bass_write_payload(level)?; | |
| 325 | + | if !self.commands.allows(codec::BASS_LEVEL_WRITE) { | |
| 326 | + | return Err(Error::Unsupported("these buds do not offer a bass level".to_owned())); | |
| 327 | + | } | |
| 328 | + | let reply = self.link.request(codec::BASS_LEVEL_WRITE, &payload)?; | |
| 329 | + | codec::check_write_reply(&reply, "the bass-level change") | |
| 330 | + | } | |
| 331 | + | ||
| 322 | 332 | /// Spec: realme_t310.md#find | |
| 323 | 333 | fn find(&mut self, ring: bool) -> Result<()> { | |
| 324 | 334 | if !self.commands.allows(codec::FIND) { | |
| @@ -1069,4 +1079,23 @@ mod tests { | |||
| 1069 | 1079 | assert_eq!(bands, [50, 250, 1000, 4000, 8000, 16000]); | |
| 1070 | 1080 | assert!(eq.warnings.is_empty()); | |
| 1071 | 1081 | } | |
| 1082 | + | ||
| 1083 | + | #[test] | |
| 1084 | + | fn sets_the_bass_level() { | |
| 1085 | + | let mut steps = handshake(); | |
| 1086 | + | steps.extend([tx(0x041B, 0x05, "FB 05 FE"), rx(0x841B, 0x05, "00")]); | |
| 1087 | + | let script = Script::new(steps); | |
| 1088 | + | open(&script).unwrap().set_bass_level(-2).unwrap(); | |
| 1089 | + | script.assert_done(); | |
| 1090 | + | } | |
| 1091 | + | ||
| 1092 | + | #[test] | |
| 1093 | + | fn a_bass_level_out_of_range_is_refused_before_anything_is_sent() { | |
| 1094 | + | let mut steps = handshake(); | |
| 1095 | + | steps.pop(); | |
| 1096 | + | let script = Script::new(steps); | |
| 1097 | + | let result = open(&script).unwrap().set_bass_level(6); | |
| 1098 | + | script.assert_done(); | |
| 1099 | + | assert!(matches!(result, Err(Error::Usage(_)))); | |
| 1100 | + | } | |
| 1072 | 1101 | } | |
| @@ -362,7 +362,7 @@ Evidence: *vendor* | |||
| 362 | 362 | <a id="writes"></a> | |
| 363 | 363 | ## 10. Writes | |
| 364 | 364 | ||
| 365 | − | **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 bass-boost switch `1D` (W5a, verified *ours-2026-10-27*), 3D sound `1B` (W5b, verified *ours-2026-10-27*), wind-noise reduction `1A` (W5c, verified *ours-2026-10-27*), and dual connection `11` (W5d, verified *ours-2026-10-27*); [§10.5](#find) find (W7, verified *ours-2026-10-27*); [§10.6](#gesture-write) touch controls (W8, verified *ours-2026-10-27*); [§10.7](#hold-cycle-write) the hold cycle (W9, verified *ours-2026-10-27*). The fit test ([§10.8](#fit-test), W10) is acked but does nothing on the T310; it is dropped. Bass level (W6) waits for a realme Link capture. budctl reads it back at once, because the spec gives it no special timing. The others are not sent yet. | |
| 365 | + | **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 bass-boost switch `1D` (W5a, verified *ours-2026-10-27*), 3D sound `1B` (W5b, verified *ours-2026-10-27*), wind-noise reduction `1A` (W5c, verified *ours-2026-10-27*), and dual connection `11` (W5d, verified *ours-2026-10-27*); [§10.5](#find) find (W7, verified *ours-2026-10-27*); [§10.6](#gesture-write) touch controls (W8, verified *ours-2026-10-27*); [§10.7](#hold-cycle-write) the hold cycle (W9, verified *ours-2026-10-27*); [§10.4](#bass-write) the bass level (W6b, acked *ours-2026-10-27*; the effect is faint by ear and cannot be read back). The fit test ([§10.8](#fit-test), W10) is acked but does nothing on the T310; it is dropped. The others are not sent yet. | |
| 366 | 366 | ||
| 367 | 367 | **General rule**: | |
| 368 | 368 | - A write reply is `0x84xx <status …>`, and `00` means accepted. | |
| @@ -411,7 +411,10 @@ Evidence: *vendor* | |||
| 411 | 411 | Evidence: *vendor* | |
| 412 | 412 | ||
| 413 | 413 | - **Range:** -5..+5. | |
| 414 | − | - **Check:** read `0x0124` again. | |
| 414 | + | - **Check:** read `0x0124` again. **Not possible on the T310**, which never answers that read ([§9.7](#bass-read-w6)), so budctl checks only the `0x841B` status and the owner listens. | |
| 415 | + | - **What budctl does:** `budctl bass <level>` refuses a level outside -5..+5 before sending anything. | |
| 416 | + | - **Expected frame** (*derived*), for `bass 3` with seq `05`: `AA 0A 00 00 1B 04 05 03 00 FB 05 03`. | |
| 417 | + | - **On the owner's T310** (*ours-2026-10-27*, W6b): `FB 05 FB` (-5), `FB 05 05` (+5) and `FB 05 00` (0) were each acked with a plain `0x841B 00` within 35 ms. Unlike the gesture write, the ack carries no data. Bass boost was off. By ear the owner heard at most a slight difference between -5 and +5. Whether the level does more with bass boost on is untested. | |
| 415 | 418 | ||
| 416 | 419 | <a id="find"></a> | |
| 417 | 420 | ### 10.5 Find my earbuds: `0x0400 01` / `0x0400 00` | |
| @@ -549,6 +552,7 @@ The owner will not install realme Link (2026-10-27), so there is no app capture. | |||
| 549 | 552 | ||
| 550 | 553 | | Date | Checkpoint | Result | Notes | | |
| 551 | 554 | |---|---|---|---| | |
| 555 | + | | 2026-10-27 | W6b `bass -5`, `bass 5`, `bass 0` (bass boost off) | pass (protocol) | All acked with `0x841B 00`. No read-back is possible. By ear, at most a slight difference. See [§10.4](#bass-write) | | |
| 552 | 556 | | 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 | 557 | | 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) | | |
| 554 | 558 | | 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) | | |