Krolyxon
Commit 6dcfe3c

Branches

Refs →

budctl6dcfe3c

budctl-gui: the bass level, on the Equalizer page

The buds take a bass level but never report it (W6a), so the card says
so and shows the level last set from the window ("Not set" before
that). A drag sets it once, on release; h/l step it, the first step
from the middle. A level moved while one is on its way goes out when
that is answered, so the latest level wins.

Core: Session::bass_range() declares the levels set_bass_level takes,
when the buds have one (the T310: -5 to +5, if its bitmap allows the
write), so front ends show the setting only for buds that declare it.
The worker reads it with the rest and gained Command::Bass.

No new dependencies.
Author
krolyxon
Date
2026-10-28 01:15 +0530
Parent
a345a10
Commit
6dcfe3c4c474cb7daa65913af0e3f02243c29675

+236 −18

6 files changed

  1. M crates/budctl-core/src/devices/mod.rs +6 −0
  2. M crates/budctl-core/src/devices/realme_t310/mod.rs +10 −1
  3. M crates/budctl-gui/src/buds.rs +8 −0
  4. M crates/budctl-gui/src/equalizer.rs +95 −12
  5. M crates/budctl-gui/src/main.rs +100 −4
  6. M crates/budctl-gui/src/worker.rs +17 −1
