Krolyxon
Commit 56c56d4

Branches

Refs →

budctl56c56d4

budctl eq delete custom-<id>: delete a custom EQ preset (W11e)

0x0418 03 with the preset as listed (the write has the same layout for
every action). A preset the buds do not have is refused before anything
is sent. The list is read again: one preset fewer, and none left with
the deleted one's name and bands; ids can be renumbered, so they prove
nothing. Not tried on hardware yet.

With no custom preset left, `eq new` no longer refuses: it uses the
layout seen on the owner's T310 (range FA 06, bands 50 to 16000 Hz).

Spec: realme_t310.md#custom-eq-delete, with the expected frame for
`eq delete custom-5` on the owner's list after W11d.

No new dependencies.
Author
krolyxon
Date
2026-10-28 01:39 +0530
Parent
84c42fc
Commit
56c56d4b54cd3cd0762bdd3593ef924e700f9b32

+183 −19

7 files changed

  1. M crates/budctl-cli/src/main.rs +17 −1
  2. M crates/budctl-cli/src/output.rs +24 −0
  3. M crates/budctl-cli/tests/cli.rs +1 −0
  4. M crates/budctl-core/src/devices/mod.rs +8 −0
  5. M crates/budctl-core/src/devices/realme_t310/codec.rs +56 −7
  6. M crates/budctl-core/src/devices/realme_t310/mod.rs +69 −7
  7. M docs/protocol/realme_t310.md +8 −4
