budctlf5d5748
budctl eq new <name>: create a custom EQ preset (W11c)
0x0418 01 with id 00: the new preset copies the range and the bands (frequencies and order) of the first listed preset, every gain at 0, since the vendor's default bands do not hold on the T310. budctl reads the list first and refuses, before sending anything, a fourth preset, a name already taken, or buds with no preset to copy. Names are 1 to 16 bytes (budctl's own limit), without control characters or spaces at either end; the CLI checks what it can before connecting. The reply 00 <id> gives the new id; ids can be renumbered, so the list is read again and the preset found by id and name, else by name alone. Whether a create also selects the preset is unknown: the read-back shows it. Not tried on hardware yet. Spec: realme_t310.md#custom-eq-create, with the expected frame for `eq new Test` on the owner's list. No new dependencies.
+336 −4
7 files changed
- M crates/budctl-cli/src/main.rs +69 −1
- M crates/budctl-cli/src/output.rs +31 −0
- M crates/budctl-cli/tests/cli.rs +13 −0
- M crates/budctl-core/src/devices/mod.rs +8 −0
- M crates/budctl-core/src/devices/realme_t310/codec.rs +119 −1
- M crates/budctl-core/src/devices/realme_t310/mod.rs +88 −0
- M docs/protocol/realme_t310.md +8 −2
| @@ -68,8 +68,11 @@ enum Command { | |||
| 68 | 68 | /// List the EQ presets, or select one (pure-raw, deep-bass, pure-vocals, pure-bass, custom-<id>) and read it back. | |
| 69 | 69 | /// | |
| 70 | 70 | /// For a custom preset, `<hz>=<gain>` pairs change those bands' gains (dB), then save and | |
| 71 | − | /// select it: `budctl eq custom-4 50=+3 16000=-2`. | |
| 71 | + | /// select it: `budctl eq custom-4 50=+3 16000=-2`. `budctl eq new <name>` creates a preset. | |
| 72 | + | #[command(args_conflicts_with_subcommands = true)] | |
| 72 | 73 | Eq { | |
| 74 | + | #[command(subcommand)] | |
| 75 | + | action: Option<EqCommand>, | |
| 73 | 76 | preset: Option<String>, | |
| 74 | 77 | /// Bands to change, as `<hz>=<gain>`. Custom presets only. | |
| 75 | 78 | #[arg(requires = "preset", value_parser = parse_band, allow_negative_numbers = true)] | |
| @@ -109,6 +112,16 @@ enum FindAction { | |||
| 109 | 112 | Stop, | |
| 110 | 113 | } | |
| 111 | 114 | ||
| 115 | + | #[derive(Subcommand)] | |
| 116 | + | enum EqCommand { | |
| 117 | + | /// Create a custom preset, flat, with the bands of the buds' custom presets, then read it back. | |
| 118 | + | New { | |
| 119 | + | /// Its name, 1 to 16 bytes. | |
| 120 | + | #[arg(value_parser = parse_name)] | |
| 121 | + | name: String, | |
| 122 | + | }, | |
| 123 | + | } | |
| 124 | + | ||
| 112 | 125 | #[derive(Subcommand)] | |
| 113 | 126 | enum TouchCommand { | |
| 114 | 127 | /// Set the noise-control modes a hold steps through (both buds), then read it back. | |
| @@ -268,6 +281,13 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 268 | 281 | let now = session.set_noise_control(mode.into())?; | |
| 269 | 282 | output::noise_control(out, &device, model, &now, cli.json) | |
| 270 | 283 | } | |
| 284 | + | Command::Eq { | |
| 285 | + | action: Some(EqCommand::New { ref name }), | |
| 286 | + | .. | |
| 287 | + | } => { | |
| 288 | + | let created = session.create_custom_eq(name)?; | |
| 289 | + | output::created_eq(out, &device, model, &created, cli.json) | |
| 290 | + | } | |
| 271 | 291 | Command::Eq { preset: None, .. } => { | |
| 272 | 292 | let presets = session.eq_presets()?; | |
| 273 | 293 | output::eq_presets(out, &device, model, &presets, cli.json) | |
| @@ -275,6 +295,7 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 275 | 295 | Command::Eq { | |
| 276 | 296 | preset: Some(ref preset), | |
| 277 | 297 | ref bands, | |
| 298 | + | .. | |
| 278 | 299 | } if !bands.is_empty() => { | |
| 279 | 300 | let now = session.set_custom_eq(preset, bands)?; | |
| 280 | 301 | output::custom_eq(out, &device, model, &now, cli.json) | |
| @@ -306,6 +327,18 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 306 | 327 | } | |
| 307 | 328 | } | |
| 308 | 329 | ||
| 330 | + | /// A preset name, checked before connecting: not empty, no spaces around it, no control | |
| 331 | + | /// characters. The buds' own limits are checked once their list is read. | |
| 332 | + | fn parse_name(text: &str) -> Result<String, String> { | |
| 333 | + | if text.is_empty() || text.trim() != text { | |
| 334 | + | return Err("a preset name cannot be empty, or start or end with a space".to_owned()); | |
| 335 | + | } | |
| 336 | + | if text.chars().any(char::is_control) { | |
| 337 | + | return Err("a preset name cannot hold control characters".to_owned()); | |
| 338 | + | } | |
| 339 | + | Ok(text.to_owned()) | |
| 340 | + | } | |
| 341 | + | ||
| 309 | 342 | /// `50=+3` → 50 Hz at +3 dB. | |
| 310 | 343 | fn parse_band(text: &str) -> Result<EqBand, String> { | |
| 311 | 344 | let (hz, gain) = text | |
| @@ -326,3 +359,38 @@ fn exit_code(error: &Error) -> u8 { | |||
| 326 | 359 | Kind::Failed => 4, | |
| 327 | 360 | } | |
| 328 | 361 | } | |
| 362 | + | ||
| 363 | + | #[cfg(test)] | |
| 364 | + | mod tests { | |
| 365 | + | use super::*; | |
| 366 | + | ||
| 367 | + | fn parse(args: &[&str]) -> Command { | |
| 368 | + | Cli::try_parse_from(std::iter::once("budctl").chain(args.iter().copied())) | |
| 369 | + | .unwrap() | |
| 370 | + | .command | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | #[test] | |
| 374 | + | fn eq_takes_a_preset_bands_or_new() { | |
| 375 | + | assert!(matches!( | |
| 376 | + | parse(&["eq"]), | |
| 377 | + | Command::Eq { | |
| 378 | + | action: None, | |
| 379 | + | preset: None, | |
| 380 | + | .. | |
| 381 | + | } | |
| 382 | + | )); | |
| 383 | + | assert!(matches!( | |
| 384 | + | parse(&["eq", "custom-4", "50=+3", "16000=-2"]), | |
| 385 | + | Command::Eq { action: None, preset: Some(p), bands } if p == "custom-4" && bands.len() == 2 | |
| 386 | + | )); | |
| 387 | + | assert!(matches!( | |
| 388 | + | parse(&["eq", "deep-bass"]), | |
| 389 | + | Command::Eq { action: None, preset: Some(p), .. } if p == "deep-bass" | |
| 390 | + | )); | |
| 391 | + | assert!(matches!( | |
| 392 | + | parse(&["eq", "new", "Gym mix"]), | |
| 393 | + | Command::Eq { action: Some(EqCommand::New { name }), .. } if name == "Gym mix" | |
| 394 | + | )); | |
| 395 | + | } | |
| 396 | + | } | |
| @@ -342,6 +342,37 @@ pub fn custom_eq( | |||
| 342 | 342 | Ok(()) | |
| 343 | 343 | } | |
| 344 | 344 | ||
| 345 | + | /// A custom preset just created, as the buds list it. | |
| 346 | + | pub fn created_eq( | |
| 347 | + | out: &mut impl Write, | |
| 348 | + | device: &PairedDevice, | |
| 349 | + | model: &dyn Device, | |
| 350 | + | created: &EqPreset, | |
| 351 | + | as_json: bool, | |
| 352 | + | ) -> Result<(), Failure> { | |
| 353 | + | if as_json { | |
| 354 | + | return json( | |
| 355 | + | out, | |
| 356 | + | Some((device, model)), | |
| 357 | + | serde_json::json!({ "created": format!("custom-{}", created.id), "preset": created }), | |
| 358 | + | ); | |
| 359 | + | } | |
| 360 | + | header(out, device, model)?; | |
| 361 | + | let bands: Vec<String> = created | |
| 362 | + | .bands | |
| 363 | + | .iter() | |
| 364 | + | .map(|b| format!("{} Hz {:+}", b.hz, b.gain)) | |
| 365 | + | .collect(); | |
| 366 | + | writeln!( | |
| 367 | + | out, | |
| 368 | + | " New preset custom-{} \"{}\": {}", | |
| 369 | + | created.id, | |
| 370 | + | created.name, | |
| 371 | + | bands.join(", ") | |
| 372 | + | )?; | |
| 373 | + | Ok(()) | |
| 374 | + | } | |
| 375 | + | ||
| 345 | 376 | /// The EQ preset after a change, as the buds report it on reading back. | |
| 346 | 377 | pub fn eq( | |
| 347 | 378 | out: &mut impl Write, | |
| @@ -26,6 +26,19 @@ fn usage_errors_exit_1() { | |||
| 26 | 26 | } | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | + | #[test] | |
| 30 | + | fn eq_new_checks_the_name_before_connecting() { | |
| 31 | + | for args in [ | |
| 32 | + | &["eq", "new"][..], | |
| 33 | + | &["eq", "new", ""], | |
| 34 | + | &["eq", "new", " Gym"], | |
| 35 | + | &["eq", "new", "a\tb"], | |
| 36 | + | ] { | |
| 37 | + | let out = budctl(args); | |
| 38 | + | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 29 | 42 | #[test] | |
| 30 | 43 | fn find_start_needs_yes_before_connecting() { | |
| 31 | 44 | let out = budctl(&["find", "start"]); | |
| @@ -84,6 +84,14 @@ pub trait Session: Send { | |||
| 84 | 84 | None | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | + | /// Create a custom EQ preset called `name`, flat, with the bands of the buds' other custom | |
| 88 | + | /// presets. Returns it as the buds list it afterwards. | |
| 89 | + | fn create_custom_eq(&mut self, name: &str) -> Result<EqPreset> { | |
| 90 | + | Err(Error::Unsupported(format!( | |
| 91 | + | "budctl cannot create the preset {name} on these buds" | |
| 92 | + | ))) | |
| 93 | + | } | |
| 94 | + | ||
| 87 | 95 | /// Set the bass level. Only the ack can be checked when the buds cannot report the level. | |
| 88 | 96 | fn set_bass_level(&mut self, _level: i8) -> Result<()> { | |
| 89 | 97 | Err(Error::Unsupported( | |
| @@ -62,6 +62,13 @@ pub(super) const CUSTOM_EQ_LIST: u16 = 0x0122; | |||
| 62 | 62 | pub(super) const CUSTOM_EQ_WRITE: u16 = 0x0418; | |
| 63 | 63 | /// The `0x0418` action that saves a whole preset and selects it. Spec: realme_t310.md#custom-eq | |
| 64 | 64 | const CUSTOM_EQ_SAVE: u8 = 0x02; | |
| 65 | + | /// The `0x0418` action that creates a preset; the buds give it an id. Spec: realme_t310.md#custom-eq | |
| 66 | + | const CUSTOM_EQ_CREATE: u8 = 0x01; | |
| 67 | + | /// The most custom presets the buds keep. Spec: realme_t310.md#custom-eq | |
| 68 | + | pub(super) const CUSTOM_EQ_MAX: usize = 3; | |
| 69 | + | /// The longest preset name budctl writes, in bytes. budctl's own limit: the name's length is one | |
| 70 | + | /// byte, and the vendor's limit is unknown. Spec: realme_t310.md#custom-eq-create | |
| 71 | + | const CUSTOM_EQ_NAME_MAX: usize = 16; | |
| 65 | 72 | /// Spec: realme_t310.md#events-offered | |
| 66 | 73 | pub(super) const EVENTS_OFFERED: u16 = 0x0200; | |
| 67 | 74 | /// Spec: realme_t310.md#subscribe | |
| @@ -586,8 +593,69 @@ pub(super) fn retune(preset: &CustomEq, changes: &[EqBand]) -> Result<CustomEq> | |||
| 586 | 593 | /// `02 <min> <max> <id> <nameLen> <name> <bands> (<freq u16 LE> <gain s8>)…`: save the whole | |
| 587 | 594 | /// preset and select it. Spec: realme_t310.md#custom-eq | |
| 588 | 595 | pub(super) fn custom_eq_save_payload(preset: &CustomEq) -> Result<Vec<u8>> { | |
| 596 | + | custom_eq_payload(CUSTOM_EQ_SAVE, preset) | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | /// `01 <min> <max> 00 <nameLen> <name> <bands> …`: create a preset, as [`new_custom_eq`] makes | |
| 600 | + | /// it. Spec: realme_t310.md#custom-eq-create | |
| 601 | + | pub(super) fn custom_eq_create_payload(preset: &CustomEq) -> Result<Vec<u8>> { | |
| 602 | + | custom_eq_payload(CUSTOM_EQ_CREATE, preset) | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | /// A preset name as budctl writes it: 1 to [`CUSTOM_EQ_NAME_MAX`] bytes of UTF-8, no control | |
| 606 | + | /// characters, no spaces around it. Spec: realme_t310.md#custom-eq-create | |
| 607 | + | pub(super) fn custom_eq_name(name: &str) -> Result<Vec<u8>> { | |
| 608 | + | if name.is_empty() || name.trim() != name { | |
| 609 | + | return Err(Error::Usage( | |
| 610 | + | "a preset name cannot be empty, or start or end with a space".to_owned(), | |
| 611 | + | )); | |
| 612 | + | } | |
| 613 | + | if name.chars().any(char::is_control) { | |
| 614 | + | return Err(Error::Usage("a preset name cannot hold control characters".to_owned())); | |
| 615 | + | } | |
| 616 | + | if name.len() > CUSTOM_EQ_NAME_MAX { | |
| 617 | + | return Err(Error::Usage(format!( | |
| 618 | + | "a preset name can be at most {CUSTOM_EQ_NAME_MAX} bytes; \"{name}\" has {}", | |
| 619 | + | name.len() | |
| 620 | + | ))); | |
| 621 | + | } | |
| 622 | + | Ok(name.as_bytes().to_vec()) | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | /// A new preset called `name`: the range and the bands of `model`, a preset the buds have, with | |
| 626 | + | /// every gain at 0, and id `00` for the buds to replace. Spec: realme_t310.md#custom-eq-create | |
| 627 | + | pub(super) fn new_custom_eq(name: &str, model: &CustomEq) -> Result<CustomEq> { | |
| 628 | + | Ok(CustomEq { | |
| 629 | + | selected: false, | |
| 630 | + | min: model.min, | |
| 631 | + | max: model.max, | |
| 632 | + | id: 0x00, | |
| 633 | + | name: custom_eq_name(name)?, | |
| 634 | + | bands: model.bands.iter().map(|b| EqBand { hz: b.hz, gain: 0 }).collect(), | |
| 635 | + | }) | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | /// The id in a `0x0418` reply: `00 <id>`. Spec: realme_t310.md#custom-eq | |
| 639 | + | pub(super) fn decode_custom_eq_write_reply(reply: &[u8]) -> Result<u8> { | |
| 640 | + | let mut r = ok_reply(reply, "the custom EQ write")?; | |
| 641 | + | r.u8() | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | /// The preset a create made, in the list read after it: the one with that name and the id the | |
| 645 | + | /// buds answered, else the only one with that name. Spec: realme_t310.md#custom-eq-create | |
| 646 | + | pub(super) fn find_created<'a>(list: &'a [CustomEq], name: &[u8], id: u8) -> Option<&'a CustomEq> { | |
| 647 | + | let mut named = list.iter().filter(|p| p.name == name); | |
| 648 | + | list.iter() | |
| 649 | + | .find(|p| p.name == name && p.id == id) | |
| 650 | + | .or_else(|| match (named.next(), named.next()) { | |
| 651 | + | (Some(only), None) => Some(only), | |
| 652 | + | _ => None, | |
| 653 | + | }) | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | fn custom_eq_payload(action: u8, preset: &CustomEq) -> Result<Vec<u8>> { | |
| 589 | 657 | let too_long = |what: &str| Error::Protocol(format!("the preset's {what} does not fit in a byte")); | |
| 590 | − | let mut payload = vec![CUSTOM_EQ_SAVE]; | |
| 658 | + | let mut payload = vec![action]; | |
| 591 | 659 | payload.extend([preset.min.to_le_bytes()[0], preset.max.to_le_bytes()[0], preset.id]); | |
| 592 | 660 | payload.push(u8::try_from(preset.name.len()).map_err(|_| too_long("name"))?); | |
| 593 | 661 | payload.extend(&preset.name); | |
| @@ -1130,4 +1198,54 @@ mod tests { | |||
| 1130 | 1198 | assert!(usage(&[EqBand { hz: 50, gain: -7 }])); | |
| 1131 | 1199 | assert!(usage(&[EqBand { hz: 50, gain: 1 }, EqBand { hz: 50, gain: 2 }])); | |
| 1132 | 1200 | } | |
| 1201 | + | ||
| 1202 | + | #[test] | |
| 1203 | + | fn creates_a_flat_copy_of_the_owners_preset() { | |
| 1204 | + | let list = decode_custom_eq_list(&hex(OWNER_CUSTOM_EQS)).unwrap(); | |
| 1205 | + | let new = new_custom_eq("Test", &list[0]).unwrap(); | |
| 1206 | + | assert_eq!((new.id, new.min, new.max), (0x00, -6, 6)); | |
| 1207 | + | assert!(new.bands.iter().all(|b| b.gain == 0)); | |
| 1208 | + | let hz: Vec<u16> = new.bands.iter().map(|b| b.hz).collect(); | |
| 1209 | + | assert_eq!(hz, [50, 250, 1000, 4000, 8000, 16000]); | |
| 1210 | + | // Spec: realme_t310.md#custom-eq-create (the expected frame, with seq 06) | |
| 1211 | + | let frame = | |
| 1212 | + | crate::devices::realme_t310::frame::encode(CUSTOM_EQ_WRITE, 0x06, &custom_eq_create_payload(&new).unwrap()) | |
| 1213 | + | .unwrap(); | |
| 1214 | + | assert_eq!( | |
| 1215 | + | frame, | |
| 1216 | + | hex("AA 23 00 00 18 04 06 1C 00 01 FA 06 00 04 54 65 73 74 06 \ | |
| 1217 | + | 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00") | |
| 1218 | + | ); | |
| 1219 | + | } | |
| 1220 | + | ||
| 1221 | + | #[test] | |
| 1222 | + | fn preset_names() { | |
| 1223 | + | assert_eq!(custom_eq_name("Gym").unwrap(), b"Gym"); | |
| 1224 | + | assert_eq!(custom_eq_name("Bass é").unwrap().len(), 7); | |
| 1225 | + | for bad in ["", " Gym", "Gym ", "a\tb", "seventeen bytes!!"] { | |
| 1226 | + | assert!(matches!(custom_eq_name(bad), Err(Error::Usage(_))), "{bad:?}"); | |
| 1227 | + | } | |
| 1228 | + | } | |
| 1229 | + | ||
| 1230 | + | #[test] | |
| 1231 | + | fn finds_the_created_preset() { | |
| 1232 | + | let preset = |id, name: &str| CustomEq { | |
| 1233 | + | selected: false, | |
| 1234 | + | min: -6, | |
| 1235 | + | max: 6, | |
| 1236 | + | id, | |
| 1237 | + | name: name.as_bytes().to_vec(), | |
| 1238 | + | bands: Vec::new(), | |
| 1239 | + | }; | |
| 1240 | + | let list = [preset(4, "Custom1"), preset(5, "Test")]; | |
| 1241 | + | assert_eq!(find_created(&list, b"Test", 5).map(|p| p.id), Some(5)); | |
| 1242 | + | // Renumbered after the reply: the name still finds it. | |
| 1243 | + | assert_eq!(find_created(&list, b"Test", 9).map(|p| p.id), Some(5)); | |
| 1244 | + | assert!(find_created(&list, b"Other", 5).is_none()); | |
| 1245 | + | let twins = [preset(4, "Test"), preset(5, "Test")]; | |
| 1246 | + | assert_eq!(find_created(&twins, b"Test", 5).map(|p| p.id), Some(5)); | |
| 1247 | + | assert!(find_created(&twins, b"Test", 9).is_none()); | |
| 1248 | + | assert_eq!(decode_custom_eq_write_reply(&hex("00 05")).unwrap(), 5); | |
| 1249 | + | assert!(decode_custom_eq_write_reply(&hex("01 05")).is_err()); | |
| 1250 | + | } | |
| 1133 | 1251 | } | |
| @@ -379,6 +379,51 @@ impl Session for T310Session { | |||
| 379 | 379 | } | |
| 380 | 380 | ||
| 381 | 381 | /// Ack-only: the T310 never answers the bass-level read. Spec: realme_t310.md#bass-write | |
| 382 | + | /// Spec: realme_t310.md#custom-eq-create, #write-read-back | |
| 383 | + | fn create_custom_eq(&mut self, name: &str) -> Result<EqPreset> { | |
| 384 | + | if !self.commands.allows(codec::CUSTOM_EQ_WRITE) || !self.commands.allows(codec::CUSTOM_EQ_LIST) { | |
| 385 | + | return Err(Error::Unsupported( | |
| 386 | + | "these buds do not offer custom EQ presets".to_owned(), | |
| 387 | + | )); | |
| 388 | + | } | |
| 389 | + | let list = codec::decode_custom_eq_list(&self.link.request(codec::CUSTOM_EQ_LIST, &[])?)?; | |
| 390 | + | if list.len() >= codec::CUSTOM_EQ_MAX { | |
| 391 | + | return Err(Error::Usage(format!( | |
| 392 | + | "these buds keep at most {} custom presets, and have {}; change one of them instead", | |
| 393 | + | codec::CUSTOM_EQ_MAX, | |
| 394 | + | list.len() | |
| 395 | + | ))); | |
| 396 | + | } | |
| 397 | + | if list.iter().any(|p| p.name == name.as_bytes()) { | |
| 398 | + | return Err(Error::Usage(format!("a custom preset is called {name} already"))); | |
| 399 | + | } | |
| 400 | + | // The new preset copies the bands the buds use: the vendor's defaults do not hold here. | |
| 401 | + | let Some(model) = list.first() else { | |
| 402 | + | return Err(Error::Usage( | |
| 403 | + | "budctl copies a new preset's bands from one the buds have, and these buds have none".to_owned(), | |
| 404 | + | )); | |
| 405 | + | }; | |
| 406 | + | let new = codec::new_custom_eq(name, model)?; | |
| 407 | + | let reply = self | |
| 408 | + | .link | |
| 409 | + | .request(codec::CUSTOM_EQ_WRITE, &codec::custom_eq_create_payload(&new)?)?; | |
| 410 | + | let id = codec::decode_custom_eq_write_reply(&reply)?; | |
| 411 | + | let created = self.read_back( | |
| 412 | + | READ_BACK, | |
| 413 | + | codec::CUSTOM_EQ_LIST, | |
| 414 | + | &[], | |
| 415 | + | |reply| { | |
| 416 | + | let now = codec::decode_custom_eq_list(reply)?; | |
| 417 | + | let created = codec::find_created(&now, &new.name, id).cloned(); | |
| 418 | + | Ok((created.is_some(), created)) | |
| 419 | + | }, | |
| 420 | + | |_| format!("the buds accepted the new preset {name}, but their list does not have it"), | |
| 421 | + | )?; | |
| 422 | + | created | |
| 423 | + | .map(|p| p.preset()) | |
| 424 | + | .ok_or_else(|| Error::Protocol(format!("the new preset {name} vanished from the list"))) | |
| 425 | + | } | |
| 426 | + | ||
| 382 | 427 | /// Spec: realme_t310.md#bass-write | |
| 383 | 428 | fn bass_range(&self) -> Option<(i8, i8)> { | |
| 384 | 429 | self.commands | |
| @@ -1203,6 +1248,49 @@ mod tests { | |||
| 1203 | 1248 | assert!(matches!(result, Err(Error::Usage(_)))); | |
| 1204 | 1249 | } | |
| 1205 | 1250 | ||
| 1251 | + | #[test] | |
| 1252 | + | fn creates_a_custom_preset_and_finds_it_in_the_list() { | |
| 1253 | + | const LIST: &str = | |
| 1254 | + | "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"; | |
| 1255 | + | const AFTER: &str = "00 02 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 \ | |
| 1256 | + | 00 FA 06 05 04 54 65 73 74 06 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00"; | |
| 1257 | + | let mut steps = handshake(); | |
| 1258 | + | steps.extend([ | |
| 1259 | + | tx(0x0122, 0x05, ""), | |
| 1260 | + | rx(0x8122, 0x05, LIST), | |
| 1261 | + | tx( | |
| 1262 | + | 0x0418, | |
| 1263 | + | 0x06, | |
| 1264 | + | "01 FA 06 00 04 54 65 73 74 06 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00", | |
| 1265 | + | ), | |
| 1266 | + | rx(0x8418, 0x06, "00 05"), | |
| 1267 | + | tx(0x0122, 0x07, ""), | |
| 1268 | + | rx(0x8122, 0x07, AFTER), | |
| 1269 | + | ]); | |
| 1270 | + | let script = Script::new(steps); | |
| 1271 | + | let created = open(&script).unwrap().create_custom_eq("Test").unwrap(); | |
| 1272 | + | script.assert_done(); | |
| 1273 | + | assert_eq!((created.id, created.name.as_str()), (5, "Test")); | |
| 1274 | + | assert_eq!(created.bands.len(), 6); | |
| 1275 | + | } | |
| 1276 | + | ||
| 1277 | + | #[test] | |
| 1278 | + | fn refuses_a_fourth_preset_or_a_taken_name_before_writing() { | |
| 1279 | + | let one = "00 FA 06 04 07 43 75 73 74 6F 6D 31 00"; | |
| 1280 | + | for (list, name) in [ | |
| 1281 | + | (format!("00 03 {one} {one} {one}"), "Test"), | |
| 1282 | + | (format!("00 01 {one}"), "Custom1"), | |
| 1283 | + | ("00 00".to_owned(), "Test"), | |
| 1284 | + | ] { | |
| 1285 | + | let mut steps = handshake(); | |
| 1286 | + | steps.extend([tx(0x0122, 0x05, ""), rx(0x8122, 0x05, &list)]); | |
| 1287 | + | let script = Script::new(steps); | |
| 1288 | + | let result = open(&script).unwrap().create_custom_eq(name); | |
| 1289 | + | script.assert_done(); | |
| 1290 | + | assert!(matches!(result, Err(Error::Usage(_))), "{list}"); | |
| 1291 | + | } | |
| 1292 | + | } | |
| 1293 | + | ||
| 1206 | 1294 | #[test] | |
| 1207 | 1295 | fn retunes_a_custom_preset_and_reads_it_back() { | |
| 1208 | 1296 | let tuned = | |
| @@ -503,7 +503,13 @@ Evidence: *vendor* | |||
| 503 | 503 | - **Expected frame** (*derived*), for `eq custom-4 50=+3 16000=-2` on the owner's list, with seq `06`: `AA 26 00 00 18 04 06 1F 00 02 FA 06 04 07 43 75 73 74 6F 6D 31 06 32 00 03 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E FE`. | |
| 504 | 504 | - **On the owner's T310** (*ours-2026-10-27*, W11b): that frame went out byte for byte. The buds answered `0x8418 00 04` within 53 ms, and the list read about 150 ms after the ack already had +3 dB at 50 Hz, -2 dB at 16000 Hz, the name unchanged and `selected` `01`. Writing the flat bands back worked the same way. | |
| 505 | 505 | - **`02` selects the preset** (*ours-2026-10-27*): after `eq pure-raw`, a bare `eq custom-4` (the preset saved unchanged) made `0x010F` read `custom-4` again. | |
| 506 | − | - Create (`01`) and delete (`03`) come later, if they are wanted at all: with one preset already on the buds, editing it gives a custom EQ without them. | |
| 506 | + | - <a id="custom-eq-create"></a>**W11c, create** (`01`), wanted by the owner (2026-10-28): `budctl eq new <name>` reads the list first. It refuses before sending anything when the buds keep 3 presets already, when a preset has that name already, or when there is no preset to copy the bands from. | |
| 507 | + | - **The name:** 1 to 16 bytes of UTF-8, with no control characters and no spaces at either end. The 16 bytes are budctl's own limit: the protocol allows 255 (one length byte), and the vendor's limit is unknown. | |
| 508 | + | - **The new preset** copies the range bytes and the bands (frequencies and order) of the first listed preset, with every gain at 0 and id `00`. The vendor default bands do not hold on the T310 (see above). | |
| 509 | + | - **Check:** the reply `00 <id>` gives the new id. Ids can be renumbered after a create, so budctl reads the list again, as for [read-back](#write-read-back). It finds the preset by that id and its name, else by its name alone. | |
| 510 | + | - **Unknown:** whether a create also selects the new preset. The read-back list shows it. | |
| 511 | + | - **Expected frame** (*derived*), for `eq new Test` on the owner's list, with seq `06`: `AA 23 00 00 18 04 06 1C 00 01 FA 06 00 04 54 65 73 74 06 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00`. | |
| 512 | + | - Renaming (a save with a new name) and deleting (`03`) come next, one checkpoint each. | |
| 507 | 513 | ||
| 508 | 514 | <a id="absent"></a> | |
| 509 | 515 | ## 11. Not on the T310 | |
| @@ -550,7 +556,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go | |||
| 550 | 556 | | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) | | |
| 551 | 557 | | W9 | `touch cycle` | | |
| 552 | 558 | | W10 | ~~`fit-test`~~: dropped, the T310 runs no test ([§10.8](#fit-test)) | | |
| 553 | − | | W11 | W11a: `eq` lists the custom presets (read-only); W11b: save an edited preset (`eq custom-<id> <hz>=<gain>…`); create and delete only if wanted | | |
| 559 | + | | W11 | W11a: `eq` lists the custom presets (read-only); W11b: save an edited preset (`eq custom-<id> <hz>=<gain>…`); W11c: create one (`eq new <name>`); W11d: rename one; delete only if wanted | | |
| 554 | 560 | ||
| 555 | 561 | The owner will not install realme Link (2026-10-27), so there is no app capture. W6 and W11 use only the vendor-documented bytes in this spec, one checkpoint at a time. | |
| 556 | 562 | ||