Krolyxon
Commit 9dea828

Branches

Refs →

budctl9dea828

budctl bass <band> <level>: bass boost band by band

The owner hears no difference with bass boost on, and W6b's levels were
acked yet changed nothing clear. The spec now says why (vendor): on the
T310's "dynamic sound", bass boost is the 1D switch and three sliders,
low, medium and high, and each slider's write ends in a band byte
(FB 05 <level> 01|02|03). Buds like these ignore a write without it,
yet still answer 00.

- core: BassBand, and Session::set_bass_band, which sends the band
  byte. Ack-only, as before: the T310 never answers the read.
- budctl bass low|medium|high <level> replaces budctl bass <level>.
- Checkpoint W6c: by ear, with bass boost on. The desktop and Android
  apps keep their one slider until it passes, then get three.
Author
krolyxon
Date
2026-11-02 11:13 +0530
Parent
832675a
Commit
9dea82839f8045a6ef71c7b016de42c1de262f93

+163 −22

10 files changed

  1. M CHANGELOG.md +5 −0
  2. M README.md +1 −1
  3. M crates/budctl-cli/src/main.rs +25 −5
  4. M crates/budctl-cli/src/output.rs +9 −5
  5. M crates/budctl-cli/tests/cli.rs +8 −6
  6. M crates/budctl-core/src/devices/mod.rs +10 −1
  7. M crates/budctl-core/src/devices/realme_t310/codec.rs +29 −1
  8. M crates/budctl-core/src/devices/realme_t310/mod.rs +43 −2
  9. M crates/budctl-core/src/status.rs +22 −0
  10. M docs/protocol/realme_t310.md +11 −1