M crates/budctl-cli/src/main.rs View
@@ -69,7 +69,7 @@ enum Command {
6969 ///
7070 /// For a custom preset, `<hz>=<gain>` pairs change those bands' gains (dB), then save and
7171 /// 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+ /// `budctl eq rename custom-<id> <name>` renames one, `budctl eq delete custom-<id>` deletes one.
7373 #[command(args_conflicts_with_subcommands = true)]
7474 Eq {
7575 #[command(subcommand)]
@@ -130,6 +130,11 @@ enum EqCommand {
130130 #[arg(value_parser = parse_name)]
131131 name: String,
132132 },
133+ /// Delete a custom preset, then read the list back. Its bands are lost.
134+ Delete {
135+ /// The preset, as `budctl eq` lists it: custom-<id>.
136+ preset: String,
137+ },
133138 }
134139
135140 #[derive(Subcommand)]
@@ -305,6 +310,13 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
305310 let now = session.rename_custom_eq(preset, name)?;
306311 output::renamed_eq(out, &device, model, &now, cli.json)
307312 }
313+ Command::Eq {
314+ action: Some(EqCommand::Delete { ref preset }),
315+ ..
316+ } => {
317+ let left = session.delete_custom_eq(preset)?;
318+ output::deleted_eq(out, &device, model, preset, &left, cli.json)
319+ }
308320 Command::Eq { preset: None, .. } => {
309321 let presets = session.eq_presets()?;
310322 output::eq_presets(out, &device, model, &presets, cli.json)
@@ -409,6 +421,10 @@ mod tests {
409421 parse(&["eq", "new", "Gym mix"]),
410422 Command::Eq { action: Some(EqCommand::New { name }), .. } if name == "Gym mix"
411423 ));
424+ assert!(matches!(
425+ parse(&["eq", "delete", "custom-5"]),
426+ Command::Eq { action: Some(EqCommand::Delete { preset }), .. } if preset == "custom-5"
427+ ));
412428 assert!(matches!(
413429 parse(&["eq", "rename", "custom-5", "Gym"]),
414430 Command::Eq { action: Some(EqCommand::Rename { preset, name }), .. }
M crates/budctl-cli/src/output.rs View
@@ -373,6 +373,30 @@ pub fn created_eq(
373373 Ok(())
374374 }
375375
376+/// The custom presets left after deleting one, as the buds list them.
377+pub fn deleted_eq(
378+ out: &mut impl Write,
379+ device: &PairedDevice,
380+ model: &dyn Device,
381+ deleted: &str,
382+ left: &[EqPreset],
383+ as_json: bool,
384+) -> Result<(), Failure> {
385+ if as_json {
386+ return json(
387+ out,
388+ Some((device, model)),
389+ serde_json::json!({ "deleted": deleted, "presets": left }),
390+ );
391+ }
392+ header(out, device, model)?;
393+ writeln!(out, " Deleted {deleted}")?;
394+ for preset in left {
395+ writeln!(out, " Left custom-{} \"{}\"", preset.id, preset.name)?;
396+ }
397+ Ok(())
398+}
399+
376400 /// A custom preset just renamed, as the buds list it.
377401 pub fn renamed_eq(
378402 out: &mut impl Write,
M crates/budctl-cli/tests/cli.rs View
@@ -35,6 +35,7 @@ fn eq_new_and_rename_check_the_name_before_connecting() {
3535 &["eq", "new", "a\tb"],
3636 &["eq", "rename", "custom-5"],
3737 &["eq", "rename", "custom-5", ""],
38+ &["eq", "delete"],
3839 ] {
3940 let out = budctl(args);
4041 assert_eq!(out.status.code(), Some(1), "{args:?}");
M crates/budctl-core/src/devices/mod.rs View
@@ -100,6 +100,14 @@ pub trait Session: Send {
100100 )))
101101 }
102102
103+ /// Delete a custom EQ preset (`custom-<id>`). Returns the custom presets left, as the buds
104+ /// list them afterwards: their ids can change.
105+ fn delete_custom_eq(&mut self, preset: &str) -> Result<Vec<EqPreset>> {
106+ Err(Error::Unsupported(format!(
107+ "budctl cannot delete {preset} on these buds"
108+ )))
109+ }
110+
103111 /// Set the bass level. Only the ack can be checked when the buds cannot report the level.
104112 fn set_bass_level(&mut self, _level: i8) -> Result<()> {
105113 Err(Error::Unsupported(
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -64,6 +64,13 @@ pub(super) const CUSTOM_EQ_WRITE: u16 = 0x0418;
6464 const CUSTOM_EQ_SAVE: u8 = 0x02;
6565 /// The `0x0418` action that creates a preset; the buds give it an id. Spec: realme_t310.md#custom-eq
6666 const CUSTOM_EQ_CREATE: u8 = 0x01;
67+/// The `0x0418` action that deletes a preset. Spec: realme_t310.md#custom-eq
68+const CUSTOM_EQ_DELETE: u8 = 0x03;
69+/// The range and bands of the owner's T310's custom presets: what a new preset gets when there is
70+/// none to copy. Spec: realme_t310.md#custom-eq-create
71+const T310_CUSTOM_RANGE: (i8, i8) = (-6, 6);
72+/// Spec: realme_t310.md#custom-eq-create
73+const T310_CUSTOM_BANDS: [u16; 6] = [50, 250, 1000, 4000, 8000, 16000];
6774 /// The most custom presets the buds keep. Spec: realme_t310.md#custom-eq
6875 pub(super) const CUSTOM_EQ_MAX: usize = 3;
6976 /// The longest preset name budctl writes, in bytes. budctl's own limit: the name's length is one
@@ -622,19 +629,39 @@ pub(super) fn custom_eq_name(name: &str) -> Result<Vec<u8>> {
622629 Ok(name.as_bytes().to_vec())
623630 }
624631
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> {
632+/// A new preset called `name`: the range and the bands of `model`, a preset the buds have, else
633+/// the T310's own, with every gain at 0, and id `00` for the buds to replace.
634+/// Spec: realme_t310.md#custom-eq-create
635+pub(super) fn new_custom_eq(name: &str, model: Option<&CustomEq>) -> Result<CustomEq> {
636+ let ((min, max), bands): ((i8, i8), Vec<u16>) = match model {
637+ Some(model) => ((model.min, model.max), model.bands.iter().map(|b| b.hz).collect()),
638+ None => (T310_CUSTOM_RANGE, T310_CUSTOM_BANDS.to_vec()),
639+ };
628640 Ok(CustomEq {
629641 selected: false,
630− min: model.min,
631− max: model.max,
642+ min,
643+ max,
632644 id: 0x00,
633645 name: custom_eq_name(name)?,
634− bands: model.bands.iter().map(|b| EqBand { hz: b.hz, gain: 0 }).collect(),
646+ bands: bands.into_iter().map(|hz| EqBand { hz, gain: 0 }).collect(),
635647 })
636648 }
637649
650+/// `03 <min> <max> <id> <nameLen> <name> <bands> …`: delete the preset, sent as the list holds it.
651+/// Spec: realme_t310.md#custom-eq-delete
652+pub(super) fn custom_eq_delete_payload(preset: &CustomEq) -> Result<Vec<u8>> {
653+ custom_eq_payload(CUSTOM_EQ_DELETE, preset)
654+}
655+
656+/// Whether a list read after deleting `gone` from `before` shows it gone: one preset fewer, and
657+/// none left with its name and bands. Ids can change, so they prove nothing.
658+/// Spec: realme_t310.md#custom-eq-delete
659+pub(super) fn deleted(before: &[CustomEq], gone: &CustomEq, now: &[CustomEq]) -> bool {
660+ let same = |p: &CustomEq| p.name == gone.name && p.bands == gone.bands;
661+ let left_before = before.iter().filter(|p| same(p)).count().saturating_sub(1);
662+ now.len() + 1 == before.len() && now.iter().filter(|p| same(p)).count() == left_before
663+}
664+
638665 /// The preset under a new name, and nothing else changed: saved with [`custom_eq_save_payload`],
639666 /// it is renamed. Spec: realme_t310.md#custom-eq-rename
640667 pub(super) fn renamed(preset: &CustomEq, name: &str) -> Result<CustomEq> {
@@ -1211,7 +1238,9 @@ mod tests {
12111238 #[test]
12121239 fn creates_a_flat_copy_of_the_owners_preset() {
12131240 let list = decode_custom_eq_list(&hex(OWNER_CUSTOM_EQS)).unwrap();
1214− let new = new_custom_eq("Test", &list[0]).unwrap();
1241+ let new = new_custom_eq("Test", list.first()).unwrap();
1242+ // With no preset to copy, the T310's own layout: the same, here.
1243+ assert_eq!(new_custom_eq("Test", None).unwrap(), new);
12151244 assert_eq!((new.id, new.min, new.max), (0x00, -6, 6));
12161245 assert!(new.bands.iter().all(|b| b.gain == 0));
12171246 let hz: Vec<u16> = new.bands.iter().map(|b| b.hz).collect();
@@ -1252,6 +1281,26 @@ mod tests {
12521281 assert!(renamed(test, "").is_err());
12531282 }
12541283
1284+ #[test]
1285+ fn deletes_the_owners_preset_as_listed() {
1286+ let list = decode_custom_eq_list(&hex(OWNER_AFTER_W11C)).unwrap();
1287+ let mut gym = list.iter().find(|p| p.id == 0x05).unwrap().clone();
1288+ gym.name = b"Gym".to_vec();
1289+ // Spec: realme_t310.md#custom-eq-delete (the expected frame, with seq 06)
1290+ let frame =
1291+ crate::devices::realme_t310::frame::encode(CUSTOM_EQ_WRITE, 0x06, &custom_eq_delete_payload(&gym).unwrap())
1292+ .unwrap();
1293+ assert_eq!(
1294+ frame,
1295+ hex("AA 22 00 00 18 04 06 1B 00 03 FA 06 05 03 47 79 6D 06 \
1296+ 32 00 00 FA 00 00 E8 03 00 A0 0F 00 40 1F 00 80 3E 00")
1297+ );
1298+ let before = vec![list[0].clone(), gym.clone()];
1299+ assert!(deleted(&before, &gym, &before[..1]));
1300+ assert!(!deleted(&before, &gym, &before));
1301+ assert!(!deleted(&before, &gym, &before[1..]), "the wrong one went");
1302+ }
1303+
12551304 #[test]
12561305 fn preset_names() {
12571306 assert_eq!(custom_eq_name("Gym").unwrap(), b"Gym");
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -398,12 +398,7 @@ impl Session for T310Session {
398398 return Err(Error::Usage(format!("a custom preset is called {name} already")));
399399 }
400400 // 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)?;
401+ let new = codec::new_custom_eq(name, list.first())?;
407402 let reply = self
408403 .link
409404 .request(codec::CUSTOM_EQ_WRITE, &codec::custom_eq_create_payload(&new)?)?;
@@ -467,6 +462,40 @@ impl Session for T310Session {
467462 .ok_or_else(|| Error::Protocol(format!("{preset} vanished from the list")))
468463 }
469464
465+ /// Spec: realme_t310.md#custom-eq-delete, #write-read-back
466+ fn delete_custom_eq(&mut self, preset: &str) -> Result<Vec<EqPreset>> {
467+ let Some(id) = codec::custom_eq_id(preset) else {
468+ return Err(Error::Usage(format!(
469+ "{preset} is not a custom preset; those are named custom-<id>, as `budctl eq` lists them"
470+ )));
471+ };
472+ if !self.commands.allows(codec::CUSTOM_EQ_WRITE) || !self.commands.allows(codec::CUSTOM_EQ_LIST) {
473+ return Err(Error::Unsupported(
474+ "these buds do not offer custom EQ presets".to_owned(),
475+ ));
476+ }
477+ // The delete carries the whole preset, as the list holds it just now.
478+ let list = codec::decode_custom_eq_list(&self.link.request(codec::CUSTOM_EQ_LIST, &[])?)?;
479+ let Some(gone) = list.iter().find(|p| p.id == id).cloned() else {
480+ return Err(Error::Usage(format!("these buds have no {preset}")));
481+ };
482+ let reply = self
483+ .link
484+ .request(codec::CUSTOM_EQ_WRITE, &codec::custom_eq_delete_payload(&gone)?)?;
485+ codec::check_write_reply(&reply, "deleting the custom EQ")?;
486+ let left = self.read_back(
487+ READ_BACK,
488+ codec::CUSTOM_EQ_LIST,
489+ &[],
490+ |reply| {
491+ let now = codec::decode_custom_eq_list(reply)?;
492+ Ok((codec::deleted(&list, &gone, &now), now))
493+ },
494+ |_| format!("the buds accepted deleting {preset}, but still list it"),
495+ )?;
496+ Ok(left.iter().map(codec::CustomEq::preset).collect())
497+ }
498+
470499 /// Spec: realme_t310.md#bass-write
471500 fn bass_range(&self) -> Option<(i8, i8)> {
472501 self.commands
@@ -1350,13 +1379,46 @@ mod tests {
13501379 assert!(matches!(taken, Err(Error::Usage(_))));
13511380 }
13521381
1382+ #[test]
1383+ fn deletes_a_custom_preset_and_reads_the_list_back() {
1384+ 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 \
1385+ 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";
1386+ const AFTER: &str =
1387+ "00 01 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";
1388+ let mut steps = handshake();
1389+ steps.extend([
1390+ tx(0x0122, 0x05, ""),
1391+ rx(0x8122, 0x05, LIST),
1392+ tx(
1393+ 0x0418,
1394+ 0x06,
1395+ "03 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",
1396+ ),
1397+ rx(0x8418, 0x06, "00 05"),
1398+ tx(0x0122, 0x07, ""),
1399+ rx(0x8122, 0x07, AFTER),
1400+ ]);
1401+ let script = Script::new(steps);
1402+ let left = open(&script).unwrap().delete_custom_eq("custom-5").unwrap();
1403+ script.assert_done();
1404+ let names: Vec<&str> = left.iter().map(|p| p.name.as_str()).collect();
1405+ assert_eq!(names, ["Custom1"]);
1406+
1407+ // A preset the buds do not have is refused before anything is written.
1408+ let mut steps = handshake();
1409+ steps.extend([tx(0x0122, 0x05, ""), rx(0x8122, 0x05, LIST)]);
1410+ let script = Script::new(steps);
1411+ let missing = open(&script).unwrap().delete_custom_eq("custom-7");
1412+ script.assert_done();
1413+ assert!(matches!(missing, Err(Error::Usage(_))));
1414+ }
1415+
13531416 #[test]
13541417 fn refuses_a_fourth_preset_or_a_taken_name_before_writing() {
13551418 let one = "00 FA 06 04 07 43 75 73 74 6F 6D 31 00";
13561419 for (list, name) in [
13571420 (format!("00 03 {one} {one} {one}"), "Test"),
13581421 (format!("00 01 {one}"), "Custom1"),
1359− ("00 00".to_owned(), "Test"),
13601422 ] {
13611423 let mut steps = handshake();
13621424 steps.extend([tx(0x0122, 0x05, ""), rx(0x8122, 0x05, &list)]);
M docs/protocol/realme_t310.md View
@@ -503,9 +503,9 @@ Evidence: *vendor*
503503 - **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`.
504504 - **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.
505505 - **`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− - <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.
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, or when a preset has that name already.
507507 - **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).
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). With no preset to copy, see [below](#custom-eq-delete).
509509 - **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.
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`.
@@ -515,7 +515,11 @@ Evidence: *vendor*
515515 - **Check:** read the list again, as for [read-back](#write-read-back). The preset with that id must have the new name.
516516 - **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`.
517517 - **On the owner's T310** (*ours-2026-10-28*, W11d): that frame went out byte for byte, and the buds answered `0x8418 00 05` within 100 ms. The list read 100 ms after the ack has id `05` named "Gym", with the same flat bands and `selected` `01`; Custom1 is unchanged.
518− - Deleting (`03`) comes later, if it is wanted.
518+ - <a id="custom-eq-delete"></a>**W11e, delete** (`03`), wanted by the owner (2026-10-28): `budctl eq delete custom-<id>` reads the list and sends the preset as listed, with action `03`: the write has the same layout for every action. A preset the buds do not have is refused before anything is sent.
519+ - **Check:** read the list again, as for [read-back](#write-read-back). It must have one preset fewer, and none left with the deleted one's name and bands. Ids can be renumbered after a delete, so they prove nothing.
520+ - **Unknown:** which EQ the buds use after the preset in use is deleted. budctl reads the EQ again after a delete.
521+ - **Expected frame** (*derived*), for `eq delete custom-5` on the owner's list after W11d (Gym, flat), with seq `06`: `AA 22 00 00 18 04 06 1B 00 03 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`.
522+ - **With no custom preset left**, a create has nothing to copy. It then uses the layout of the owner's T310 (*ours-2026-10-27*): range `FA 06` and bands at 50, 250, 1000, 4000, 8000 and 16000 Hz.
519523
520524 <a id="absent"></a>
521525 ## 11. Not on the T310
@@ -562,7 +566,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go
562566 | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) |
563567 | W9 | `touch cycle` |
564568 | W10 | ~~`fit-test`~~: dropped, the T310 runs no test ([§10.8](#fit-test)) |
565−| 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 |
569+| 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; W11e: delete one (`eq delete custom-<id>`) |
566570
567571 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.
568572