| 1 | //! The buds the window shows: made-up ones for `--demo` (roughly what the owner's T310 reported |
| 2 | //! at the last checkpoints), or an empty pair until the real buds answer. |
| 3 | |
| 4 | use budctl_core::bt::BdAddr; |
| 5 | use budctl_core::status::{ |
| 6 | Battery, Charge, DualDevice, EqBand, EqPreset, EqPresets, Gesture, GestureChoice, NoiseControl, Status, Switch, |
| 7 | TouchControls, |
| 8 | }; |
| 9 | |
| 10 | /// The buds the window shows. |
| 11 | pub struct Buds { |
| 12 | pub name: String, |
| 13 | pub connected: bool, |
| 14 | pub status: Status, |
| 15 | /// The EQ presets, when the buds list them. |
| 16 | pub eq: Option<EqPresets>, |
| 17 | /// The touch controls, when the buds report them. |
| 18 | pub touch: Option<TouchControls>, |
| 19 | /// The levels the bass level takes, when the buds have one to set. |
| 20 | 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>, |
| 23 | /// The devices dual connection keeps, when the buds list them. |
| 24 | pub dual: Option<Vec<DualDevice>>, |
| 25 | } |
| 26 | |
| 27 | impl Buds { |
| 28 | /// The name to show for the EQ preset in use. |
| 29 | pub fn eq_name(&self) -> String { |
| 30 | eq_name(self.status.eq.as_deref(), self.eq.as_ref()) |
| 31 | } |
| 32 | |
| 33 | /// Records that `now` is the EQ preset in use. |
| 34 | pub fn set_eq(&mut self, now: String) { |
| 35 | if let Some(eq) = &mut self.eq { |
| 36 | eq.current = Some(now.clone()); |
| 37 | } |
| 38 | self.status.eq = Some(now); |
| 39 | } |
| 40 | |
| 41 | /// Takes in a custom preset as the buds report it after a write: saved, and in use. A new |
| 42 | /// one joins the list. |
| 43 | pub fn set_custom(&mut self, preset: EqPreset) { |
| 44 | let now = key(&preset); |
| 45 | if let Some(eq) = self.eq.as_mut() { |
| 46 | match eq.presets.iter_mut().find(|p| p.custom && p.id == preset.id) { |
| 47 | Some(shown) => *shown = preset, |
| 48 | None => eq.presets.push(preset), |
| 49 | } |
| 50 | } |
| 51 | self.set_eq(now); |
| 52 | } |
| 53 | |
| 54 | /// Takes in the whole preset list, as the buds report it. |
| 55 | pub fn set_presets(&mut self, eq: EqPresets) { |
| 56 | self.status.eq.clone_from(&eq.current); |
| 57 | self.eq = Some(eq); |
| 58 | } |
| 59 | |
| 60 | /// The custom presets' names, as the buds store them. |
| 61 | pub fn custom_names(&self) -> impl Iterator<Item = (u8, &str)> { |
| 62 | self.eq |
| 63 | .iter() |
| 64 | .flat_map(|eq| eq.presets.iter()) |
| 65 | .filter(|p| p.custom) |
| 66 | .map(|p| (p.id, p.name.as_str())) |
| 67 | } |
| 68 | |
| 69 | /// The preset with this id. |
| 70 | pub fn preset(&self, id: u8) -> Option<&EqPreset> { |
| 71 | self.eq.as_ref()?.presets.iter().find(|p| p.id == id) |
| 72 | } |
| 73 | |
| 74 | /// What a gesture of a bud (`left`, `right`) does now. |
| 75 | pub fn action(&self, side: &str, gesture: &str) -> Option<&str> { |
| 76 | self.touch |
| 77 | .as_ref()? |
| 78 | .gestures |
| 79 | .iter() |
| 80 | .find(|g| g.side == side && g.gesture == gesture && g.group.is_none()) |
| 81 | .map(|g| g.action.as_str()) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /// The made-up buds of `--demo`. |
| 86 | pub fn demo() -> Buds { |
| 87 | let charge = |level| Some(Charge { level, charging: false }); |
| 88 | let switch = |key, label, on| Switch { key, label, on }; |
| 89 | let builtin = |id, name: &str| EqPreset { |
| 90 | name: name.to_owned(), |
| 91 | id, |
| 92 | custom: false, |
| 93 | bands: Vec::new(), |
| 94 | range: None, |
| 95 | }; |
| 96 | let band = |hz, gain| EqBand { hz, gain }; |
| 97 | let gesture = |side: &str, gesture: &str, action: &str| Gesture { |
| 98 | side: side.to_owned(), |
| 99 | gesture: gesture.to_owned(), |
| 100 | action: action.to_owned(), |
| 101 | group: None, |
| 102 | }; |
| 103 | let choice = |gesture: &str, actions: &[&str]| GestureChoice { |
| 104 | gesture: gesture.to_owned(), |
| 105 | actions: actions.iter().map(|&a| a.to_owned()).collect(), |
| 106 | }; |
| 107 | let names = |names: &[&str]| names.iter().map(|&n| n.to_owned()).collect::<Vec<_>>(); |
| 108 | Buds { |
| 109 | name: "realme Buds T310".to_owned(), |
| 110 | connected: true, |
| 111 | status: Status { |
| 112 | battery: Battery { |
| 113 | left: charge(80), |
| 114 | right: charge(80), |
| 115 | case: None, |
| 116 | }, |
| 117 | noise_control: Some(NoiseControl { |
| 118 | mode: "anc".to_owned(), |
| 119 | level: Some("deep".to_owned()), |
| 120 | }), |
| 121 | eq: Some("custom-4".to_owned()), |
| 122 | bass_level: None, |
| 123 | switches: vec![ |
| 124 | switch("game", "Game mode", false), |
| 125 | switch("dual", "Dual connection", true), |
| 126 | switch("wind", "Wind-noise reduction", true), |
| 127 | switch("spatial", "3D sound", true), |
| 128 | switch("bass-boost", "Bass boost", true), |
| 129 | ], |
| 130 | codec: Some("SBC".to_owned()), |
| 131 | firmware: Some("1.1.0.43".to_owned()), |
| 132 | warnings: Vec::new(), |
| 133 | }, |
| 134 | eq: Some(EqPresets { |
| 135 | current: Some("custom-4".to_owned()), |
| 136 | presets: vec![ |
| 137 | builtin(0, "pure-raw"), |
| 138 | builtin(1, "deep-bass"), |
| 139 | builtin(2, "pure-vocals"), |
| 140 | builtin(3, "pure-bass"), |
| 141 | EqPreset { |
| 142 | name: "Custom1".to_owned(), |
| 143 | id: 4, |
| 144 | custom: true, |
| 145 | bands: vec![ |
| 146 | band(50, 3), |
| 147 | band(250, 1), |
| 148 | band(1000, 0), |
| 149 | band(4000, -1), |
| 150 | band(8000, 1), |
| 151 | band(16000, 2), |
| 152 | ], |
| 153 | range: Some((-6, 6)), |
| 154 | }, |
| 155 | ], |
| 156 | warnings: Vec::new(), |
| 157 | }), |
| 158 | touch: Some(TouchControls { |
| 159 | gestures: vec![ |
| 160 | gesture("left", "double-tap", "play-pause"), |
| 161 | gesture("left", "triple-tap", "previous"), |
| 162 | gesture("left", "hold", "noise-cycle"), |
| 163 | gesture("right", "double-tap", "play-pause"), |
| 164 | gesture("right", "triple-tap", "next"), |
| 165 | gesture("right", "hold", "noise-cycle"), |
| 166 | ], |
| 167 | hold_cycle: Some(names(&["off", "anc"])), |
| 168 | choices: vec![ |
| 169 | choice("double-tap", &["none", "play-pause", "next", "voice-assistant"]), |
| 170 | choice( |
| 171 | "triple-tap", |
| 172 | &[ |
| 173 | "none", |
| 174 | "previous", |
| 175 | "next", |
| 176 | "voice-assistant", |
| 177 | "volume-up", |
| 178 | "volume-down", |
| 179 | "game-mode", |
| 180 | ], |
| 181 | ), |
| 182 | choice("hold", &["none", "noise-cycle", "voice-assistant", "game-mode"]), |
| 183 | ], |
| 184 | cycle_modes: names(&["off", "transparency", "anc"]), |
| 185 | warnings: Vec::new(), |
| 186 | }), |
| 187 | bass_range: Some((-5, 5)), |
| 188 | bass: None, |
| 189 | dual: Some(vec![ |
| 190 | DualDevice { |
| 191 | address: BdAddr::new([0x2B, 0x1A, 0x00, 0xAA, 0xBB, 0xCC]), |
| 192 | name: "arch".to_owned(), |
| 193 | connected: true, |
| 194 | this_computer: true, |
| 195 | main_audio: false, |
| 196 | playing: false, |
| 197 | }, |
| 198 | DualDevice { |
| 199 | address: BdAddr::new([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]), |
| 200 | name: "Pixel 8".to_owned(), |
| 201 | connected: true, |
| 202 | this_computer: false, |
| 203 | main_audio: true, |
| 204 | playing: true, |
| 205 | }, |
| 206 | ]), |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | /// What the window shows before the real buds answer. |
| 211 | pub fn unknown() -> Buds { |
| 212 | Buds { |
| 213 | name: "Your buds".to_owned(), |
| 214 | connected: false, |
| 215 | status: Status::default(), |
| 216 | eq: None, |
| 217 | touch: None, |
| 218 | bass_range: None, |
| 219 | bass: None, |
| 220 | dual: None, |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// The name a preset is selected by, as in [`Status::eq`]: a built-in one's own name, |
| 225 | /// `custom-<id>` for a custom one. |
| 226 | pub fn key(preset: &EqPreset) -> String { |
| 227 | if preset.custom { |
| 228 | format!("custom-{}", preset.id) |
| 229 | } else { |
| 230 | preset.name.clone() |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /// A preset's name to show: a custom one's stored name, a built-in one's in words. |
| 235 | pub fn shown_name(preset: &EqPreset) -> String { |
| 236 | if preset.custom { |
| 237 | preset.name.clone() |
| 238 | } else { |
| 239 | words(&preset.name) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | /// The name to show for the EQ preset in use: a custom preset's own name (`custom-4` → |
| 244 | /// "Custom1"), else the preset's name in words (`pure-bass` → "Pure bass"). |
| 245 | pub fn eq_name(current: Option<&str>, presets: Option<&EqPresets>) -> String { |
| 246 | let Some(current) = current else { |
| 247 | return "Unknown".to_owned(); |
| 248 | }; |
| 249 | presets |
| 250 | .and_then(|eq| eq.presets.iter().find(|p| key(p) == current)) |
| 251 | .map_or_else(|| words(current), shown_name) |
| 252 | } |
| 253 | |
| 254 | /// `pure-bass` → "Pure bass". |
| 255 | pub fn words(name: &str) -> String { |
| 256 | let words = name.replace('-', " "); |
| 257 | let mut letters = words.chars(); |
| 258 | letters |
| 259 | .next() |
| 260 | .map(|first| first.to_uppercase().chain(letters).collect()) |
| 261 | .unwrap_or_default() |
| 262 | } |
| 263 | |
| 264 | #[cfg(test)] |
| 265 | mod tests { |
| 266 | use super::*; |
| 267 | |
| 268 | #[test] |
| 269 | fn eq_names() { |
| 270 | let buds = demo(); |
| 271 | assert_eq!(eq_name(Some("custom-4"), buds.eq.as_ref()), "Custom1"); |
| 272 | assert_eq!(eq_name(Some("custom-4"), None), "Custom 4"); |
| 273 | assert_eq!(eq_name(Some("pure-bass"), buds.eq.as_ref()), "Pure bass"); |
| 274 | assert_eq!(eq_name(None, None), "Unknown"); |
| 275 | assert_eq!(buds.eq_name(), "Custom1"); |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn eq_changes_show() { |
| 280 | let mut buds = demo(); |
| 281 | buds.set_eq("deep-bass".to_owned()); |
| 282 | assert_eq!(buds.eq_name(), "Deep bass"); |
| 283 | assert_eq!(buds.eq.as_ref().unwrap().current.as_deref(), Some("deep-bass")); |
| 284 | |
| 285 | let mut custom = buds.preset(4).unwrap().clone(); |
| 286 | custom.bands[0].gain = -6; |
| 287 | buds.set_custom(custom); |
| 288 | assert_eq!(buds.status.eq.as_deref(), Some("custom-4")); |
| 289 | assert_eq!(buds.preset(4).unwrap().bands[0].gain, -6); |
| 290 | } |
| 291 | } |