Krolyxon
Commit 39f837a

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-28 01:30 +0530
Parent
b1f5115
Commit
39f837a8124236f951577ae56eaf0c8f712c4698

+169 −3

7 files changed

  1. M crates/budctl-cli/src/main.rs +23 −1
  2. M crates/budctl-cli/src/output.rs +20 −0
  3. M crates/budctl-cli/tests/cli.rs +3 −1
  4. M crates/budctl-core/src/devices/mod.rs +8 −0
  5. M crates/budctl-core/src/devices/realme_t310/codec.rs +34 −0
  6. M crates/budctl-core/src/devices/realme_t310/mod.rs +76 −0
  7. M docs/protocol/realme_t310.md +5 −1
M crates/budctl-cli/src/main.rs View
@@ -68,7 +68,8 @@ enum Command {
6868 /// List the EQ presets, or select one (pure-raw, deep-bass, pure-vocals, pure-bass, custom-<id>) and read it back.
6969 ///
7070 /// 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.
7273 #[command(args_conflicts_with_subcommands = true)]
7374 Eq {
7475 #[command(subcommand)]
@@ -120,6 +121,15 @@ enum EqCommand {
120121 #[arg(value_parser = parse_name)]
121122 name: String,
122123 },
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+ },
123133 }
124134
125135 #[derive(Subcommand)]
@@ -288,6 +298,13 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
288298 let created = session.create_custom_eq(name)?;
289299 output::created_eq(out, &device, model, &created, cli.json)
290300 }
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+ }
291308 Command::Eq { preset: None, .. } => {
292309 let presets = session.eq_presets()?;
293310 output::eq_presets(out, &device, model, &presets, cli.json)
@@ -392,5 +409,10 @@ mod tests {
392409 parse(&["eq", "new", "Gym mix"]),
393410 Command::Eq { action: Some(EqCommand::New { name }), .. } if name == "Gym mix"
394411 ));
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+ ));
395417 }
396418 }
M crates/budctl-cli/src/output.rs View
@@ -373,6 +373,26 @@ pub fn created_eq(
373373 Ok(())
374374 }
375375
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+
376396 /// The EQ preset after a change, as the buds report it on reading back.
377397 pub fn eq(
378398 out: &mut impl Write,
M crates/budctl-cli/tests/cli.rs View
@@ -27,12 +27,14 @@ fn usage_errors_exit_1() {
2727 }
2828
2929 #[test]
30−fn eq_new_checks_the_name_before_connecting() {
30+fn eq_new_and_rename_check_the_name_before_connecting() {
3131 for args in [
3232 &["eq", "new"][..],
3333 &["eq", "new", ""],
3434 &["eq", "new", " Gym"],
3535 &["eq", "new", "a\tb"],
36+ &["eq", "rename", "custom-5"],
37+ &["eq", "rename", "custom-5", ""],
3638 ] {
3739 let out = budctl(args);
3840 assert_eq!(out.status.code(), Some(1), "{args:?}");
M crates/budctl-core/src/devices/mod.rs View
@@ -92,6 +92,14 @@ pub trait Session: Send {
9292 )))
9393 }
9494
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+
95103 /// Set the bass level. Only the ack can be checked when the buds cannot report the level.
96104 fn set_bass_level(&mut self, _level: i8) -> Result<()> {
97105 Err(Error::Unsupported(
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -635,6 +635,15 @@ pub(super) fn new_custom_eq(name: &str, model: &CustomEq) -> Result<CustomEq> {
635635 })
636636 }
637637
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+
638647 /// The id in a `0x0418` reply: `00 <id>`. Spec: realme_t310.md#custom-eq
639648 pub(super) fn decode_custom_eq_write_reply(reply: &[u8]) -> Result<u8> {
640649 let mut r = ok_reply(reply, "the custom EQ write")?;
@@ -1218,6 +1227,31 @@ mod tests {
12181227 );
12191228 }
12201229
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+
12211255 #[test]
12221256 fn preset_names() {
12231257 assert_eq!(custom_eq_name("Gym").unwrap(), b"Gym");
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -424,6 +424,49 @@ impl Session for T310Session {
424424 .ok_or_else(|| Error::Protocol(format!("the new preset {name} vanished from the list")))
425425 }
426426
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+
427470 /// Spec: realme_t310.md#bass-write
428471 fn bass_range(&self) -> Option<(i8, i8)> {
429472 self.commands
@@ -1274,6 +1317,39 @@ mod tests {
12741317 assert_eq!(created.bands.len(), 6);
12751318 }
12761319
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+
12771353 #[test]
12781354 fn refuses_a_fourth_preset_or_a_taken_name_before_writing() {
12791355 let one = "00 FA 06 04 07 43 75 73 74 6F 6D 31 00";
M docs/protocol/realme_t310.md View
@@ -510,7 +510,11 @@ Evidence: *vendor*
510510 - **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`.
511511 - **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`.
512512 - **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.
514518
515519 <a id="absent"></a>
516520 ## 11. Not on the T310