Krolyxon
Commit c98214a

Branches

Refs →

budctlc98214a

Bass boost band by band in the desktop and Android apps

W6c passed: with bass boost on, the low band at +5 and at -5 sound
definitely different, where the band-less writes changed nothing. The
frames matched the spec byte for byte.

- The band-less write is gone (Session::set_bass_level): the T310
  ignores it. The worker's Bass command and answer carry the band.
- budctl-gui: under Bass boost, three rows instead of one (low, medium
  and high frequencies), each with its own dotted slider and the level
  last set here. Each band keeps its own draft, so moving one band
  while another is saving loses nothing. The keyboard cursor steps
  through the three.
- Android: the same three rows; the JSON bass command and event name
  the band.
- The README's screenshots show the three rows.
Author
krolyxon
Date
2026-11-02 11:22 +0530
Parent
9dea828
Commit
c98214a3adb26a2fdd24ee564498996a079221f7

+240 −148

17 files changed

  1. M CLAUDE.md +1 −1
  2. M README.md +2 −1
  3. M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +21 −17
  4. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt +3 −3
  5. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt +11 −8
  6. M crates/budctl-android/src/json.rs +26 −4
  7. M crates/budctl-core/src/devices/mod.rs +2 −9
  8. M crates/budctl-core/src/devices/realme_t310/mod.rs +4 −22
  9. M crates/budctl-core/src/worker.rs +8 −8
  10. M crates/budctl-gui/src/buds.rs +34 −7
  11. M crates/budctl-gui/src/dots.rs +3 −2
  12. M crates/budctl-gui/src/main.rs +90 −47
  13. M crates/budctl-gui/src/overview.rs +32 −17
  14. M docs/protocol/realme_t310.md +3 −2
  15. M docs/screenshots/android-overview.png
  16. M docs/screenshots/keys.png
  17. M docs/screenshots/overview.png
