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.
+163 −22
10 files changed
- M CHANGELOG.md +5 −0
- M README.md +1 −1
- M crates/budctl-cli/src/main.rs +25 −5
- M crates/budctl-cli/src/output.rs +9 −5
- M crates/budctl-cli/tests/cli.rs +8 −6
- M crates/budctl-core/src/devices/mod.rs +10 −1
- M crates/budctl-core/src/devices/realme_t310/codec.rs +29 −1
- M crates/budctl-core/src/devices/realme_t310/mod.rs +43 −2
- M crates/budctl-core/src/status.rs +22 −0
- M docs/protocol/realme_t310.md +11 −1
| @@ -1,5 +1,10 @@ | |||
| 1 | 1 | # Changelog | |
| 2 | 2 | ||
| 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 | + | ||
| 3 | 8 | ## 0.1.4 (2026-11-01) | |
| 4 | 9 | ||
| 5 | 10 | budctl comes to Android, and gets an icon. | |
| @@ -57,7 +57,7 @@ budctl eq delete custom-4 # delete a custom preset | |||
| 57 | 57 | budctl game on|off # game mode (low latency) | |
| 58 | 58 | budctl set bass-boost|spatial|wind|dual on|off | |
| 59 | 59 | 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 | |
| 61 | 61 | budctl touch # what each tap and hold does | |
| 62 | 62 | budctl touch left triple-tap previous # give a gesture an action (--dry-run first, if you like) | |
| 63 | 63 | budctl touch cycle off anc # the modes a hold steps through | |
| @@ -9,7 +9,7 @@ use std::io::{self, Write}; | |||
| 9 | 9 | use std::process::ExitCode; | |
| 10 | 10 | ||
| 11 | 11 | use budctl_core::bt::BdAddr; | |
| 12 | − | use budctl_core::status::{AncLevel, EqBand, NoiseMode}; | |
| 12 | + | use budctl_core::status::{AncLevel, BassBand, EqBand, NoiseMode}; | |
| 13 | 13 | use budctl_core::{Error, Kind}; | |
| 14 | 14 | use clap::{Parser, Subcommand, ValueEnum}; | |
| 15 | 15 | ||
| @@ -87,8 +87,11 @@ enum Command { | |||
| 87 | 87 | #[arg(value_enum)] | |
| 88 | 88 | state: OnOff, | |
| 89 | 89 | }, | |
| 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. | |
| 91 | 92 | Bass { | |
| 93 | + | #[arg(value_enum)] | |
| 94 | + | band: BandArg, | |
| 92 | 95 | /// Checked here, before connecting; core checks it again. | |
| 93 | 96 | #[arg(allow_negative_numbers = true, value_parser = clap::value_parser!(i8).range(-5..=5))] | |
| 94 | 97 | level: i8, | |
| @@ -187,6 +190,23 @@ impl Side { | |||
| 187 | 190 | } | |
| 188 | 191 | } | |
| 189 | 192 | ||
| 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 | + | ||
| 190 | 210 | #[derive(Clone, Copy, ValueEnum)] | |
| 191 | 211 | enum OnOff { | |
| 192 | 212 | On, | |
| @@ -353,9 +373,9 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 353 | 373 | let now = session.set_switch(switch, matches!(state, OnOff::On))?; | |
| 354 | 374 | output::switch(out, &device, model, &now, cli.json) | |
| 355 | 375 | } | |
| 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) | |
| 359 | 379 | } | |
| 360 | 380 | Command::Find { action, .. } => { | |
| 361 | 381 | let ring = matches!(action, FindAction::Start); | |
| @@ -5,7 +5,8 @@ use std::io::{self, Write}; | |||
| 5 | 5 | use budctl_core::PairedDevice; | |
| 6 | 6 | use budctl_core::devices::Device; | |
| 7 | 7 | 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, | |
| 9 | 10 | }; | |
| 10 | 11 | use budctl_core::{Error, Found}; | |
| 11 | 12 | use serde_json::Value; | |
| @@ -433,21 +434,24 @@ pub fn eq( | |||
| 433 | 434 | Ok(()) | |
| 434 | 435 | } | |
| 435 | 436 | ||
| 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( | |
| 438 | 439 | out: &mut impl Write, | |
| 439 | 440 | device: &PairedDevice, | |
| 440 | 441 | model: &dyn Device, | |
| 442 | + | band: BassBand, | |
| 441 | 443 | level: i8, | |
| 442 | 444 | as_json: bool, | |
| 443 | 445 | ) -> Result<(), Failure> { | |
| 444 | 446 | 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); | |
| 446 | 449 | } | |
| 447 | 450 | header(out, device, model)?; | |
| 448 | 451 | writeln!( | |
| 449 | 452 | 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() | |
| 451 | 455 | )?; | |
| 452 | 456 | Ok(()) | |
| 453 | 457 | } | |
| @@ -82,14 +82,16 @@ fn the_hold_cycle_needs_two_or_three_known_modes() { | |||
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | #[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. | |
| 87 | 87 | for args in [ | |
| 88 | 88 | &["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"], | |
| 93 | 95 | ] { | |
| 94 | 96 | let out = budctl(args); | |
| 95 | 97 | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| @@ -7,7 +7,8 @@ use crate::PairedDevice; | |||
| 7 | 7 | use crate::bt::Uuid; | |
| 8 | 8 | use crate::error::{Error, Result}; | |
| 9 | 9 | 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, | |
| 11 | 12 | }; | |
| 12 | 13 | use crate::transport::Transport; | |
| 13 | 14 | ||
| @@ -117,6 +118,14 @@ pub trait Session: Send { | |||
| 117 | 118 | )) | |
| 118 | 119 | } | |
| 119 | 120 | ||
| 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 | + | ||
| 120 | 129 | /// Start (`true`) or stop ringing the buds so they can be found. Only the ack can be | |
| 121 | 130 | /// checked: there is no state to read back. | |
| 122 | 131 | fn find(&mut self, _ring: bool) -> Result<()> { | |
| @@ -5,7 +5,8 @@ use crate::bt::BdAddr; | |||
| 5 | 5 | use crate::error::{Error, Result}; | |
| 6 | 6 | use crate::reader::Reader; | |
| 7 | 7 | 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, | |
| 9 | 10 | }; | |
| 10 | 11 | ||
| 11 | 12 | /// Spec: realme_t310.md#bitmap | |
| @@ -46,6 +47,18 @@ pub(super) fn bass_write_payload(level: i8) -> Result<[u8; 3]> { | |||
| 46 | 47 | let [min, max, level] = [min, max, level].map(|v| v.to_le_bytes()[0]); | |
| 47 | 48 | Ok([min, max, level]) | |
| 48 | 49 | } | |
| 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 | + | ||
| 49 | 62 | /// Spec: realme_t310.md#gesture-write | |
| 50 | 63 | pub(super) const GESTURE_WRITE: u16 = 0x0401; | |
| 51 | 64 | ||
| @@ -1296,6 +1309,21 @@ mod tests { | |||
| 1296 | 1309 | assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 1B 04 05 03 00 FB 05 03"); | |
| 1297 | 1310 | } | |
| 1298 | 1311 | ||
| 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 | + | ||
| 1299 | 1327 | /// The owner's custom EQ list (W11a): "Custom1", id 04, 6 flat bands from 50 Hz. | |
| 1300 | 1328 | const OWNER_CUSTOM_EQS: &str = | |
| 1301 | 1329 | "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"; | |
| @@ -10,8 +10,8 @@ use crate::bt::Uuid; | |||
| 10 | 10 | use crate::devices::{Device, Session}; | |
| 11 | 11 | use crate::error::{Error, Kind, Result}; | |
| 12 | 12 | 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, | |
| 15 | 15 | }; | |
| 16 | 16 | use crate::transport::Transport; | |
| 17 | 17 | ||
| @@ -512,6 +512,21 @@ impl Session for T310Session { | |||
| 512 | 512 | codec::check_write_reply(&reply, "the bass-level change") | |
| 513 | 513 | } | |
| 514 | 514 | ||
| 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 | + | ||
| 515 | 530 | /// Spec: realme_t310.md#find | |
| 516 | 531 | fn find(&mut self, ring: bool) -> Result<()> { | |
| 517 | 532 | if !self.commands.allows(codec::FIND) { | |
| @@ -1353,6 +1368,32 @@ mod tests { | |||
| 1353 | 1368 | script.assert_done(); | |
| 1354 | 1369 | } | |
| 1355 | 1370 | ||
| 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 | + | ||
| 1356 | 1397 | #[test] | |
| 1357 | 1398 | fn a_bass_level_out_of_range_is_refused_before_anything_is_sent() { | |
| 1358 | 1399 | let mut steps = handshake(); | |
| @@ -153,6 +153,28 @@ pub struct EqPreset { | |||
| 153 | 153 | pub range: Option<(i8, i8)>, | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 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 | + | ||
| 156 | 178 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] | |
| 157 | 179 | pub struct EqBand { | |
| 158 | 180 | pub hz: u16, | |
| @@ -455,6 +455,16 @@ Evidence: *vendor* | |||
| 455 | 455 | - **What budctl does:** `budctl bass <level>` refuses a level outside -5..+5 before sending anything. | |
| 456 | 456 | - **Expected frame** (*derived*), for `bass 3` with seq `05`: `AA 0A 00 00 1B 04 05 03 00 FB 05 03`. | |
| 457 | 457 | - **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`. | |
| 458 | 468 | ||
| 459 | 469 | <a id="find"></a> | |
| 460 | 470 | ### 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 | |||
| 604 | 614 | | W3 | `game on/off` | | |
| 605 | 615 | | W4 | `eq <preset>` | | |
| 606 | 616 | | 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 | | |
| 608 | 618 | | W7 | `find` | | |
| 609 | 619 | | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) | | |
| 610 | 620 | | W9 | `touch cycle` | | |