Krolyxon
Commit f5d5748

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-28 01:26 +0530
Parent
3c3ef17
Commit
f5d5748391009d41f3478dc6e6816e694196e917

+336 −4

7 files changed

  1. M crates/budctl-cli/src/main.rs +69 −1
  2. M crates/budctl-cli/src/output.rs +31 −0
  3. M crates/budctl-cli/tests/cli.rs +13 −0
  4. M crates/budctl-core/src/devices/mod.rs +8 −0
  5. M crates/budctl-core/src/devices/realme_t310/codec.rs +119 −1
  6. M crates/budctl-core/src/devices/realme_t310/mod.rs +88 −0
  7. M docs/protocol/realme_t310.md +8 −2
M crates/budctl-cli/src/main.rs View
@@ -68,8 +68,11 @@ 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`.
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)]
7273 Eq {
74+ #[command(subcommand)]
75+ action: Option<EqCommand>,
7376 preset: Option<String>,
7477 /// Bands to change, as `<hz>=<gain>`. Custom presets only.
7578 #[arg(requires = "preset", value_parser = parse_band, allow_negative_numbers = true)]
@@ -109,6 +112,16 @@ enum FindAction {
109112 Stop,
110113 }
111114
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+
112125 #[derive(Subcommand)]
113126 enum TouchCommand {
114127 /// 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> {
268281 let now = session.set_noise_control(mode.into())?;
269282 output::noise_control(out, &device, model, &now, cli.json)
270283 }
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+ }
271291 Command::Eq { preset: None, .. } => {
272292 let presets = session.eq_presets()?;
273293 output::eq_presets(out, &device, model, &presets, cli.json)
@@ -275,6 +295,7 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
275295 Command::Eq {
276296 preset: Some(ref preset),
277297 ref bands,
298+ ..
278299 } if !bands.is_empty() => {
279300 let now = session.set_custom_eq(preset, bands)?;
280301 output::custom_eq(out, &device, model, &now, cli.json)
@@ -306,6 +327,18 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
306327 }
307328 }
308329
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+
309342 /// `50=+3` → 50 Hz at +3 dB.
310343 fn parse_band(text: &str) -> Result<EqBand, String> {
311344 let (hz, gain) = text
@@ -326,3 +359,38 @@ fn exit_code(error: &Error) -> u8 {
326359 Kind::Failed => 4,
327360 }
328361 }
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+}
M crates/budctl-cli/src/output.rs View
@@ -342,6 +342,37 @@ pub fn custom_eq(
342342 Ok(())
343343 }
344344
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+
345376 /// The EQ preset after a change, as the buds report it on reading back.
346377 pub fn eq(
347378 out: &mut impl Write,
M crates/budctl-cli/tests/cli.rs View
@@ -26,6 +26,19 @@ fn usage_errors_exit_1() {
2626 }
2727 }
2828
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+
2942 #[test]
3043 fn find_start_needs_yes_before_connecting() {
3144 let out = budctl(&["find", "start"]);
M crates/budctl-core/src/devices/mod.rs View
@@ -84,6 +84,14 @@ pub trait Session: Send {
8484 None
8585 }
8686
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+
8795 /// Set the bass level. Only the ack can be checked when the buds cannot report the level.
8896 fn set_bass_level(&mut self, _level: i8) -> Result<()> {
8997 Err(Error::Unsupported(
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -62,6 +62,13 @@ pub(super) const CUSTOM_EQ_LIST: u16 = 0x0122;
6262 pub(super) const CUSTOM_EQ_WRITE: u16 = 0x0418;
6363 /// The `0x0418` action that saves a whole preset and selects it. Spec: realme_t310.md#custom-eq
6464 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;
6572 /// Spec: realme_t310.md#events-offered
6673 pub(super) const EVENTS_OFFERED: u16 = 0x0200;
6774 /// Spec: realme_t310.md#subscribe
@@ -586,8 +593,69 @@ pub(super) fn retune(preset: &CustomEq, changes: &[EqBand]) -> Result<CustomEq>
586593 /// `02 <min> <max> <id> <nameLen> <name> <bands> (<freq u16 LE> <gain s8>)…`: save the whole
587594 /// preset and select it. Spec: realme_t310.md#custom-eq
588595 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>> {
589657 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];
591659 payload.extend([preset.min.to_le_bytes()[0], preset.max.to_le_bytes()[0], preset.id]);
592660 payload.push(u8::try_from(preset.name.len()).map_err(|_| too_long("name"))?);
593661 payload.extend(&preset.name);
@@ -1130,4 +1198,54 @@ mod tests {
11301198 assert!(usage(&[EqBand { hz: 50, gain: -7 }]));
11311199 assert!(usage(&[EqBand { hz: 50, gain: 1 }, EqBand { hz: 50, gain: 2 }]));
11321200 }
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+ }
11331251 }
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -379,6 +379,51 @@ impl Session for T310Session {
379379 }
380380
381381 /// 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+
382427 /// Spec: realme_t310.md#bass-write
383428 fn bass_range(&self) -> Option<(i8, i8)> {
384429 self.commands
@@ -1203,6 +1248,49 @@ mod tests {
12031248 assert!(matches!(result, Err(Error::Usage(_))));
12041249 }
12051250
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+
12061294 #[test]
12071295 fn retunes_a_custom_preset_and_reads_it_back() {
12081296 let tuned =
M docs/protocol/realme_t310.md View
@@ -503,7 +503,13 @@ 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− - 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.
507513
508514 <a id="absent"></a>
509515 ## 11. Not on the T310
@@ -550,7 +556,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go
550556 | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) |
551557 | W9 | `touch cycle` |
552558 | 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 |
554560
555561 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.
556562