M CLAUDE.md View
@@ -107,7 +107,7 @@ Exit codes:
107107 3. game mode
108108 4. EQ preset
109109 5. switches (bass boost, 3D sound, wind; dual connection last)
110− 6. bass level
110+ 6. bass level (per band: low, medium, high)
111111 7. find
112112 8. touch controls
113113 9. hold cycle
M README.md View
@@ -84,7 +84,8 @@ Exit codes:
8484
8585 `budctl-gui` has five pages:
8686
87−- **Overview:** battery, noise control and its strength, the switches, the bass level.
87+- **Overview:** battery, noise control and its strength, the switches, and bass boost band by band
88+ (low, medium and high frequencies).
8889 - **Equalizer:** the presets; create, rename and delete your own, and change their bands.
8990 - **Gestures:** what each tap and hold does on each bud, and what a hold steps through.
9091 - **Earbud settings:** dual connection and the devices it keeps, find my earbuds, firmware, audio codec.
M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt View
@@ -25,7 +25,8 @@ sealed interface Item {
2525
2626 data class Switch(val key: String) : Item
2727
28− data object Bass : Item
28+ /** One band of bass boost: `low`, `medium` or `high`. */
29+ data class Bass(val band: String) : Item
2930
3031 /** A preset chosen, saved, renamed or deleted. */
3132 data class Preset(val id: Int) : Item
@@ -90,8 +91,8 @@ class App(
9091 var ancLevel by mutableStateOf("deep")
9192 private set
9293
93− /** A bass level moved on the screen and not set on the buds yet. */
94− var bassDraft by mutableStateOf<Int?>(null)
94+ /** Bass levels moved on the screen and not set on the buds yet, by band. */
95+ var bassDraft by mutableStateOf<Map<String, Int>>(emptyMap())
9596 private set
9697
9798 /** "Turn dual connection off?" is open. */
@@ -159,18 +160,19 @@ class App(
159160 if (yes) command(Item.Switch(DUAL), JSONObject().put("command", "switch").put("key", DUAL).put("on", false))
160161 }
161162
162− /** The bass level slider moved: shown at once, written when the finger lifts ([saveBass]). */
163− fun moveBass(level: Int) {
164− bassDraft = level
163+ /** A band's bass slider moved: shown at once, written when the finger lifts ([saveBass]). */
164+ fun moveBass(band: String, level: Int) {
165+ bassDraft = bassDraft + (band to level)
165166 }
166167
167− fun saveBass() {
168− val level = bassDraft ?: return
169− if (level == buds.bass) {
170− bassDraft = null
168+ /** Writes a band's moved level. If one is on its way for that band, this one goes after it. */
169+ fun saveBass(band: String) {
170+ val level = bassDraft[band] ?: return
171+ if (level == buds.bass[band]) {
172+ bassDraft = bassDraft - band
171173 return
172174 }
173− command(Item.Bass, JSONObject().put("command", "bass").put("level", level))
175+ command(Item.Bass(band), JSONObject().put("command", "bass").put("band", band).put("level", level))
174176 }
175177
176178 fun selectPreset(id: Int) {
@@ -374,16 +376,18 @@ class App(
374376 connection = Connection.Live
375377 }
376378 "bass" -> {
379+ val band = event.getString("band")
377380 val level = event.getInt("level")
378− buds = buds.copy(bass = level)
379− if (bassDraft == level) bassDraft = null
381+ buds = buds.copy(bass = buds.bass + (band to level))
380382 connection = Connection.Live
383+ // A level moved while this one was on its way goes out now.
384+ if (bassDraft[band] == level) bassDraft = bassDraft - band else saveBass(band)
381385 }
382386 "failed" -> {
383387 val kind = event.getString("kind")
384388 connection = Connection.Failed(kind, event.getString("message"))
385389 if (kind == "not-found") buds = buds.copy(connected = false)
386− if (item == Item.Bass) bassDraft = null
390+ if (item is Item.Bass) bassDraft = bassDraft - item.band
387391 // The buds keep what they had: show that, not what was picked.
388392 if (item is Item.Preset && draft?.preset == item.id) draft = null
389393 if (item is Item.Gesture && touchDraft?.let { Item.Gesture(it.side, it.gesture) } == item) touchDraft = null
@@ -407,9 +411,9 @@ class App(
407411 val on = json.getBoolean("on")
408412 buds = buds.copy(switches = buds.switches.map { if (it.key == item.key) it.copy(on = on) else it })
409413 }
410− Item.Bass -> {
411− buds = buds.copy(bass = json.getInt("level"))
412− bassDraft = null
414+ is Item.Bass -> {
415+ buds = buds.copy(bass = buds.bass + (item.band to json.getInt("level")))
416+ bassDraft = bassDraft - item.band
413417 }
414418 is Item.Preset, Item.NewPreset -> pretendEq(json)
415419 is Item.Gesture -> {
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt View
@@ -57,10 +57,10 @@ data class Buds(
5757 val firmware: String? = null,
5858 val presets: EqPresets? = null,
5959 val touch: TouchControls? = null,
60− /** The levels the bass level takes, when the buds have one to set. */
60+ /** The levels each band of bass boost takes, when the buds have bass levels to set. */
6161 val bassRange: IntRange? = null,
62− /** The bass level last set from this app: the buds do not report it. */
63− val bass: Int? = null,
62+ /** The level of each band of bass boost (`low`, `medium`, `high`) last set from this app: the buds do not report them. */
63+ val bass: Map<String, Int> = emptyMap(),
6464 val dual: List<DualDevice>? = null,
6565 ) {
6666 /** The name to show for the EQ preset in use (crates/budctl-gui/src/buds.rs, `eq_name`). */
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt View
@@ -111,26 +111,29 @@ private fun Switches(app: App) {
111111 if (app.saving(Item.Switch(s.key))) SAVING else known?.third,
112112 onClick = { app.switch(s.key, !s.on) },
113113 ) { Toggle(s.on) }
114− if (s.key == "bass-boost") buds.bassRange?.let { BassLevel(app, it) }
114+ if (s.key == "bass-boost") buds.bassRange?.let { range -> for ((band, title) in BASS_BANDS) BassLevel(app, range, band, title) }
115115 }
116116 }
117117 }
118118
119−/** The bass level: the buds take it but do not report it, so the row shows the last level set here. */
119+/** The bands of bass boost, as the buds and the rows name them. */
120+private val BASS_BANDS = listOf("low" to "Low frequencies", "medium" to "Medium frequencies", "high" to "High frequencies")
121+
122+/** One band of bass boost: the buds take its level but do not report it, so the row shows the last level set here. */
120123 @Composable
121−private fun BassLevel(app: App, range: IntRange) {
122− val level = app.bassDraft ?: app.buds.bass
124+private fun BassLevel(app: App, range: IntRange, band: String, title: String) {
125+ val level = app.bassDraft[band] ?: app.buds.bass[band]
123126 SettingRow(
124127 Icon.Bass,
125− "Bass level",
126− if (app.saving(Item.Bass)) SAVING else "Last set here: the buds do not report it",
128+ title,
129+ if (app.saving(Item.Bass(band))) SAVING else "Bass boost, last set here",
127130 below = {
128131 DotSlider(
129132 range.first,
130133 range.last,
131134 level ?: 0,
132− change = app::moveBass,
133− release = app::saveBass,
135+ change = { app.moveBass(band, it) },
136+ release = { app.saveBass(band) },
134137 modifier = Modifier.padding(start = 52.dp, top = 6.dp),
135138 )
136139 },
M crates/budctl-android/src/json.rs View
@@ -4,7 +4,7 @@
44 //! as `{"event": "noise", "mode": "anc", "level": "deep"}`. The values are the status types the
55 //! CLI's `--json` writes, except that switches keep their labels.
66
7−use budctl_core::status::{AncLevel, EqBand, NoiseMode, Status};
7+use budctl_core::status::{AncLevel, BassBand, EqBand, NoiseMode, Status};
88 use budctl_core::worker::{Command, Event};
99 use budctl_core::{Error, Kind, PairedDevice, Result};
1010 use serde::Deserialize;
@@ -44,7 +44,9 @@ enum Request {
4444 Find {
4545 ring: bool,
4646 },
47+ /// One band of bass boost: `low`, `medium` or `high`.
4748 Bass {
49+ band: String,
4850 level: i8,
4951 },
5052 CreateEq {
@@ -87,13 +89,20 @@ pub fn command(json: &str) -> Result<Command> {
8789 Request::Touch { side, gesture, action } => Command::Touch { side, gesture, action },
8890 Request::HoldCycle { modes } => Command::HoldCycle(modes),
8991 Request::Find { ring } => Command::Find(ring),
90− Request::Bass { level } => Command::Bass(level),
92+ Request::Bass { band, level } => Command::Bass(bass_band(&band)?, level),
9193 Request::CreateEq { name } => Command::CreateEq(name),
9294 Request::RenameEq { preset, name } => Command::RenameEq(preset, name),
9395 Request::DeleteEq { preset } => Command::DeleteEq(preset),
9496 })
9597 }
9698
99+fn bass_band(name: &str) -> Result<BassBand> {
100+ BassBand::ALL
101+ .into_iter()
102+ .find(|band| band.name() == name)
103+ .ok_or_else(|| Error::Usage(format!("no bass band {name:?}: low, medium or high")))
104+}
105+
97106 /// `anc` with no level is the deepest, as `budctl anc on` is.
98107 fn noise_mode(mode: &str, level: Option<&str>) -> Result<NoiseMode> {
99108 match (mode, level) {
@@ -132,7 +141,10 @@ pub fn event(event: &Event) -> serde_json::Result<String> {
132141 Event::Touch(gestures) => ("touch", vec![("gestures", to_value(gestures)?)]),
133142 Event::HoldCycle(modes) => ("hold-cycle", vec![("modes", to_value(modes)?)]),
134143 Event::Ringing(ring) => ("ringing", vec![("ring", Value::from(*ring))]),
135− Event::Bass(level) => ("bass", vec![("level", Value::from(*level))]),
144+ Event::Bass(band, level) => (
145+ "bass",
146+ vec![("band", Value::from(band.name())), ("level", Value::from(*level))],
147+ ),
136148 Event::Presets(eq) => ("presets", vec![("eq", to_value(eq)?)]),
137149 Event::Failed { kind, message } => (
138150 "failed",
@@ -251,6 +263,10 @@ mod tests {
251263 command(r#"{"command": "hold-cycle", "modes": ["off", "anc"]}"#),
252264 Ok(Command::HoldCycle(modes)) if modes == ["off", "anc"]
253265 ));
266+ assert!(matches!(
267+ command(r#"{"command": "bass", "band": "medium", "level": -2}"#),
268+ Ok(Command::Bass(BassBand::Medium, -2))
269+ ));
254270 }
255271
256272 #[test]
@@ -259,7 +275,9 @@ mod tests {
259275 r#"{"command": "reboot"}"#,
260276 r#"{"command": "noise", "mode": "loud"}"#,
261277 r#"{"command": "noise", "mode": "off", "level": "deep"}"#,
262− r#"{"command": "bass", "level": 300}"#,
278+ r#"{"command": "bass", "band": "low", "level": 300}"#,
279+ r#"{"command": "bass", "band": "deep", "level": 1}"#,
280+ r#"{"command": "bass", "level": 1}"#,
263281 "not json",
264282 ] {
265283 assert_eq!(command(json).err().map(|e| e.kind()), Some(Kind::Usage), "{json}");
@@ -293,6 +311,10 @@ mod tests {
293311 parse(&Event::Ringing(true)),
294312 serde_json::json!({"event": "ringing", "ring": true})
295313 );
314+ assert_eq!(
315+ parse(&Event::Bass(BassBand::High, 3)),
316+ serde_json::json!({"event": "bass", "band": "high", "level": 3})
317+ );
296318 }
297319
298320 #[test]
M crates/budctl-core/src/devices/mod.rs View
@@ -81,8 +81,8 @@ pub trait Session: Send {
8181 )))
8282 }
8383
84− /// The lowest and the highest level [`Session::set_bass_level`] takes, when the buds have a
85− /// bass level to set.
84+ /// The lowest and the highest level [`Session::set_bass_band`] takes, when the buds have bass
85+ /// levels to set.
8686 fn bass_range(&self) -> Option<(i8, i8)> {
8787 None
8888 }
@@ -111,13 +111,6 @@ pub trait Session: Send {
111111 )))
112112 }
113113
114− /// Set the bass level. Only the ack can be checked when the buds cannot report the level.
115− fn set_bass_level(&mut self, _level: i8) -> Result<()> {
116− Err(Error::Unsupported(
117− "budctl cannot change the bass level on these buds".to_owned(),
118− ))
119− }
120−
121114 /// Set the level of one band of bass boost, on buds that set it band by band. Only the ack can
122115 /// be checked when the buds cannot report the level.
123116 fn set_bass_band(&mut self, _band: BassBand, _level: i8) -> Result<()> {
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -503,16 +503,8 @@ impl Session for T310Session {
503503 .then_some(codec::BASS_RANGE)
504504 }
505505
506− fn set_bass_level(&mut self, level: i8) -> Result<()> {
507− let payload = codec::bass_write_payload(level)?;
508− if !self.commands.allows(codec::BASS_LEVEL_WRITE) {
509− return Err(Error::Unsupported("these buds do not offer a bass level".to_owned()));
510− }
511− let reply = self.link.request(codec::BASS_LEVEL_WRITE, &payload)?;
512− codec::check_write_reply(&reply, "the bass-level change")
513− }
514−
515− /// Ack-only too: the T310 never answers the bass-level read. Spec: realme_t310.md#bass-bands
506+ /// Ack-only: the T310 never answers the bass-level read. A level without its band is ignored,
507+ /// so there is no band-less write. Spec: realme_t310.md#bass-bands
516508 fn set_bass_band(&mut self, band: BassBand, level: i8) -> Result<()> {
517509 let payload = codec::bass_band_payload(band, level)?;
518510 if !self.commands.allows(codec::BASS_LEVEL_WRITE) {
@@ -1357,17 +1349,6 @@ mod tests {
13571349 assert!(eq.warnings.is_empty());
13581350 }
13591351
1360− #[test]
1361− fn sets_the_bass_level() {
1362− let mut steps = handshake();
1363− steps.extend([tx(0x041B, 0x05, "FB 05 FE"), rx(0x841B, 0x05, "00")]);
1364− let script = Script::new(steps);
1365− let mut session = open(&script).unwrap();
1366− assert_eq!(session.bass_range(), Some((-5, 5)));
1367− session.set_bass_level(-2).unwrap();
1368− script.assert_done();
1369− }
1370−
13711352 #[test]
13721353 fn sets_a_bass_band_with_its_band_byte() {
13731354 let mut steps = handshake();
@@ -1379,6 +1360,7 @@ mod tests {
13791360 ]);
13801361 let script = Script::new(steps);
13811362 let mut session = open(&script).unwrap();
1363+ assert_eq!(session.bass_range(), Some((-5, 5)));
13821364 session.set_bass_band(BassBand::Low, 5).unwrap();
13831365 session.set_bass_band(BassBand::High, -5).unwrap();
13841366 script.assert_done();
@@ -1399,7 +1381,7 @@ mod tests {
13991381 let mut steps = handshake();
14001382 steps.pop();
14011383 let script = Script::new(steps);
1402− let result = open(&script).unwrap().set_bass_level(6);
1384+ let result = open(&script).unwrap().set_bass_band(BassBand::Low, 6);
14031385 script.assert_done();
14041386 assert!(matches!(result, Err(Error::Usage(_))));
14051387 }
M crates/budctl-core/src/worker.rs View
@@ -13,7 +13,7 @@ use std::time::{Duration, Instant};
1313 use crate::devices::Session;
1414 use crate::error::{Error, Kind, Result};
1515 use crate::status::{
16− DualDevice, EqBand, EqPreset, EqPresets, Gesture, NoiseControl, NoiseMode, Status, Switch, TouchControls,
16+ BassBand, DualDevice, EqBand, EqPreset, EqPresets, Gesture, NoiseControl, NoiseMode, Status, Switch, TouchControls,
1717 };
1818
1919 /// How long the session stays open after the last command, unless the front end says otherwise.
@@ -43,8 +43,8 @@ pub enum Command {
4343 HoldCycle(Vec<String>),
4444 /// Start (`true`) or stop ringing the buds.
4545 Find(bool),
46− /// Set the bass level.
47− Bass(i8),
46+ /// Set the bass level of one band of bass boost.
47+ Bass(BassBand, i8),
4848 /// Create a custom EQ preset with this name.
4949 CreateEq(String),
5050 /// Give a custom EQ preset (`custom-<id>`) a new name.
@@ -73,8 +73,8 @@ pub enum Event {
7373 HoldCycle(Vec<String>),
7474 /// The answer to [`Command::Find`]: whether the buds were told to ring.
7575 Ringing(bool),
76− /// The answer to [`Command::Bass`]: the level the buds took.
77− Bass(i8),
76+ /// The answer to [`Command::Bass`]: the band, and the level the buds took for it.
77+ Bass(BassBand, i8),
7878 /// The answer to [`Command::CreateEq`] and [`Command::DeleteEq`]: the whole preset list after
7979 /// it (ids can change).
8080 Presets(EqPresets),
@@ -226,10 +226,10 @@ impl Worker {
226226 .session()
227227 .and_then(|session| session.rename_custom_eq(&preset, &name))
228228 .map(Event::CustomEq),
229− Command::Bass(level) => self
229+ Command::Bass(band, level) => self
230230 .session()
231− .and_then(|session| session.set_bass_level(level))
232− .map(|()| Event::Bass(level)),
231+ .and_then(|session| session.set_bass_band(band, level))
232+ .map(|()| Event::Bass(band, level)),
233233 Command::Find(ring) => self
234234 .session()
235235 .and_then(|session| session.find(ring))
M crates/budctl-gui/src/buds.rs View
@@ -3,10 +3,37 @@
33
44 use budctl_core::bt::BdAddr;
55 use budctl_core::status::{
6− Battery, Charge, DualDevice, EqBand, EqPreset, EqPresets, Gesture, GestureChoice, NoiseControl, Status, Switch,
7− TouchControls,
6+ BassBand, Battery, Charge, DualDevice, EqBand, EqPreset, EqPresets, Gesture, GestureChoice, NoiseControl, Status,
7+ Switch, TouchControls,
88 };
99
10+/// A bass level for each band of bass boost, where one is known.
11+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
12+pub struct BassLevels {
13+ low: Option<i8>,
14+ medium: Option<i8>,
15+ high: Option<i8>,
16+}
17+
18+impl BassLevels {
19+ pub fn get(self, band: BassBand) -> Option<i8> {
20+ match band {
21+ BassBand::Low => self.low,
22+ BassBand::Medium => self.medium,
23+ BassBand::High => self.high,
24+ }
25+ }
26+
27+ pub fn set(&mut self, band: BassBand, level: Option<i8>) {
28+ let slot = match band {
29+ BassBand::Low => &mut self.low,
30+ BassBand::Medium => &mut self.medium,
31+ BassBand::High => &mut self.high,
32+ };
33+ *slot = level;
34+ }
35+}
36+
1037 /// The buds the window shows.
1138 pub struct Buds {
1239 pub name: String,
@@ -16,10 +43,10 @@ pub struct Buds {
1643 pub eq: Option<EqPresets>,
1744 /// The touch controls, when the buds report them.
1845 pub touch: Option<TouchControls>,
19− /// The levels the bass level takes, when the buds have one to set.
46+ /// The levels each band of bass boost takes, when the buds have bass levels to set.
2047 pub bass_range: Option<(i8, i8)>,
21− /// The bass level last set from this window. The buds do not report it.
22− pub bass: Option<i8>,
48+ /// The bass level of each band, as last set from this window. The buds do not report them.
49+ pub bass: BassLevels,
2350 /// The devices dual connection keeps, when the buds list them.
2451 pub dual: Option<Vec<DualDevice>>,
2552 }
@@ -185,7 +212,7 @@ pub fn demo() -> Buds {
185212 warnings: Vec::new(),
186213 }),
187214 bass_range: Some((-5, 5)),
188− bass: None,
215+ bass: BassLevels::default(),
189216 dual: Some(vec![
190217 DualDevice {
191218 address: BdAddr::new([0x2B, 0x1A, 0x00, 0xAA, 0xBB, 0xCC]),
@@ -216,7 +243,7 @@ pub fn unknown() -> Buds {
216243 eq: None,
217244 touch: None,
218245 bass_range: None,
219− bass: None,
246+ bass: BassLevels::default(),
220247 dual: None,
221248 }
222249 }
M crates/budctl-gui/src/dots.rs View
@@ -633,6 +633,7 @@ fn steps(since: Instant, every: Duration, now: Instant) -> (u128, Option<Instant
633633 #[cfg(test)]
634634 mod tests {
635635 use super::*;
636+ use budctl_core::status::BassBand;
636637
637638 fn slider(low: i16, high: i16) -> Slider<fn(i16, bool) -> Message> {
638639 Slider {
@@ -640,8 +641,8 @@ mod tests {
640641 high,
641642 value: 0,
642643 vertical: false,
643− change: |_, _| Message::SaveBass,
644− release: Message::SaveBass,
644+ change: |_, _| Message::SaveBass(BassBand::Low),
645+ release: Message::SaveBass(BassBand::Low),
645646 on: Color::WHITE,
646647 off: Color::BLACK,
647648 motion: false,
M crates/budctl-gui/src/main.rs View
@@ -28,12 +28,13 @@ use std::time::{Duration, Instant};
2828
2929 use budctl_core::Kind;
3030 use budctl_core::bt::BdAddr;
31−use budctl_core::status::{AncLevel, EqBand, EqPreset, NoiseControl, NoiseMode, Switch};
31+use budctl_core::status::{AncLevel, BassBand, EqBand, EqPreset, NoiseControl, NoiseMode, Switch};
3232 use iced::keyboard::{self, Key, Modifiers};
3333 use iced::widget::operation::{self, RelativeOffset};
3434 use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text, text_input};
3535 use iced::{Alignment, Border, Element, Length, Size, Subscription, Task, Theme, event, mouse, window};
3636
37+use buds::BassLevels;
3738 use keys::Action;
3839 use theme::Look;
3940 use worker::{Command, Event, Reading};
@@ -268,8 +269,8 @@ enum Item {
268269 Cycle(u8),
269270 /// Find my earbuds.
270271 Find,
271− /// The bass level.
272− Bass,
272+ /// One band's bass level.
273+ Bass(BassBand),
273274 /// "New preset", on the Equalizer page.
274275 NewPreset,
275276 /// "Rename", on a custom EQ preset's card.
@@ -400,13 +401,14 @@ enum Message {
400401 Find,
401402 /// The answer to "Ring the buds?".
402403 ConfirmFind(bool),
403− /// Move the bass level. With `save`, set it on the buds right away, else when
404+ /// Move one band's bass level. With `save`, set it on the buds right away, else when
404405 /// [`Message::SaveBass`] comes (the mouse lets go of the slider).
405406 Bass {
407+ band: BassBand,
406408 level: i8,
407409 save: bool,
408410 },
409− SaveBass,
411+ SaveBass(BassBand),
410412 /// Ask for a new preset's name.
411413 NewPreset,
412414 /// Ask for a custom preset's (by its id) new name.
@@ -473,8 +475,8 @@ struct App {
473475 draft: Option<Draft>,
474476 /// A gesture's action picked and not written yet.
475477 touch_draft: Option<TouchDraft>,
476− /// A bass level moved and not set yet.
477− bass_draft: Option<i8>,
478+ /// Bass levels moved and not set yet, band by band.
479+ bass_draft: BassLevels,
478480 /// The buds were told to ring and not yet to stop.
479481 ringing: bool,
480482 /// "Ring the buds?" is open.
@@ -535,7 +537,7 @@ impl App {
535537 read_at: None,
536538 draft: None,
537539 touch_draft: None,
538− bass_draft: None,
540+ bass_draft: BassLevels::default(),
539541 ringing: false,
540542 confirm_find: false,
541543 palette_problem: None,
@@ -602,14 +604,14 @@ impl App {
602604 }
603605 }
604606 Message::ShowHelp => self.help = true,
605− Message::Bass { level, save } => {
607+ Message::Bass { band, level, save } => {
606608 let (low, high) = self.buds.bass_range.unwrap_or((i8::MIN, i8::MAX));
607− self.bass_draft = Some(level.clamp(low, high));
609+ self.bass_draft.set(band, Some(level.clamp(low, high)));
608610 if save {
609− self.save_bass();
611+ self.save_bass(band);
610612 }
611613 }
612− Message::SaveBass => self.save_bass(),
614+ Message::SaveBass(band) => self.save_bass(band),
613615 Message::NewPreset => return self.ask_name(NameTarget::New, String::new()),
614616 Message::RenamePreset(id) => {
615617 let name = self.buds.preset(id).map(|p| p.name.clone()).unwrap_or_default();
@@ -802,9 +804,9 @@ impl App {
802804 }
803805 }
804806 Command::Find(ring) => self.ringing = ring,
805− Command::Bass(level) => {
806− self.buds.bass = Some(level);
807− self.bass_draft = None;
807+ Command::Bass(band, level) => {
808+ self.buds.bass.set(band, Some(level));
809+ self.bass_draft.set(band, None);
808810 }
809811 Command::CreateEq(name) => {
810812 let customs = || {
@@ -958,12 +960,12 @@ impl App {
958960 self.editing = equalizer::in_use(&self.buds).map(|preset| preset.id);
959961 }
960962 }
961− Event::Bass(level) => {
963+ Event::Bass(band, level) => {
962964 self.in_flight.pop_front();
963− self.buds.bass = Some(level);
965+ self.buds.bass.set(band, Some(level));
964966 self.link = Link::Live;
965967 // A level moved while this one was on its way goes out now.
966− self.save_bass();
968+ self.save_bass(band);
967969 }
968970 Event::Ringing(ring) => {
969971 self.in_flight.pop_front();
@@ -986,7 +988,7 @@ impl App {
986988 Some(Some(Item::Gesture(row))) if self.touch_draft.as_ref().is_some_and(|d| d.row == row) => {
987989 self.touch_draft = None;
988990 }
989− Some(Some(Item::Bass)) => self.bass_draft = None,
991+ Some(Some(Item::Bass(band))) => self.bass_draft.set(band, None),
990992 _ => {}
991993 }
992994 if kind == Kind::NotFound {
@@ -1187,16 +1189,16 @@ impl App {
11871189 }
11881190 }
11891191
1190− /// Sets the bass level moved on the screen. If a level is on its way, this one goes out
1191− /// when that is answered: the latest level wins.
1192− fn save_bass(&mut self) {
1193− let Some(level) = self.bass_draft else {
1192+ /// Sets a band's bass level moved on the screen. If a level for that band is on its way,
1193+ /// this one goes out when that is answered: the latest level wins.
1194+ fn save_bass(&mut self, band: BassBand) {
1195+ let Some(level) = self.bass_draft.get(band) else {
11941196 return;
11951197 };
1196− if self.buds.bass == Some(level) {
1197− self.bass_draft = None;
1198− } else if !self.pending().contains(&Item::Bass) {
1199− self.write(Item::Bass, Command::Bass(level));
1198+ if self.buds.bass.get(band) == Some(level) {
1199+ self.bass_draft.set(band, None);
1200+ } else if !self.pending().contains(&Item::Bass(band)) {
1201+ self.write(Item::Bass(band), Command::Bass(band, level));
12001202 }
12011203 }
12021204
@@ -1391,9 +1393,9 @@ impl App {
13911393 Item::Delete(id) => (action != Action::Left).then_some(Message::DeletePreset(id)),
13921394 Item::Edit(id) => (action != Action::Left).then_some(Message::EditPreset(id)),
13931395 Item::Done => (action != Action::Left).then_some(Message::DoneEditing),
1394− Item::Bass => {
1396+ Item::Bass(band) => {
13951397 let (low, high) = self.buds.bass_range?;
1396− let now = self.bass_draft.or(self.buds.bass);
1398+ let now = self.bass_draft.get(band).or(self.buds.bass.get(band));
13971399 let to = match (action, now) {
13981400 // Not set yet: the first step starts from the middle.
13991401 (Action::Left | Action::Right, None) => 0,
@@ -1401,7 +1403,11 @@ impl App {
14011403 (Action::Right, Some(level)) => level.checked_add(1)?.min(high),
14021404 _ => return None,
14031405 };
1404− (Some(to) != now).then_some(Message::Bass { level: to, save: true })
1406+ (Some(to) != now).then_some(Message::Bass {
1407+ band,
1408+ level: to,
1409+ save: true,
1410+ })
14051411 }
14061412 Item::Cycle(mode) => {
14071413 let touch = self.buds.touch.as_ref()?;
@@ -1919,7 +1925,9 @@ mod tests {
19191925 Item::Switch("game"),
19201926 Item::Switch("spatial"),
19211927 Item::Switch("bass-boost"),
1922− Item::Bass,
1928+ Item::Bass(BassBand::Low),
1929+ Item::Bass(BassBand::Medium),
1930+ Item::Bass(BassBand::High),
19231931 Item::Switch("wind"),
19241932 Item::Switch("dual"),
19251933 Item::Open(Page::Equalizer),
@@ -2091,7 +2099,8 @@ mod tests {
20912099 assert_eq!(app.link, Link::Live);
20922100 assert_eq!(app.buds.name, "realme Buds T310");
20932101 assert!(app.buds.connected);
2094− assert_eq!(app.items().len(), 11);
2102+ // The noise control and its strength, five switches, three bass bands, three pages.
2103+ assert_eq!(app.items().len(), 13);
20952104 }
20962105
20972106 #[test]
@@ -2338,17 +2347,33 @@ mod tests {
23382347 }
23392348
23402349 #[test]
2341− fn the_bass_level_by_key() {
2350+ fn the_bass_levels_by_key() {
23422351 let mut app = app();
23432352 press(&mut app, "j j j j j j");
2344− assert_eq!(app.focus(), Some(Item::Bass), "under bass boost, on the Overview");
2345− assert_eq!(app.buds.bass, None, "the buds do not say");
2353+ assert_eq!(
2354+ app.focus(),
2355+ Some(Item::Bass(BassBand::Low)),
2356+ "under bass boost, on the Overview"
2357+ );
2358+ assert_eq!(app.buds.bass.get(BassBand::Low), None, "the buds do not say");
23462359 press(&mut app, "l");
2347− assert_eq!(app.buds.bass, Some(0), "the first step starts from the middle");
2360+ assert_eq!(
2361+ app.buds.bass.get(BassBand::Low),
2362+ Some(0),
2363+ "the first step starts from the middle"
2364+ );
23482365 press(&mut app, "l l");
2349− assert_eq!(app.buds.bass, Some(2));
2366+ assert_eq!(app.buds.bass.get(BassBand::Low), Some(2));
23502367 press(&mut app, "h h h h h h h h");
2351− assert_eq!(app.buds.bass, Some(-5), "it stops at the bottom");
2368+ assert_eq!(app.buds.bass.get(BassBand::Low), Some(-5), "it stops at the bottom");
2369+ press(&mut app, "j l");
2370+ assert_eq!(app.focus(), Some(Item::Bass(BassBand::Medium)));
2371+ assert_eq!(
2372+ app.buds.bass.get(BassBand::Medium),
2373+ Some(0),
2374+ "each band has its own level"
2375+ );
2376+ assert_eq!(app.buds.bass.get(BassBand::Low), Some(-5));
23522377 }
23532378
23542379 #[test]
@@ -2356,20 +2381,38 @@ mod tests {
23562381 let (mut app, commands) = live();
23572382 let _ = commands.try_recv();
23582383 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2359− let _ = app.update(Message::Bass { level: 2, save: false });
2384+ let low = BassBand::Low;
2385+ let _ = app.update(Message::Bass {
2386+ band: low,
2387+ level: 2,
2388+ save: false,
2389+ });
23602390 assert!(
23612391 commands.try_recv().is_err(),
23622392 "a drag sends nothing until the mouse lets go"
23632393 );
2364− let _ = app.update(Message::SaveBass);
2365− assert!(matches!(commands.try_recv(), Ok(Command::Bass(2))));
2366− let _ = app.update(Message::Bass { level: 4, save: true });
2394+ let _ = app.update(Message::SaveBass(low));
2395+ assert!(matches!(commands.try_recv(), Ok(Command::Bass(BassBand::Low, 2))));
2396+ let _ = app.update(Message::Bass {
2397+ band: low,
2398+ level: 4,
2399+ save: true,
2400+ });
23672401 assert!(commands.try_recv().is_err());
2368− let _ = app.update(Message::Worker(Event::Bass(2)));
2369− assert!(matches!(commands.try_recv(), Ok(Command::Bass(4))));
2370− let _ = app.update(Message::Worker(Event::Bass(4)));
2371− assert_eq!(app.buds.bass, Some(4));
2372− assert_eq!(app.bass_draft, None);
2402+ // Another band does not wait for this one.
2403+ let _ = app.update(Message::Bass {
2404+ band: BassBand::High,
2405+ level: -1,
2406+ save: true,
2407+ });
2408+ assert!(matches!(commands.try_recv(), Ok(Command::Bass(BassBand::High, -1))));
2409+ let _ = app.update(Message::Worker(Event::Bass(low, 2)));
2410+ assert!(matches!(commands.try_recv(), Ok(Command::Bass(BassBand::Low, 4))));
2411+ let _ = app.update(Message::Worker(Event::Bass(BassBand::High, -1)));
2412+ let _ = app.update(Message::Worker(Event::Bass(low, 4)));
2413+ assert_eq!(app.buds.bass.get(low), Some(4));
2414+ assert_eq!(app.buds.bass.get(BassBand::High), Some(-1));
2415+ assert_eq!(app.bass_draft, BassLevels::default());
23732416 assert!(commands.try_recv().is_err());
23742417 }
23752418
M crates/budctl-gui/src/overview.rs View
@@ -1,10 +1,10 @@
11 //! The Overview page: battery, noise control, the switches, and the way to the other pages.
22
3−use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch};
3+use budctl_core::status::{AncLevel, BassBand, Charge, NoiseMode, Switch};
44 use iced::widget::{column, container, row};
55 use iced::{Alignment, Element, Length};
66
7−use crate::buds::Buds;
7+use crate::buds::{BassLevels, Buds};
88 use crate::dots;
99 use crate::equalizer;
1010 use crate::icons::{self, Icon};
@@ -24,25 +24,25 @@ pub fn items(buds: &Buds) -> Vec<Item> {
2424 for switch in shown(&buds.status.switches) {
2525 items.push(Item::Switch(switch.key));
2626 if switch.key == BASS_BOOST && buds.bass_range.is_some() {
27− items.push(Item::Bass);
27+ items.extend(BassBand::ALL.map(Item::Bass));
2828 }
2929 }
3030 items.extend(MORE.map(|(page, _, _)| Item::Open(page)));
3131 items
3232 }
3333
34−/// The switch the bass level shows under.
34+/// The switch the bass levels show under.
3535 const BASS_BOOST: &str = "bass-boost";
3636
3737 /// Shown instead of an item's subtitle while a write to it is on its way.
3838 const SAVING: &str = "Saving…";
3939
40−/// `bass`: a bass level moved on the screen and not set on the buds yet.
40+/// `bass`: bass levels moved on the screen and not set on the buds yet.
4141 pub fn view<'a>(
4242 look: Look,
4343 buds: &'a Buds,
4444 anc_level: AncLevel,
45− bass: Option<i8>,
45+ bass: BassLevels,
4646 marks: Marks,
4747 ) -> Element<'a, Message> {
4848 column![
@@ -155,7 +155,7 @@ fn shown(switches: &[Switch]) -> Vec<&Switch> {
155155 shown
156156 }
157157
158−fn switches<'a>(look: Look, buds: &Buds, bass: Option<i8>, marks: Marks) -> Element<'a, Message> {
158+fn switches<'a>(look: Look, buds: &Buds, bass: BassLevels, marks: Marks) -> Element<'a, Message> {
159159 let known = |key: &str| SWITCHES.iter().find(|(k, _, _)| *k == key);
160160 let mut rows = Vec::new();
161161 for s in shown(&buds.status.switches) {
@@ -174,20 +174,34 @@ fn switches<'a>(look: Look, buds: &Buds, bass: Option<i8>, marks: Marks) -> Elem
174174 )
175175 });
176176 if let (BASS_BOOST, Some(range)) = (s.key, buds.bass_range) {
177− rows.push(bass_level(look, range, bass.or(buds.bass), marks));
177+ for band in BassBand::ALL {
178+ let level = bass.get(band).or(buds.bass.get(band));
179+ rows.push(bass_level(look, range, band, level, marks));
180+ }
178181 }
179182 }
180183 ui::card(look, rows)
181184 }
182185
183−/// The bass level: the buds take it but do not report it, so the row shows the last level set
184−/// from this window, if any.
185−fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: Marks) -> Element<'a, Message> {
186+/// One band of bass boost: the buds take its level but do not report it, so the row shows the last
187+/// level set from this window, if any.
188+fn bass_level<'a>(
189+ look: Look,
190+ (low, high): (i8, i8),
191+ band: BassBand,
192+ level: Option<i8>,
193+ marks: Marks,
194+) -> Element<'a, Message> {
186195 let p = look.p;
187− let about = if marks.pending.contains(&Item::Bass) {
196+ let about = if marks.pending.contains(&Item::Bass(band)) {
188197 SAVING
189198 } else {
190− "Last set here: the buds do not report it"
199+ "Bass boost, last set here"
200+ };
201+ let title = match band {
202+ BassBand::Low => "Low frequencies",
203+ BassBand::Medium => "Medium frequencies",
204+ BassBand::High => "High frequencies",
191205 };
192206 let shown = level.map_or_else(|| "not set".to_owned(), equalizer::gain);
193207 let slider = dots::Slider {
@@ -195,11 +209,12 @@ fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: M
195209 high: i16::from(high),
196210 value: i16::from(level.unwrap_or(0)),
197211 vertical: false,
198− change: |level, save| Message::Bass {
212+ change: move |level, save| Message::Bass {
213+ band,
199214 level: i8::try_from(level).unwrap_or(0),
200215 save,
201216 },
202− release: Message::SaveBass,
217+ release: Message::SaveBass(band),
203218 on: p.fill,
204219 off: p.s3,
205220 motion: look.motion,
@@ -210,11 +225,11 @@ fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: M
210225 ui::setting(
211226 look,
212227 Icon::Bass,
213− "Bass level",
228+ title,
214229 Some(about.to_owned()),
215230 control.into(),
216231 None,
217− marks.focus == Some(Item::Bass),
232+ marks.focus == Some(Item::Bass(band)),
218233 )
219234 }
220235
M docs/protocol/realme_t310.md View
@@ -455,7 +455,7 @@ 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:
458+- <a id="bass-bands"></a>**The band byte** (*vendor*; the low band *ours-2026-11-02*, W6c). 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:
459459
460460 | Payload | Band |
461461 |---|---|
@@ -463,7 +463,7 @@ Evidence: *vendor*
463463 | `FB 05 <level s8> 02` | medium frequencies |
464464 | `FB 05 <level s8> 03` | high frequencies |
465465
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.
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 sends the band byte: `budctl bass <band> <level>`, and one slider per band in the desktop and Android apps. **On the owner's T310** (*ours-2026-11-02*, W6c): with bass boost on, the low band at +5 and at -5 sound definitely different. The band-less write is not sent any more.
467467 - **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`.
468468
469469 <a id="find"></a>
@@ -628,6 +628,7 @@ The owner will not install realme Link (2026-10-27), so there is no app capture.
628628
629629 | Date | Checkpoint | Result | Notes |
630630 |---|---|---|---|
631+| 2026-11-02 | W6c `set bass-boost on`, then `bass low 5`, `bass low -5`, `bass low 5` | pass | Each write went out byte for byte as the derived frame (`AA 0B 00 00 1B 04 05 04 00 FB 05 05 01` for +5, `FB` in the level byte for -5) and was acked with `0x841B 00` within 20-36 ms. The owner heard a definite difference between +5 and -5, where W6b's band-less writes changed nothing clear. The medium and high bands (`02`, `03`) were not tried by ear. See [§10.4](#bass-bands) |
631632 | 2026-11-02 | A4 Android app (v0.1.4) and `budctl status` on the laptop | pass (owner's report) | `budctl status` on the laptop works while the Android app is open and connected, not only once the app has let go: with dual connection on, the T310 gives each host a control link of its own, as with a phone app on 2026-10-27. No log was taken of this run |
632633 | 2026-11-01 | A3 Android app, Gestures and Earbud settings pages | pass | Right double tap set to `none` and back to `play-pause`, each written (`0x0401`) and read back in about 1 s. Six hold-cycle writes (`0x0404 02 01`), each acked and read back in 0.25-1.8 s, ending at off → anc as before. Ring and Stop (`0x0400 01`/`00`) acked; the buds rang. The devices list showed. The owner confirmed it all on screen |
633634 | 2026-11-01 | A3 Android app, Equalizer page: built-in presets, the custom one, its bands, a new preset, a delete | pass | Each built-in preset (`0x0406`) and the custom one were put in use and read back in 0.25-0.45 s. Fifteen band saves on `custom-4` were each acked and read back in 0.4-0.7 s; bands moved while a save was on its way went out as soon as it was answered, the latest winning. "New preset" created `custom-5` and opened its bands, five saves went to it, and the bin deleted it. Rename was not tried on the phone |
M docs/screenshots/android-overview.png View

[Binary file]

M docs/screenshots/keys.png View

[Binary file]

M docs/screenshots/overview.png View

[Binary file]