Krolyxon
Commit 00f061b

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-27 20:44 +0530
Committer
krolyxon · 2026-10-27 20:44 +0530
Parent
54dede1
Commit
00f061b97ddc895dcec7681f0e6e2988c8662f08

+116 −2

7 files changed

  1. M crates/budctl-cli/src/main.rs +10 −0
  2. M crates/budctl-cli/src/output.rs +19 −0
  3. M crates/budctl-cli/tests/cli.rs +15 −0
  4. M crates/budctl-core/src/devices/mod.rs +7 −0
  5. M crates/budctl-core/src/devices/realme_t310/codec.rs +30 −0
  6. M crates/budctl-core/src/devices/realme_t310/mod.rs +29 −0
  7. M docs/protocol/realme_t310.md +6 −2
M crates/budctl-cli/src/main.rs View
@@ -74,6 +74,12 @@ enum Command {
7474 #[arg(value_enum)]
7575 state: OnOff,
7676 },
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+ },
7783 /// Ring the buds so you can find them (loud: take them out of your ears first).
7884 Find {
7985 #[arg(value_enum)]
@@ -268,6 +274,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
268274 let now = session.set_switch(switch, matches!(state, OnOff::On))?;
269275 output::switch(out, &device, model, &now, cli.json)
270276 }
277+ Command::Bass { level } => {
278+ session.set_bass_level(level)?;
279+ output::bass_level(out, &device, model, level, cli.json)
280+ }
271281 Command::Find { action, .. } => {
272282 let ring = matches!(action, FindAction::Start);
273283 session.find(ring)?;
M crates/budctl-cli/src/output.rs View
@@ -335,6 +335,25 @@ pub fn eq(
335335 Ok(())
336336 }
337337
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+
338357 /// After `find start` or `find stop`: the buds acked; there is nothing to read back.
339358 pub fn find(
340359 out: &mut impl Write,
M crates/budctl-cli/tests/cli.rs View
@@ -59,3 +59,18 @@ fn the_hold_cycle_needs_two_or_three_known_modes() {
5959 assert_eq!(out.status.code(), Some(1), "{args:?}");
6060 }
6161 }
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+}
M crates/budctl-core/src/devices/mod.rs View
@@ -62,6 +62,13 @@ pub trait Session: Send {
6262 ))
6363 }
6464
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+
6572 /// Start (`true`) or stop ringing the buds so they can be found. Only the ack can be
6673 /// checked: there is no state to read back.
6774 fn find(&mut self, _ring: bool) -> Result<()> {
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -27,6 +27,22 @@ pub(super) const SWITCH_WRITE: u16 = 0x0403;
2727 pub(super) const EQ_SELECT: u16 = 0x0406;
2828 /// Spec: realme_t310.md#find
2929 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+}
3046 /// Spec: realme_t310.md#gesture-write
3147 pub(super) const GESTURE_WRITE: u16 = 0x0401;
3248
@@ -948,4 +964,18 @@ mod tests {
948964 assert!(decode_custom_eqs(&hex("00 01 01 FA 06 04 02 4D 65 02 3E 00")).is_err());
949965 assert_eq!(builtin_eqs().len(), 4);
950966 }
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+ }
951981 }
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -319,6 +319,16 @@ impl Session for T310Session {
319319 )
320320 }
321321
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+
322332 /// Spec: realme_t310.md#find
323333 fn find(&mut self, ring: bool) -> Result<()> {
324334 if !self.commands.allows(codec::FIND) {
@@ -1069,4 +1079,23 @@ mod tests {
10691079 assert_eq!(bands, [50, 250, 1000, 4000, 8000, 16000]);
10701080 assert!(eq.warnings.is_empty());
10711081 }
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+ }
10721101 }
M docs/protocol/realme_t310.md View
@@ -362,7 +362,7 @@ Evidence: *vendor*
362362 <a id="writes"></a>
363363 ## 10. Writes
364364
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.
366366
367367 **General rule**:
368368 - A write reply is `0x84xx <status …>`, and `00` means accepted.
@@ -411,7 +411,10 @@ Evidence: *vendor*
411411 Evidence: *vendor*
412412
413413 - **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.
415418
416419 <a id="find"></a>
417420 ### 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.
549552
550553 | Date | Checkpoint | Result | Notes |
551554 |---|---|---|---|
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) |
552556 | 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) |
553557 | 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) |
554558 | 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) |