budctl39f837a
budctl eq rename custom-<id> <name>: rename a custom EQ preset (W11d)
A save (0x0418 02) of the preset exactly as listed, with only the name changed; like any save, it also selects the preset. The name follows the create rules, and another preset's name is refused before anything is sent. The list is read again: the preset with that id must have the new name. Not tried on hardware yet. Spec: realme_t310.md#custom-eq-rename, with the expected frame for `eq rename custom-5 Gym` on the owner's list after W11c. No new dependencies.
+169 −3
7 files changed
- M crates/budctl-cli/src/main.rs +23 −1
- M crates/budctl-cli/src/output.rs +20 −0
- M crates/budctl-cli/tests/cli.rs +3 −1
- M crates/budctl-core/src/devices/mod.rs +8 −0
- M crates/budctl-core/src/devices/realme_t310/codec.rs +34 −0
- M crates/budctl-core/src/devices/realme_t310/mod.rs +76 −0
- M docs/protocol/realme_t310.md +5 −1
| @@ -68,7 +68,8 @@ 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`. `budctl eq new <name>` creates a preset. | |
| 71 | + | /// select it: `budctl eq custom-4 50=+3 16000=-2`. `budctl eq new <name>` creates a preset, | |
| 72 | + | /// `budctl eq rename custom-<id> <name>` renames one. | |
| 72 | 73 | #[command(args_conflicts_with_subcommands = true)] | |
| 73 | 74 | Eq { | |
| 74 | 75 | #[command(subcommand)] | |
| @@ -120,6 +121,15 @@ enum EqCommand { | |||
| 120 | 121 | #[arg(value_parser = parse_name)] | |
| 121 | 122 | name: String, | |
| 122 | 123 | }, | |
| 124 | + | /// Give a custom preset a new name, then read it back. The buds save it, which also selects | |
| 125 | + | /// it. | |
| 126 | + | Rename { | |
| 127 | + | /// The preset, as `budctl eq` lists it: custom-<id>. | |
| 128 | + | preset: String, | |
| 129 | + | /// Its new name, 1 to 16 bytes. | |
| 130 | + | #[arg(value_parser = parse_name)] | |
| 131 | + | name: String, | |
| 132 | + | }, | |
| 123 | 133 | } | |
| 124 | 134 | ||
| 125 | 135 | #[derive(Subcommand)] | |
| @@ -288,6 +298,13 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 288 | 298 | let created = session.create_custom_eq(name)?; | |
| 289 | 299 | output::created_eq(out, &device, model, &created, cli.json) | |
| 290 | 300 | } | |
| 301 | + | Command::Eq { | |
| 302 | + | action: Some(EqCommand::Rename { ref preset, ref name }), | |
| 303 | + | .. | |
| 304 | + | } => { | |
| 305 | + | let now = session.rename_custom_eq(preset, name)?; | |
| 306 | + | output::renamed_eq(out, &device, model, &now, cli.json) | |
| 307 | + | } | |
| 291 | 308 | Command::Eq { preset: None, .. } => { | |
| 292 | 309 | let presets = session.eq_presets()?; | |
| 293 | 310 | output::eq_presets(out, &device, model, &presets, cli.json) | |
| @@ -392,5 +409,10 @@ mod tests { | |||
| 392 | 409 | parse(&["eq", "new", "Gym mix"]), | |
| 393 | 410 | Command::Eq { action: Some(EqCommand::New { name }), .. } if name == "Gym mix" | |
| 394 | 411 | )); | |
| 412 | + | assert!(matches!( | |
| 413 | + | parse(&["eq", "rename", "custom-5", "Gym"]), | |
| 414 | + | Command::Eq { action: Some(EqCommand::Rename { preset, name }), .. } | |
| 415 | + | if preset == "custom-5" && name == "Gym" | |
| 416 | + | )); | |
| 395 | 417 | } | |
| 396 | 418 | } | |
| @@ -373,6 +373,26 @@ pub fn created_eq( | |||
| 373 | 373 | Ok(()) | |
| 374 | 374 | } | |
| 375 | 375 | ||
| 376 | + | /// A custom preset just renamed, as the buds list it. | |
| 377 | + | pub fn renamed_eq( | |
| 378 | + | out: &mut impl Write, | |
| 379 | + | device: &PairedDevice, | |
| 380 | + | model: &dyn Device, | |
| 381 | + | now: &EqPreset, | |
| 382 | + | as_json: bool, | |
| 383 | + | ) -> Result<(), Failure> { | |
| 384 | + | if as_json { | |
| 385 | + | return json( | |
| 386 | + | out, | |
| 387 | + | Some((device, model)), | |
| 388 | + | serde_json::json!({ "renamed": format!("custom-{}", now.id), "preset": now }), | |
| 389 | + | ); | |
| 390 | + | } | |
| 391 | + | header(out, device, model)?; | |
| 392 | + | writeln!(out, " Renamed custom-{} \"{}\"", now.id, now.name)?; | |
| 393 | + | Ok(()) | |
| 394 | + | } | |
| 395 | + | ||
| 376 | 396 | /// The EQ preset after a change, as the buds report it on reading back. | |
| 377 | 397 | pub fn eq( | |
| 378 | 398 | out: &mut impl Write, | |
| @@ -27,12 +27,14 @@ fn usage_errors_exit_1() { | |||
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | #[test] | |
| 30 | − | fn eq_new_checks_the_name_before_connecting() { | |
| 30 | + | fn eq_new_and_rename_check_the_name_before_connecting() { | |
| 31 | 31 | for args in [ | |
| 32 | 32 | &["eq", "new"][..], | |
| 33 | 33 | &["eq", "new", ""], | |
| 34 | 34 | &["eq", "new", " Gym"], | |
| 35 | 35 | &["eq", "new", "a\tb"], | |
| 36 | + | &["eq", "rename", "custom-5"], | |
| 37 | + | &["eq", "rename", "custom-5", ""], | |
| 36 | 38 | ] { | |
| 37 | 39 | let out = budctl(args); | |
| 38 | 40 | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| @@ -92,6 +92,14 @@ pub trait Session: Send { | |||
| 92 | 92 | ))) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | + | /// Give a custom EQ preset (`custom-<id>`) a new name. The buds save it, which also selects | |
| 96 | + | /// it. Returns it as the buds list it afterwards. | |
| 97 | + | fn rename_custom_eq(&mut self, preset: &str, _name: &str) -> Result<EqPreset> { | |
| 98 | + | Err(Error::Unsupported(format!( | |
| 99 | + | "budctl cannot rename {preset} on these buds" | |
| 100 | + | ))) | |
| 101 | + | } | |
| 102 | + | ||
| 95 | 103 | /// Set the bass level. Only the ack can be checked when the buds cannot report the level. | |
| 96 | 104 | fn set_bass_level(&mut self, _level: i8) -> Result<()> { | |
| 97 | 105 | Err(Error::Unsupported( | |
| @@ -635,6 +635,15 @@ pub(super) fn new_custom_eq(name: &str, model: &CustomEq) -> Result<CustomEq> { | |||
| 635 | 635 | }) | |
| 636 | 636 | } | |
| 637 | 637 | ||
| 638 | + | /// The preset under a new name, and nothing else changed: saved with [`custom_eq_save_payload`], | |
| 639 | + | /// it is renamed. Spec: realme_t310.md#custom-eq-rename | |
| 640 | + | pub(super) fn renamed(preset: &CustomEq, name: &str) -> Result<CustomEq> { | |
| 641 | + | Ok(CustomEq { | |
| 642 | + | name: custom_eq_name(name)?, | |
| 643 | + | ..preset.clone() | |
| 644 | + | }) | |
| 645 | + | } | |
| 646 | + | ||
| 638 | 647 | /// The id in a `0x0418` reply: `00 <id>`. Spec: realme_t310.md#custom-eq | |
| 639 | 648 | pub(super) fn decode_custom_eq_write_reply(reply: &[u8]) -> Result<u8> { | |
| 640 | 649 | let mut r = ok_reply(reply, "the custom EQ write")?; | |
| @@ -1218,6 +1227,31 @@ mod tests { | |||
| 1218 | 1227 | ); | |
| 1219 | 1228 | } | |
| 1220 | 1229 | ||
| 1230 | + | /// The owner's list after W11c: Custom1 (id 04) and Test (id 05, flat, selected). | |
| 1231 | + | const OWNER_AFTER_W11C: &str = "00 02 00 FA 06 04 07 43 75 73 74 6F 6D 31 06 32 00 06 FA 00 03 E8 03 03 A0 0F 04 40 1F 06 80 3E 06 \ | |
| 1232 | + | 01 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"; | |
| 1233 | + | ||
| 1234 | + | #[test] | |
| 1235 | + | fn renames_the_owners_new_preset() { | |
| 1236 | + | let list = decode_custom_eq_list(&hex(OWNER_AFTER_W11C)).unwrap(); | |
| 1237 | + | let test = list.iter().find(|p| p.id == 0x05).unwrap(); | |
| 1238 | + | let gym = renamed(test, "Gym").unwrap(); | |
| 1239 | + | assert_eq!( | |
| 1240 | + | (gym.id, gym.bands.clone(), gym.name.clone()), | |
| 1241 | + | (0x05, test.bands.clone(), b"Gym".to_vec()) | |
| 1242 | + | ); | |
| 1243 | + | // Spec: realme_t310.md#custom-eq-rename (the expected frame, with seq 06) | |
| 1244 | + | let frame = | |
| 1245 | + | crate::devices::realme_t310::frame::encode(CUSTOM_EQ_WRITE, 0x06, &custom_eq_save_payload(&gym).unwrap()) | |
| 1246 | + | .unwrap(); | |
| 1247 | + | assert_eq!( | |
| 1248 | + | frame, | |
| 1249 | + | hex("AA 22 00 00 18 04 06 1B 00 02 FA 06 05 03 47 79 6D 06 \ | |
| 1250 | + | 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00") | |
| 1251 | + | ); | |
| 1252 | + | assert!(renamed(test, "").is_err()); | |
| 1253 | + | } | |
| 1254 | + | ||
| 1221 | 1255 | #[test] | |
| 1222 | 1256 | fn preset_names() { | |
| 1223 | 1257 | assert_eq!(custom_eq_name("Gym").unwrap(), b"Gym"); | |
| @@ -424,6 +424,49 @@ impl Session for T310Session { | |||
| 424 | 424 | .ok_or_else(|| Error::Protocol(format!("the new preset {name} vanished from the list"))) | |
| 425 | 425 | } | |
| 426 | 426 | ||
| 427 | + | /// Spec: realme_t310.md#custom-eq-rename, #write-read-back | |
| 428 | + | fn rename_custom_eq(&mut self, preset: &str, name: &str) -> Result<EqPreset> { | |
| 429 | + | let Some(id) = codec::custom_eq_id(preset) else { | |
| 430 | + | return Err(Error::Usage(format!( | |
| 431 | + | "{preset} is not a custom preset; those are named custom-<id>, as `budctl eq` lists them" | |
| 432 | + | ))); | |
| 433 | + | }; | |
| 434 | + | if !self.commands.allows(codec::CUSTOM_EQ_WRITE) || !self.commands.allows(codec::CUSTOM_EQ_LIST) { | |
| 435 | + | return Err(Error::Unsupported( | |
| 436 | + | "these buds do not offer custom EQ presets".to_owned(), | |
| 437 | + | )); | |
| 438 | + | } | |
| 439 | + | // A save carries the whole preset, so it starts from the list read just now. | |
| 440 | + | let list = codec::decode_custom_eq_list(&self.link.request(codec::CUSTOM_EQ_LIST, &[])?)?; | |
| 441 | + | let Some(current) = list.iter().find(|p| p.id == id) else { | |
| 442 | + | return Err(Error::Usage(format!("these buds have no {preset}"))); | |
| 443 | + | }; | |
| 444 | + | if list.iter().any(|p| p.id != id && p.name == name.as_bytes()) { | |
| 445 | + | return Err(Error::Usage(format!("a custom preset is called {name} already"))); | |
| 446 | + | } | |
| 447 | + | let wanted = codec::renamed(current, name)?; | |
| 448 | + | let reply = self | |
| 449 | + | .link | |
| 450 | + | .request(codec::CUSTOM_EQ_WRITE, &codec::custom_eq_save_payload(&wanted)?)?; | |
| 451 | + | codec::check_write_reply(&reply, "renaming the custom EQ")?; | |
| 452 | + | let now = self.read_back( | |
| 453 | + | READ_BACK, | |
| 454 | + | codec::CUSTOM_EQ_LIST, | |
| 455 | + | &[], | |
| 456 | + | |reply| { | |
| 457 | + | let now = codec::decode_custom_eq_list(reply)?.into_iter().find(|p| p.id == id); | |
| 458 | + | let applied = now.as_ref().is_some_and(|p| p.name == wanted.name); | |
| 459 | + | Ok((applied, now)) | |
| 460 | + | }, | |
| 461 | + | |now| match now { | |
| 462 | + | None => format!("the buds accepted the new name, but their list no longer has {preset}"), | |
| 463 | + | Some(_) => format!("the buds accepted the new name, but still list {preset} under the old one"), | |
| 464 | + | }, | |
| 465 | + | )?; | |
| 466 | + | now.map(|p| p.preset()) | |
| 467 | + | .ok_or_else(|| Error::Protocol(format!("{preset} vanished from the list"))) | |
| 468 | + | } | |
| 469 | + | ||
| 427 | 470 | /// Spec: realme_t310.md#bass-write | |
| 428 | 471 | fn bass_range(&self) -> Option<(i8, i8)> { | |
| 429 | 472 | self.commands | |
| @@ -1274,6 +1317,39 @@ mod tests { | |||
| 1274 | 1317 | assert_eq!(created.bands.len(), 6); | |
| 1275 | 1318 | } | |
| 1276 | 1319 | ||
| 1320 | + | #[test] | |
| 1321 | + | fn renames_a_custom_preset_and_reads_it_back() { | |
| 1322 | + | const LIST: &str = "00 02 00 FA 06 04 07 43 75 73 74 6F 6D 31 06 32 00 06 FA 00 03 E8 03 03 A0 0F 04 40 1F 06 80 3E 06 \ | |
| 1323 | + | 01 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"; | |
| 1324 | + | const AFTER: &str = "00 02 00 FA 06 04 07 43 75 73 74 6F 6D 31 06 32 00 06 FA 00 03 E8 03 03 A0 0F 04 40 1F 06 80 3E 06 \ | |
| 1325 | + | 01 FA 06 05 03 47 79 6D 06 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00"; | |
| 1326 | + | let mut steps = handshake(); | |
| 1327 | + | steps.extend([ | |
| 1328 | + | tx(0x0122, 0x05, ""), | |
| 1329 | + | rx(0x8122, 0x05, LIST), | |
| 1330 | + | tx( | |
| 1331 | + | 0x0418, | |
| 1332 | + | 0x06, | |
| 1333 | + | "02 FA 06 05 03 47 79 6D 06 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00", | |
| 1334 | + | ), | |
| 1335 | + | rx(0x8418, 0x06, "00 05"), | |
| 1336 | + | tx(0x0122, 0x07, ""), | |
| 1337 | + | rx(0x8122, 0x07, AFTER), | |
| 1338 | + | ]); | |
| 1339 | + | let script = Script::new(steps); | |
| 1340 | + | let now = open(&script).unwrap().rename_custom_eq("custom-5", "Gym").unwrap(); | |
| 1341 | + | script.assert_done(); | |
| 1342 | + | assert_eq!((now.id, now.name.as_str()), (5, "Gym")); | |
| 1343 | + | ||
| 1344 | + | // Another preset's name is refused before anything is written. | |
| 1345 | + | let mut steps = handshake(); | |
| 1346 | + | steps.extend([tx(0x0122, 0x05, ""), rx(0x8122, 0x05, LIST)]); | |
| 1347 | + | let script = Script::new(steps); | |
| 1348 | + | let taken = open(&script).unwrap().rename_custom_eq("custom-5", "Custom1"); | |
| 1349 | + | script.assert_done(); | |
| 1350 | + | assert!(matches!(taken, Err(Error::Usage(_)))); | |
| 1351 | + | } | |
| 1352 | + | ||
| 1277 | 1353 | #[test] | |
| 1278 | 1354 | fn refuses_a_fourth_preset_or_a_taken_name_before_writing() { | |
| 1279 | 1355 | let one = "00 FA 06 04 07 43 75 73 74 6F 6D 31 00"; | |
| @@ -510,7 +510,11 @@ Evidence: *vendor* | |||
| 510 | 510 | - **A create selects the new preset** (*ours-2026-10-28*, W11c): in the list read after it, the new preset's `selected` byte is `01` and the others' are `00`. | |
| 511 | 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 | 512 | - **On the owner's T310** (*ours-2026-10-28*, W11c): that frame went out byte for byte. The buds answered `0x8418 00 05` within 50 ms. The list read 150 ms after the ack had two presets: Custom1 (id `04`, unchanged, `selected` `00`) and Test (id `05`, the six bands at 0 dB, `selected` `01`). So the reply's id was the listed id: no renumbering on this create. | |
| 513 | − | - Renaming (a save with a new name) and deleting (`03`) come next, one checkpoint each. | |
| 513 | + | - <a id="custom-eq-rename"></a>**W11d, rename**, wanted by the owner (2026-10-28): `budctl eq rename custom-<id> <name>` reads the list and saves (`02`) the preset as listed with only its name changed. The name follows the [create](#custom-eq-create) rules, and another preset's name is refused before anything is sent. Like any save, a rename also selects the preset. | |
| 514 | + | - **Evidence:** *derived*. A save carries the name, and W11b saved the name back unchanged. That a save with a different name renames the preset in place, under the same id, is what this checkpoint tests. | |
| 515 | + | - **Check:** read the list again, as for [read-back](#write-read-back). The preset with that id must have the new name. | |
| 516 | + | - **Expected frame** (*derived*), for `eq rename custom-5 Gym` on the owner's list after W11c, with seq `06`: `AA 22 00 00 18 04 06 1B 00 02 FA 06 05 03 47 79 6D 06 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00`. | |
| 517 | + | - Deleting (`03`) comes later, if it is wanted. | |
| 514 | 518 | ||
| 515 | 519 | <a id="absent"></a> | |
| 516 | 520 | ## 11. Not on the T310 | |