M CHANGELOG.md View
@@ -1,5 +1,10 @@
11 # Changelog
22
3+## Unreleased
4+
5+- `budctl bass` sets bass boost band by band: `budctl bass low|medium|high <level>`. The buds
6+ ignored the old `budctl bass <level>`: they need to know which band the level is for.
7+
38 ## 0.1.4 (2026-11-01)
49
510 budctl comes to Android, and gets an icon.
M README.md View
@@ -57,7 +57,7 @@ budctl eq delete custom-4 # delete a custom preset
5757 budctl game on|off # game mode (low latency)
5858 budctl set bass-boost|spatial|wind|dual on|off
5959 budctl dual # the phones and computers dual connection keeps
60−budctl bass 2 # bass level, -5 to +5
60+budctl bass low 2 # bass boost, band by band (low, medium, high): -5 to +5
6161 budctl touch # what each tap and hold does
6262 budctl touch left triple-tap previous # give a gesture an action (--dry-run first, if you like)
6363 budctl touch cycle off anc # the modes a hold steps through
M crates/budctl-cli/src/main.rs View
@@ -9,7 +9,7 @@ use std::io::{self, Write};
99 use std::process::ExitCode;
1010
1111 use budctl_core::bt::BdAddr;
12−use budctl_core::status::{AncLevel, EqBand, NoiseMode};
12+use budctl_core::status::{AncLevel, BassBand, EqBand, NoiseMode};
1313 use budctl_core::{Error, Kind};
1414 use clap::{Parser, Subcommand, ValueEnum};
1515
@@ -87,8 +87,11 @@ enum Command {
8787 #[arg(value_enum)]
8888 state: OnOff,
8989 },
90− /// Set the bass level, -5 to +5. The buds cannot report it back, so only their ack is checked.
90+ /// Set the level of one band of bass boost (low, medium, high), -5 to +5; bass boost must be on
91+ /// (`set bass-boost on`). The buds cannot report it back, so only their ack is checked.
9192 Bass {
93+ #[arg(value_enum)]
94+ band: BandArg,
9295 /// Checked here, before connecting; core checks it again.
9396 #[arg(allow_negative_numbers = true, value_parser = clap::value_parser!(i8).range(-5..=5))]
9497 level: i8,
@@ -187,6 +190,23 @@ impl Side {
187190 }
188191 }
189192
193+#[derive(Clone, Copy, ValueEnum)]
194+enum BandArg {
195+ Low,
196+ Medium,
197+ High,
198+}
199+
200+impl From<BandArg> for BassBand {
201+ fn from(arg: BandArg) -> Self {
202+ match arg {
203+ BandArg::Low => BassBand::Low,
204+ BandArg::Medium => BassBand::Medium,
205+ BandArg::High => BassBand::High,
206+ }
207+ }
208+}
209+
190210 #[derive(Clone, Copy, ValueEnum)]
191211 enum OnOff {
192212 On,
@@ -353,9 +373,9 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
353373 let now = session.set_switch(switch, matches!(state, OnOff::On))?;
354374 output::switch(out, &device, model, &now, cli.json)
355375 }
356− Command::Bass { level } => {
357− session.set_bass_level(level)?;
358− output::bass_level(out, &device, model, level, cli.json)
376+ Command::Bass { band, level } => {
377+ session.set_bass_band(band.into(), level)?;
378+ output::bass_band(out, &device, model, band.into(), level, cli.json)
359379 }
360380 Command::Find { action, .. } => {
361381 let ring = matches!(action, FindAction::Start);
M crates/budctl-cli/src/output.rs View
@@ -5,7 +5,8 @@ use std::io::{self, Write};
55 use budctl_core::PairedDevice;
66 use budctl_core::devices::Device;
77 use budctl_core::status::{
8− Charge, DualDevice, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls,
8+ BassBand, Charge, DualDevice, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange,
9+ TouchControls,
910 };
1011 use budctl_core::{Error, Found};
1112 use serde_json::Value;
@@ -433,21 +434,24 @@ pub fn eq(
433434 Ok(())
434435 }
435436
436−/// After `bass <level>`: the buds acked; they cannot report the level back.
437−pub fn bass_level(
437+/// After `bass <band> <level>`: the buds acked; they cannot report the level back.
438+pub fn bass_band(
438439 out: &mut impl Write,
439440 device: &PairedDevice,
440441 model: &dyn Device,
442+ band: BassBand,
441443 level: i8,
442444 as_json: bool,
443445 ) -> Result<(), Failure> {
444446 if as_json {
445− return json(out, Some((device, model)), serde_json::json!({ "bass_level": level }));
447+ let body = serde_json::json!({ "bass": { "band": band, "level": level } });
448+ return json(out, Some((device, model)), body);
446449 }
447450 header(out, device, model)?;
448451 writeln!(
449452 out,
450− " Bass level {level:+} (accepted; the buds cannot report it back)"
453+ " Bass, {} frequencies {level:+} (accepted; the buds cannot report it back)",
454+ band.name()
451455 )?;
452456 Ok(())
453457 }
M crates/budctl-cli/tests/cli.rs View
@@ -82,14 +82,16 @@ fn the_hold_cycle_needs_two_or_three_known_modes() {
8282 }
8383
8484 #[test]
85−fn bass_needs_a_level_from_minus_5_to_5() {
86− // Out of range fails while parsing, before anything connects.
85+fn bass_needs_a_band_and_a_level_from_minus_5_to_5() {
86+ // A missing band, or a level out of range, fails while parsing, before anything connects.
8787 for args in [
8888 &["bass"][..],
89− &["bass", "loud"],
90− &["bass", "300"],
91− &["bass", "6"],
92− &["bass", "-6"],
89+ &["bass", "3"],
90+ &["bass", "loud", "3"],
91+ &["bass", "low"],
92+ &["bass", "low", "300"],
93+ &["bass", "low", "6"],
94+ &["bass", "high", "-6"],
9395 ] {
9496 let out = budctl(args);
9597 assert_eq!(out.status.code(), Some(1), "{args:?}");
M crates/budctl-core/src/devices/mod.rs View
@@ -7,7 +7,8 @@ use crate::PairedDevice;
77 use crate::bt::Uuid;
88 use crate::error::{Error, Result};
99 use crate::status::{
10− DualDevice, EqBand, EqPreset, EqPresets, NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls,
10+ BassBand, DualDevice, EqBand, EqPreset, EqPresets, NoiseControl, NoiseMode, Status, Switch, TouchChange,
11+ TouchControls,
1112 };
1213 use crate::transport::Transport;
1314
@@ -117,6 +118,14 @@ pub trait Session: Send {
117118 ))
118119 }
119120
121+ /// Set the level of one band of bass boost, on buds that set it band by band. Only the ack can
122+ /// be checked when the buds cannot report the level.
123+ fn set_bass_band(&mut self, _band: BassBand, _level: i8) -> Result<()> {
124+ Err(Error::Unsupported(
125+ "budctl cannot change the bass bands on these buds".to_owned(),
126+ ))
127+ }
128+
120129 /// Start (`true`) or stop ringing the buds so they can be found. Only the ack can be
121130 /// checked: there is no state to read back.
122131 fn find(&mut self, _ring: bool) -> Result<()> {
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -5,7 +5,8 @@ use crate::bt::BdAddr;
55 use crate::error::{Error, Result};
66 use crate::reader::Reader;
77 use crate::status::{
8− AncLevel, Battery, Charge, DualDevice, EqBand, EqPreset, Gesture, GestureChoice, NoiseControl, NoiseMode, Switch,
8+ AncLevel, BassBand, Battery, Charge, DualDevice, EqBand, EqPreset, Gesture, GestureChoice, NoiseControl, NoiseMode,
9+ Switch,
910 };
1011
1112 /// Spec: realme_t310.md#bitmap
@@ -46,6 +47,18 @@ pub(super) fn bass_write_payload(level: i8) -> Result<[u8; 3]> {
4647 let [min, max, level] = [min, max, level].map(|v| v.to_le_bytes()[0]);
4748 Ok([min, max, level])
4849 }
50+/// `FB 05 <level s8> <band>`: the band-less payload, then the band byte: `01` low, `02` medium,
51+/// `03` high. Buds whose bass engine has bands ignore a write without it. Spec: realme_t310.md#bass-bands
52+pub(super) fn bass_band_payload(band: BassBand, level: i8) -> Result<[u8; 4]> {
53+ let [min, max, level] = bass_write_payload(level)?;
54+ let band = match band {
55+ BassBand::Low => 0x01,
56+ BassBand::Medium => 0x02,
57+ BassBand::High => 0x03,
58+ };
59+ Ok([min, max, level, band])
60+}
61+
4962 /// Spec: realme_t310.md#gesture-write
5063 pub(super) const GESTURE_WRITE: u16 = 0x0401;
5164
@@ -1296,6 +1309,21 @@ mod tests {
12961309 assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 1B 04 05 03 00 FB 05 03");
12971310 }
12981311
1312+ #[test]
1313+ fn bass_band_payloads() {
1314+ assert_eq!(bass_band_payload(BassBand::Low, 3).unwrap(), [0xFB, 0x05, 0x03, 0x01]);
1315+ assert_eq!(
1316+ bass_band_payload(BassBand::Medium, -5).unwrap(),
1317+ [0xFB, 0x05, 0xFB, 0x02]
1318+ );
1319+ assert_eq!(bass_band_payload(BassBand::High, 0).unwrap(), [0xFB, 0x05, 0x00, 0x03]);
1320+ assert!(matches!(bass_band_payload(BassBand::Low, 6), Err(Error::Usage(_))));
1321+ // The frame the spec gives (realme_t310.md#bass-bands).
1322+ let payload = bass_band_payload(BassBand::Low, 3).unwrap();
1323+ let frame = crate::devices::realme_t310::frame::encode(BASS_LEVEL_WRITE, 0x05, &payload).unwrap();
1324+ assert_eq!(crate::debug::hex(&frame), "AA 0B 00 00 1B 04 05 04 00 FB 05 03 01");
1325+ }
1326+
12991327 /// The owner's custom EQ list (W11a): "Custom1", id 04, 6 flat bands from 50 Hz.
13001328 const OWNER_CUSTOM_EQS: &str =
13011329 "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";
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -10,8 +10,8 @@ use crate::bt::Uuid;
1010 use crate::devices::{Device, Session};
1111 use crate::error::{Error, Kind, Result};
1212 use crate::status::{
13− DualDevice, EqBand, EqPreset, EqPresets, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch,
14− TouchChange, TouchControls,
13+ BassBand, DualDevice, EqBand, EqPreset, EqPresets, GestureChange, NoiseControl, NoiseMode, RawWrite, Status,
14+ Switch, TouchChange, TouchControls,
1515 };
1616 use crate::transport::Transport;
1717
@@ -512,6 +512,21 @@ impl Session for T310Session {
512512 codec::check_write_reply(&reply, "the bass-level change")
513513 }
514514
515+ /// Ack-only too: the T310 never answers the bass-level read. Spec: realme_t310.md#bass-bands
516+ fn set_bass_band(&mut self, band: BassBand, level: i8) -> Result<()> {
517+ let payload = codec::bass_band_payload(band, level)?;
518+ if !self.commands.allows(codec::BASS_LEVEL_WRITE) {
519+ return Err(Error::Unsupported("these buds do not offer a bass level".to_owned()));
520+ }
521+ let reply = self.link.request(codec::BASS_LEVEL_WRITE, &payload)?;
522+ let what = match band {
523+ BassBand::Low => "the low bass change",
524+ BassBand::Medium => "the medium bass change",
525+ BassBand::High => "the high bass change",
526+ };
527+ codec::check_write_reply(&reply, what)
528+ }
529+
515530 /// Spec: realme_t310.md#find
516531 fn find(&mut self, ring: bool) -> Result<()> {
517532 if !self.commands.allows(codec::FIND) {
@@ -1353,6 +1368,32 @@ mod tests {
13531368 script.assert_done();
13541369 }
13551370
1371+ #[test]
1372+ fn sets_a_bass_band_with_its_band_byte() {
1373+ let mut steps = handshake();
1374+ steps.extend([
1375+ tx(0x041B, 0x05, "FB 05 05 01"),
1376+ rx(0x841B, 0x05, "00"),
1377+ tx(0x041B, 0x06, "FB 05 FB 03"),
1378+ rx(0x841B, 0x06, "00"),
1379+ ]);
1380+ let script = Script::new(steps);
1381+ let mut session = open(&script).unwrap();
1382+ session.set_bass_band(BassBand::Low, 5).unwrap();
1383+ session.set_bass_band(BassBand::High, -5).unwrap();
1384+ script.assert_done();
1385+ }
1386+
1387+ #[test]
1388+ fn a_refused_bass_band_says_which() {
1389+ let mut steps = handshake();
1390+ steps.extend([tx(0x041B, 0x05, "FB 05 02 02"), rx(0x841B, 0x05, "01")]);
1391+ let script = Script::new(steps);
1392+ let result = open(&script).unwrap().set_bass_band(BassBand::Medium, 2);
1393+ script.assert_done();
1394+ assert!(matches!(result, Err(Error::Rejected { what, .. }) if what.contains("medium")));
1395+ }
1396+
13561397 #[test]
13571398 fn a_bass_level_out_of_range_is_refused_before_anything_is_sent() {
13581399 let mut steps = handshake();
M crates/budctl-core/src/status.rs View
@@ -153,6 +153,28 @@ pub struct EqPreset {
153153 pub range: Option<(i8, i8)>,
154154 }
155155
156+/// A frequency band of bass boost, on buds that set its level band by band.
157+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
158+#[serde(rename_all = "lowercase")]
159+pub enum BassBand {
160+ Low,
161+ Medium,
162+ High,
163+}
164+
165+impl BassBand {
166+ pub const ALL: [BassBand; 3] = [BassBand::Low, BassBand::Medium, BassBand::High];
167+
168+ /// `low`, `medium` or `high`, as the command line and JSON name it.
169+ pub fn name(self) -> &'static str {
170+ match self {
171+ BassBand::Low => "low",
172+ BassBand::Medium => "medium",
173+ BassBand::High => "high",
174+ }
175+ }
176+}
177+
156178 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
157179 pub struct EqBand {
158180 pub hz: u16,
M docs/protocol/realme_t310.md View
@@ -455,6 +455,16 @@ Evidence: *vendor*
455455 - **What budctl does:** `budctl bass <level>` refuses a level outside -5..+5 before sending anything.
456456 - **Expected frame** (*derived*), for `bass 3` with seq `05`: `AA 0A 00 00 1B 04 05 03 00 FB 05 03`.
457457 - **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.
458+- <a id="bass-bands"></a>**The band byte** (*vendor*). On realme buds whose bass engine is "dynamic sound", the vendor app shows bass boost as the switch `1D` and three sliders: low, medium and high frequencies. Each slider writes its own level, with a band byte after it:
459+
460+ | Payload | Band |
461+ |---|---|
462+ | `FB 05 <level s8> 01` | low frequencies |
463+ | `FB 05 <level s8> 02` | medium frequencies |
464+ | `FB 05 <level s8> 03` | high frequencies |
465+
466+ The T310 is one of these models. Such buds ignore a write without the band byte, yet still answer `00`. That fits W6b: the band-less writes were acked, and changed nothing clear by ear. The owner also hears no difference with bass boost on (2026-11-02), which fits three bands still at their default level. `budctl bass <band> <level>` sends the band byte, for checkpoint W6c ([roadmap](#roadmap)). The desktop and Android apps keep the band-less write until W6c passes, then move to three sliders.
467+- **Expected frame with the band** (*derived*), for `bass low 3` with seq `05`: `AA 0B 00 00 1B 04 05 04 00 FB 05 03 01`.
458468
459469 <a id="find"></a>
460470 ### 10.5 Find my earbuds: `0x0400 01` / `0x0400 00`
@@ -604,7 +614,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go
604614 | W3 | `game on/off` |
605615 | W4 | `eq <preset>` |
606616 | W5 | `set bass-boost` → `set spatial` → `set wind` → `set dual`, each one checkpoint |
607−| W6 | W6a: read `0x0124` with bass boost on; W6b: `bass <level>` |
617+| W6 | W6a: read `0x0124` with bass boost on; W6b: `bass <level>`; W6c: `bass <band> <level>` with the band byte ([§10.4](#bass-bands)), bass boost on, by ear: each band at -5, then at +5 |
608618 | W7 | `find` |
609619 | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) |
610620 | W9 | `touch cycle` |