M crates/budctl-core/src/devices/mod.rs View
@@ -78,6 +78,12 @@ pub trait Session: Send {
7878 )))
7979 }
8080
81+ /// The lowest and the highest level [`Session::set_bass_level`] takes, when the buds have a
82+ /// bass level to set.
83+ fn bass_range(&self) -> Option<(i8, i8)> {
84+ None
85+ }
86+
8187 /// Set the bass level. Only the ack can be checked when the buds cannot report the level.
8288 fn set_bass_level(&mut self, _level: i8) -> Result<()> {
8389 Err(Error::Unsupported(
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -379,6 +379,13 @@ impl Session for T310Session {
379379 }
380380
381381 /// Ack-only: the T310 never answers the bass-level read. Spec: realme_t310.md#bass-write
382+ /// Spec: realme_t310.md#bass-write
383+ fn bass_range(&self) -> Option<(i8, i8)> {
384+ self.commands
385+ .allows(codec::BASS_LEVEL_WRITE)
386+ .then_some(codec::BASS_RANGE)
387+ }
388+
382389 fn set_bass_level(&mut self, level: i8) -> Result<()> {
383390 let payload = codec::bass_write_payload(level)?;
384391 if !self.commands.allows(codec::BASS_LEVEL_WRITE) {
@@ -1180,7 +1187,9 @@ mod tests {
11801187 let mut steps = handshake();
11811188 steps.extend([tx(0x041B, 0x05, "FB 05 FE"), rx(0x841B, 0x05, "00")]);
11821189 let script = Script::new(steps);
1183− open(&script).unwrap().set_bass_level(-2).unwrap();
1190+ let mut session = open(&script).unwrap();
1191+ assert_eq!(session.bass_range(), Some((-5, 5)));
1192+ session.set_bass_level(-2).unwrap();
11841193 script.assert_done();
11851194 }
11861195
M crates/budctl-gui/src/buds.rs View
@@ -14,6 +14,10 @@ pub struct Buds {
1414 pub eq: Option<EqPresets>,
1515 /// The touch controls, when the buds report them.
1616 pub touch: Option<TouchControls>,
17+ /// The levels the bass level takes, when the buds have one to set.
18+ pub bass_range: Option<(i8, i8)>,
19+ /// The bass level last set from this window. The buds do not report it.
20+ pub bass: Option<i8>,
1721 }
1822
1923 impl Buds {
@@ -161,6 +165,8 @@ pub fn demo() -> Buds {
161165 cycle_modes: names(&["off", "transparency", "anc"]),
162166 warnings: Vec::new(),
163167 }),
168+ bass_range: Some((-5, 5)),
169+ bass: None,
164170 }
165171 }
166172
@@ -172,6 +178,8 @@ pub fn unknown() -> Buds {
172178 status: Status::default(),
173179 eq: None,
174180 touch: None,
181+ bass_range: None,
182+ bass: None,
175183 }
176184 }
177185
M crates/budctl-gui/src/equalizer.rs View
@@ -1,6 +1,7 @@
11 //! The Equalizer page: the presets, and the bands of the custom ones.
22
33 use budctl_core::status::{EqBand, EqPreset};
4+use iced::widget::slider::Slider;
45 use iced::widget::{Row, column, container, row, slider, space, text, vertical_slider};
56 use iced::{Alignment, Border, Element, Length};
67
@@ -14,26 +15,39 @@ use crate::{Draft, Item, Marks, Message};
1415 const SAVING: &str = "Saving…";
1516
1617 /// What the keyboard cursor can rest on, top to bottom, as [`view`] shows them: each preset,
17−/// then each band of each custom preset.
18+/// then each band of each custom preset, then the bass level.
1819 pub fn items(buds: &Buds) -> Vec<Item> {
19− let Some(eq) = &buds.eq else {
20− return Vec::new();
21− };
22− let mut items: Vec<Item> = eq.presets.iter().map(|p| Item::Preset(p.id)).collect();
23− for preset in eq.presets.iter().filter(|p| p.custom) {
24− items.extend(preset.bands.iter().map(|band| Item::Band(preset.id, band.hz)));
20+ let mut items = Vec::new();
21+ if let Some(eq) = &buds.eq {
22+ items.extend(eq.presets.iter().map(|p| Item::Preset(p.id)));
23+ for preset in eq.presets.iter().filter(|p| p.custom) {
24+ items.extend(preset.bands.iter().map(|band| Item::Band(preset.id, band.hz)));
25+ }
26+ }
27+ if buds.bass_range.is_some() {
28+ items.push(Item::Bass);
2529 }
2630 items
2731 }
2832
29−/// `draft`: bands moved on the screen and not saved on the buds yet.
30−pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
33+/// `draft`: bands moved on the screen and not saved on the buds yet; `bass`: a bass level
34+/// likewise.
35+pub fn view<'a>(
36+ look: Look,
37+ buds: &'a Buds,
38+ draft: Option<&'a Draft>,
39+ bass: Option<i8>,
40+ marks: Marks,
41+) -> Element<'a, Message> {
3142 let title = ui::title(look, "Equalizer");
43+ let bass = buds
44+ .bass_range
45+ .map(|range| bass_level(look, range, bass.or(buds.bass), marks));
3246 let Some(eq) = &buds.eq else {
3347 let none = text("These buds did not list their presets.")
3448 .size(14)
3549 .color(look.p.dim);
36− return column![title, none].spacing(12).into();
50+ return column![title, none].push(bass).spacing(12).into();
3751 };
3852 let current = eq.current.as_deref();
3953 let rows = eq
@@ -70,9 +84,77 @@ pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Mar
7084 .fold(column![title, ui::card(look, rows)].spacing(18), |page, preset| {
7185 page.push(bands(look, preset, draft, marks))
7286 })
87+ .push(bass)
7388 .into()
7489 }
7590
91+/// The bass level: the buds take it but do not report it, so the page shows the last level
92+/// set from here, if any.
93+fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: Marks) -> Element<'a, Message> {
94+ let p = look.p;
95+ let focused = marks.focus == Some(Item::Bass);
96+ let saving = marks
97+ .pending
98+ .contains(&Item::Bass)
99+ .then(|| text(SAVING).size(13).color(p.icon));
100+ let shown = level.map_or_else(|| "Not set".to_owned(), gain);
101+ let heading = row![
102+ text("Bass level").size(16).font(look.medium()).color(p.fg),
103+ text(shown)
104+ .size(14)
105+ .font(look.medium())
106+ .color(p.dim)
107+ .width(Length::Fill)
108+ ]
109+ .push(saving)
110+ .spacing(10)
111+ .align_y(Alignment::Center);
112+ let note = text("The buds do not report their bass level: this is the last one set from here.")
113+ .size(13)
114+ .color(p.dim);
115+ let slider: Slider<'a, i16, Message> = slider(
116+ i16::from(low)..=i16::from(high),
117+ i16::from(level.unwrap_or(0)),
118+ |level| Message::Bass {
119+ level: i8::try_from(level).unwrap_or(0),
120+ save: false,
121+ },
122+ )
123+ .on_release(Message::SaveBass)
124+ .style(move |_, _| slider::Style {
125+ rail: slider::Rail {
126+ backgrounds: (p.accent.into(), p.surface_highest.into()),
127+ width: 6.0,
128+ border: Border::default().rounded(3),
129+ },
130+ handle: slider::Handle {
131+ shape: slider::HandleShape::Circle { radius: 9.0 },
132+ background: p.fg.into(),
133+ border_width: 2.0,
134+ border_color: p.accent,
135+ },
136+ });
137+ let scale = row![
138+ text(gain(low)).size(12).color(p.dim),
139+ space::horizontal(),
140+ text("0").size(12).color(p.dim),
141+ space::horizontal(),
142+ text(gain(high)).size(12).color(p.dim)
143+ ];
144+ let content = container(column![heading, note, slider, scale].spacing(12))
145+ .padding(12)
146+ .style(move |_| container::Style {
147+ background: focused.then_some(p.surface_high.into()),
148+ border: Border {
149+ color: p.accent,
150+ width: if focused { 2.0 } else { 0.0 },
151+ radius: 12.0.into(),
152+ },
153+ ..container::Style::default()
154+ });
155+ ui::panel(look, content.into())
156+}
157+
76158 /// A custom preset's bands, one slider each.
77159 fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
78160 let p = look.p;
@@ -168,10 +250,11 @@ mod tests {
168250 #[test]
169251 fn items_follow_the_page() {
170252 let items = items(&buds::demo());
171− assert_eq!(items.len(), 5 + 6);
253+ assert_eq!(items.len(), 5 + 6 + 1);
172254 assert_eq!(items.first(), Some(&Item::Preset(0)));
173255 assert_eq!(items.get(5), Some(&Item::Band(4, 50)));
174− assert_eq!(items.last(), Some(&Item::Band(4, 16000)));
256+ assert_eq!(items.get(10), Some(&Item::Band(4, 16000)));
257+ assert_eq!(items.last(), Some(&Item::Bass));
175258 assert!(super::items(&buds::unknown()).is_empty());
176259 }
177260
M crates/budctl-gui/src/main.rs View
@@ -190,6 +190,8 @@ enum Item {
190190 Cycle(u8),
191191 /// Find my earbuds.
192192 Find,
193+ /// The bass level.
194+ Bass,
193195 }
194196
195197 /// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the
@@ -259,6 +261,13 @@ enum Message {
259261 Find,
260262 /// The answer to "Ring the buds?".
261263 ConfirmFind(bool),
264+ /// Move the bass level. With `save`, set it on the buds right away, else when
265+ /// [`Message::SaveBass`] comes (the mouse lets go of the slider).
266+ Bass {
267+ level: i8,
268+ save: bool,
269+ },
270+ SaveBass,
262271 ShowHelp,
263272 /// Read the buds again.
264273 Refresh,
@@ -300,6 +309,8 @@ struct App {
300309 draft: Option<Draft>,
301310 /// A gesture's action picked and not written yet.
302311 touch_draft: Option<TouchDraft>,
312+ /// A bass level moved and not set yet.
313+ bass_draft: Option<i8>,
303314 /// The buds were told to ring and not yet to stop.
304315 ringing: bool,
305316 /// "Ring the buds?" is open.
@@ -339,6 +350,7 @@ impl App {
339350 read_at: None,
340351 draft: None,
341352 touch_draft: None,
353+ bass_draft: None,
342354 ringing: false,
343355 confirm_find: false,
344356 palette_problem: None,
@@ -376,6 +388,14 @@ impl App {
376388 }
377389 }
378390 Message::ShowHelp => self.help = true,
391+ Message::Bass { level, save } => {
392+ let (low, high) = self.buds.bass_range.unwrap_or((i8::MIN, i8::MAX));
393+ self.bass_draft = Some(level.clamp(low, high));
394+ if save {
395+ self.save_bass();
396+ }
397+ }
398+ Message::SaveBass => self.save_bass(),
379399 Message::Refresh => self.refresh(),
380400 Message::Worker(event) => self.answer(event),
381401 Message::Focused => {
@@ -427,6 +447,10 @@ impl App {
427447 }
428448 }
429449 Command::Find(ring) => self.ringing = ring,
450+ Command::Bass(level) => {
451+ self.buds.bass = Some(level);
452+ self.bass_draft = None;
453+ }
430454 Command::CustomEq(_, bands) => {
431455 if let Some(preset) = self.buds.preset(item_preset(item)) {
432456 let mut preset = preset.clone();
@@ -535,6 +559,13 @@ impl App {
535559 self.save_touch(row);
536560 }
537561 }
562+ Event::Bass(level) => {
563+ self.in_flight.pop_front();
564+ self.buds.bass = Some(level);
565+ self.link = Link::Live;
566+ // A level moved while this one was on its way goes out now.
567+ self.save_bass();
568+ }
538569 Event::Ringing(ring) => {
539570 self.in_flight.pop_front();
540571 self.ringing = ring;
@@ -556,6 +587,7 @@ impl App {
556587 Some(Some(Item::Gesture(row))) if self.touch_draft.as_ref().is_some_and(|d| d.row == row) => {
557588 self.touch_draft = None;
558589 }
590+ Some(Some(Item::Bass)) => self.bass_draft = None,
559591 _ => {}
560592 }
561593 if kind == Kind::NotFound {
@@ -569,6 +601,7 @@ impl App {
569601 fn apply_reading(&mut self, reading: Reading) {
570602 self.buds.eq = reading.eq;
571603 self.buds.touch = reading.touch;
604+ self.buds.bass_range = reading.bass_range;
572605 if let Some(level) = reading
573606 .status
574607 .noise_control
@@ -701,6 +734,19 @@ impl App {
701734 }
702735 }
703736
737+ /// Sets the bass level moved on the screen. If a level is on its way, this one goes out
738+ /// when that is answered: the latest level wins.
739+ fn save_bass(&mut self) {
740+ let Some(level) = self.bass_draft else {
741+ return;
742+ };
743+ if self.buds.bass == Some(level) {
744+ self.bass_draft = None;
745+ } else if !self.pending().contains(&Item::Bass) {
746+ self.write(Item::Bass, Command::Bass(level));
747+ }
748+ }
749+
704750 /// The items with a write on its way.
705751 fn pending(&self) -> Vec<Item> {
706752 self.in_flight.iter().flatten().copied().collect()
@@ -870,6 +916,18 @@ impl App {
870916 })
871917 }
872918 Item::Find => (action != Action::Left).then_some(Message::Find),
919+ Item::Bass => {
920+ let (low, high) = self.buds.bass_range?;
921+ let now = self.bass_draft.or(self.buds.bass);
922+ let to = match (action, now) {
923+ // Not set yet: the first step starts from the middle.
924+ (Action::Left | Action::Right, None) => 0,
925+ (Action::Left, Some(level)) => level.checked_sub(1)?.max(low),
926+ (Action::Right, Some(level)) => level.checked_add(1)?.min(high),
927+ _ => return None,
928+ };
929+ (Some(to) != now).then_some(Message::Bass { level: to, save: true })
930+ }
873931 Item::Cycle(mode) => {
874932 let touch = self.buds.touch.as_ref()?;
875933 let on = touch
@@ -951,7 +1009,7 @@ impl App {
9511009 focus: self.focus(),
9521010 pending: &pending,
9531011 };
954− equalizer::view(look, &self.buds, self.draft.as_ref(), marks)
1012+ equalizer::view(look, &self.buds, self.draft.as_ref(), self.bass_draft, marks)
9551013 }
9561014 Page::Gestures if self.has_reading() => {
9571015 let marks = Marks {
@@ -1230,6 +1288,7 @@ mod tests {
12301288 status: buds::demo().status,
12311289 eq: buds::demo().eq,
12321290 touch: buds::demo().touch,
1291+ bass_range: buds::demo().bass_range,
12331292 }
12341293 }
12351294
@@ -1514,7 +1573,7 @@ mod tests {
15141573 press(&mut app, "j l");
15151574 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
15161575 assert_eq!(app.buds.eq_name(), "Pure raw");
1517− press(&mut app, "G");
1576+ press(&mut app, "G k");
15181577 assert_eq!(app.focus(), Some(Item::Band(4, 16000)));
15191578 // Moving a band saves the custom preset, which also selects it.
15201579 press(&mut app, "l");
@@ -1530,7 +1589,7 @@ mod tests {
15301589 let (mut app, commands) = live();
15311590 let _ = commands.try_recv();
15321591 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1533− press(&mut app, "J G G l");
1592+ press(&mut app, "J G G k l");
15341593 let gain = |bands: &[EqBand]| bands.last().map(|band| band.gain);
15351594 assert!(matches!(
15361595 commands.try_recv(),
@@ -1648,6 +1707,42 @@ mod tests {
16481707 assert!(matches!(commands.try_recv(), Ok(Command::Find(false))));
16491708 }
16501709
1710+ #[test]
1711+ fn the_bass_level_by_key() {
1712+ let mut app = app();
1713+ press(&mut app, "J G G");
1714+ assert_eq!(app.focus(), Some(Item::Bass));
1715+ assert_eq!(app.buds.bass, None, "the buds do not say");
1716+ press(&mut app, "l");
1717+ assert_eq!(app.buds.bass, Some(0), "the first step starts from the middle");
1718+ press(&mut app, "l l");
1719+ assert_eq!(app.buds.bass, Some(2));
1720+ press(&mut app, "h h h h h h h h");
1721+ assert_eq!(app.buds.bass, Some(-5), "it stops at the bottom");
1722+ }
1723+
1724+ #[test]
1725+ fn bass_moves_wait_for_the_level_before_them() {
1726+ let (mut app, commands) = live();
1727+ let _ = commands.try_recv();
1728+ let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1729+ let _ = app.update(Message::Bass { level: 2, save: false });
1730+ assert!(
1731+ commands.try_recv().is_err(),
1732+ "a drag sends nothing until the mouse lets go"
1733+ );
1734+ let _ = app.update(Message::SaveBass);
1735+ assert!(matches!(commands.try_recv(), Ok(Command::Bass(2))));
1736+ let _ = app.update(Message::Bass { level: 4, save: true });
1737+ assert!(commands.try_recv().is_err());
1738+ let _ = app.update(Message::Worker(Event::Bass(2)));
1739+ assert!(matches!(commands.try_recv(), Ok(Command::Bass(4))));
1740+ let _ = app.update(Message::Worker(Event::Bass(4)));
1741+ assert_eq!(app.buds.bass, Some(4));
1742+ assert_eq!(app.bass_draft, None);
1743+ assert!(commands.try_recv().is_err());
1744+ }
1745+
16511746 #[test]
16521747 fn steps() {
16531748 let abc = ['a', 'b', 'c'];
@@ -1699,9 +1794,10 @@ mod tests {
16991794 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
17001795 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
17011796 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
1702− shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k")));
1797+ shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k k")));
17031798 shots.push(("gestures-cursor".to_owned(), 1000.0, on(Page::Gestures, "j j")));
17041799 shots.push(("find-confirm".to_owned(), 800.0, on(Page::Earbuds, "j Enter")));
1800+ shots.push(("equalizer-bass".to_owned(), 1300.0, on(Page::Equalizer, "G G l l")));
17051801 let mut connecting = App::new(palette.clone(), font, None, false);
17061802 connecting.link = Link::Connecting;
17071803 shots.push(("connecting".to_owned(), 800.0, connecting));
M crates/budctl-gui/src/worker.rs View
@@ -46,6 +46,8 @@ pub enum Command {
4646 HoldCycle(Vec<String>),
4747 /// Start (`true`) or stop ringing the buds.
4848 Find(bool),
49+ /// Set the bass level.
50+ Bass(i8),
4951 }
5052
5153 #[derive(Debug, Clone)]
@@ -70,6 +72,8 @@ pub enum Event {
7072 HoldCycle(Vec<String>),
7173 /// The answer to [`Command::Find`]: whether the buds were told to ring.
7274 Ringing(bool),
75+ /// The answer to [`Command::Bass`]: the level the buds took.
76+ Bass(i8),
7377 /// The answer to a command that failed. The session is closed; the next command opens a
7478 /// new one.
7579 Failed { kind: Kind, message: String },
@@ -84,6 +88,8 @@ pub struct Reading {
8488 pub eq: Option<EqPresets>,
8589 /// The touch controls, when the buds report them.
8690 pub touch: Option<TouchControls>,
91+ /// The levels the bass level takes, when the buds have one to set.
92+ pub bass_range: Option<(i8, i8)>,
8793 }
8894
8995 /// The worker, as a stream of events for an iced subscription. `device` picks the buds, as
@@ -207,6 +213,10 @@ impl Worker {
207213 .session()
208214 .and_then(|session| session.set_touch(&side, &gesture, &action, false))
209215 .map(|change| Event::Touch(change.gestures)),
216+ Command::Bass(level) => self
217+ .session()
218+ .and_then(|session| session.set_bass_level(level))
219+ .map(|()| Event::Bass(level)),
210220 Command::Find(ring) => self
211221 .session()
212222 .and_then(|session| session.find(ring))
@@ -235,7 +245,13 @@ impl Worker {
235245 let status = session.status()?;
236246 let eq = session.eq_presets().ok();
237247 let touch = session.touch_controls().ok();
238− Ok(Reading { status, eq, touch })
248+ let bass_range = session.bass_range();
249+ Ok(Reading {
250+ status,
251+ eq,
252+ touch,
253+ bass_range,
254+ })
239255 }
240256
241257 /// The open session, opening one first if there is none.