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.
+183 −19
7 files changed
- M crates/budctl-cli/src/main.rs +17 −1
- M crates/budctl-cli/src/output.rs +24 −0
- M crates/budctl-cli/tests/cli.rs +1 −0
- M crates/budctl-core/src/devices/mod.rs +8 −0
- M crates/budctl-core/src/devices/realme_t310/codec.rs +56 −7
- M crates/budctl-core/src/devices/realme_t310/mod.rs +69 −7
- M docs/protocol/realme_t310.md +8 −4
| @@ -69,7 +69,7 @@ enum Command { | |||
| 69 | 69 | /// | |
| 70 | 70 | /// For a custom preset, `<hz>=<gain>` pairs change those bands' gains (dB), then save and | |
| 71 | 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 | + | /// `budctl eq rename custom-<id> <name>` renames one, `budctl eq delete custom-<id>` deletes one. | |
| 73 | 73 | #[command(args_conflicts_with_subcommands = true)] | |
| 74 | 74 | Eq { | |
| 75 | 75 | #[command(subcommand)] | |
| @@ -130,6 +130,11 @@ enum EqCommand { | |||
| 130 | 130 | #[arg(value_parser = parse_name)] | |
| 131 | 131 | name: String, | |
| 132 | 132 | }, | |
| 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 | + | }, | |
| 133 | 138 | } | |
| 134 | 139 | ||
| 135 | 140 | #[derive(Subcommand)] | |
| @@ -305,6 +310,13 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 305 | 310 | let now = session.rename_custom_eq(preset, name)?; | |
| 306 | 311 | output::renamed_eq(out, &device, model, &now, cli.json) | |
| 307 | 312 | } | |
| 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 | + | } | |
| 308 | 320 | Command::Eq { preset: None, .. } => { | |
| 309 | 321 | let presets = session.eq_presets()?; | |
| 310 | 322 | output::eq_presets(out, &device, model, &presets, cli.json) | |
| @@ -409,6 +421,10 @@ mod tests { | |||
| 409 | 421 | parse(&["eq", "new", "Gym mix"]), | |
| 410 | 422 | Command::Eq { action: Some(EqCommand::New { name }), .. } if name == "Gym mix" | |
| 411 | 423 | )); | |
| 424 | + | assert!(matches!( | |
| 425 | + | parse(&["eq", "delete", "custom-5"]), | |
| 426 | + | Command::Eq { action: Some(EqCommand::Delete { preset }), .. } if preset == "custom-5" | |
| 427 | + | )); | |
| 412 | 428 | assert!(matches!( | |
| 413 | 429 | parse(&["eq", "rename", "custom-5", "Gym"]), | |
| 414 | 430 | Command::Eq { action: Some(EqCommand::Rename { preset, name }), .. } | |
| @@ -373,6 +373,30 @@ pub fn created_eq( | |||
| 373 | 373 | Ok(()) | |
| 374 | 374 | } | |
| 375 | 375 | ||
| 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 | + | ||
| 376 | 400 | /// A custom preset just renamed, as the buds list it. | |
| 377 | 401 | pub fn renamed_eq( | |
| 378 | 402 | out: &mut impl Write, | |
| @@ -35,6 +35,7 @@ fn eq_new_and_rename_check_the_name_before_connecting() { | |||
| 35 | 35 | &["eq", "new", "a\tb"], | |
| 36 | 36 | &["eq", "rename", "custom-5"], | |
| 37 | 37 | &["eq", "rename", "custom-5", ""], | |
| 38 | + | &["eq", "delete"], | |
| 38 | 39 | ] { | |
| 39 | 40 | let out = budctl(args); | |
| 40 | 41 | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| @@ -100,6 +100,14 @@ pub trait Session: Send { | |||
| 100 | 100 | ))) | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 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 | + | ||
| 103 | 111 | /// Set the bass level. Only the ack can be checked when the buds cannot report the level. | |
| 104 | 112 | fn set_bass_level(&mut self, _level: i8) -> Result<()> { | |
| 105 | 113 | Err(Error::Unsupported( | |
| @@ -64,6 +64,13 @@ pub(super) const CUSTOM_EQ_WRITE: u16 = 0x0418; | |||
| 64 | 64 | const CUSTOM_EQ_SAVE: u8 = 0x02; | |
| 65 | 65 | /// The `0x0418` action that creates a preset; the buds give it an id. Spec: realme_t310.md#custom-eq | |
| 66 | 66 | 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]; | |
| 67 | 74 | /// The most custom presets the buds keep. Spec: realme_t310.md#custom-eq | |
| 68 | 75 | pub(super) const CUSTOM_EQ_MAX: usize = 3; | |
| 69 | 76 | /// 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>> { | |||
| 622 | 629 | Ok(name.as_bytes().to_vec()) | |
| 623 | 630 | } | |
| 624 | 631 | ||
| 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 | + | }; | |
| 628 | 640 | Ok(CustomEq { | |
| 629 | 641 | selected: false, | |
| 630 | − | min: model.min, | |
| 631 | − | max: model.max, | |
| 642 | + | min, | |
| 643 | + | max, | |
| 632 | 644 | id: 0x00, | |
| 633 | 645 | 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(), | |
| 635 | 647 | }) | |
| 636 | 648 | } | |
| 637 | 649 | ||
| 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 | + | ||
| 638 | 665 | /// The preset under a new name, and nothing else changed: saved with [`custom_eq_save_payload`], | |
| 639 | 666 | /// it is renamed. Spec: realme_t310.md#custom-eq-rename | |
| 640 | 667 | pub(super) fn renamed(preset: &CustomEq, name: &str) -> Result<CustomEq> { | |
| @@ -1211,7 +1238,9 @@ mod tests { | |||
| 1211 | 1238 | #[test] | |
| 1212 | 1239 | fn creates_a_flat_copy_of_the_owners_preset() { | |
| 1213 | 1240 | 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); | |
| 1215 | 1244 | assert_eq!((new.id, new.min, new.max), (0x00, -6, 6)); | |
| 1216 | 1245 | assert!(new.bands.iter().all(|b| b.gain == 0)); | |
| 1217 | 1246 | let hz: Vec<u16> = new.bands.iter().map(|b| b.hz).collect(); | |
| @@ -1252,6 +1281,26 @@ mod tests { | |||
| 1252 | 1281 | assert!(renamed(test, "").is_err()); | |
| 1253 | 1282 | } | |
| 1254 | 1283 | ||
| 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 | + | ||
| 1255 | 1304 | #[test] | |
| 1256 | 1305 | fn preset_names() { | |
| 1257 | 1306 | assert_eq!(custom_eq_name("Gym").unwrap(), b"Gym"); | |
| @@ -398,12 +398,7 @@ impl Session for T310Session { | |||
| 398 | 398 | return Err(Error::Usage(format!("a custom preset is called {name} already"))); | |
| 399 | 399 | } | |
| 400 | 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)?; | |
| 401 | + | let new = codec::new_custom_eq(name, list.first())?; | |
| 407 | 402 | let reply = self | |
| 408 | 403 | .link | |
| 409 | 404 | .request(codec::CUSTOM_EQ_WRITE, &codec::custom_eq_create_payload(&new)?)?; | |
| @@ -467,6 +462,40 @@ impl Session for T310Session { | |||
| 467 | 462 | .ok_or_else(|| Error::Protocol(format!("{preset} vanished from the list"))) | |
| 468 | 463 | } | |
| 469 | 464 | ||
| 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 | + | ||
| 470 | 499 | /// Spec: realme_t310.md#bass-write | |
| 471 | 500 | fn bass_range(&self) -> Option<(i8, i8)> { | |
| 472 | 501 | self.commands | |
| @@ -1350,13 +1379,46 @@ mod tests { | |||
| 1350 | 1379 | assert!(matches!(taken, Err(Error::Usage(_)))); | |
| 1351 | 1380 | } | |
| 1352 | 1381 | ||
| 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 | + | ||
| 1353 | 1416 | #[test] | |
| 1354 | 1417 | fn refuses_a_fourth_preset_or_a_taken_name_before_writing() { | |
| 1355 | 1418 | let one = "00 FA 06 04 07 43 75 73 74 6F 6D 31 00"; | |
| 1356 | 1419 | for (list, name) in [ | |
| 1357 | 1420 | (format!("00 03 {one} {one} {one}"), "Test"), | |
| 1358 | 1421 | (format!("00 01 {one}"), "Custom1"), | |
| 1359 | − | ("00 00".to_owned(), "Test"), | |
| 1360 | 1422 | ] { | |
| 1361 | 1423 | let mut steps = handshake(); | |
| 1362 | 1424 | steps.extend([tx(0x0122, 0x05, ""), rx(0x8122, 0x05, &list)]); | |
| @@ -503,9 +503,9 @@ 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 | − | - <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. | |
| 507 | 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). | |
| 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). | |
| 509 | 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 | 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`. | |
| @@ -515,7 +515,11 @@ Evidence: *vendor* | |||
| 515 | 515 | - **Check:** read the list again, as for [read-back](#write-read-back). The preset with that id must have the new name. | |
| 516 | 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 | 517 | - **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. | |
| 519 | 523 | ||
| 520 | 524 | <a id="absent"></a> | |
| 521 | 525 | ## 11. Not on the T310 | |
| @@ -562,7 +566,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go | |||
| 562 | 566 | | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) | | |
| 563 | 567 | | W9 | `touch cycle` | | |
| 564 | 568 | | 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>`) | | |
| 566 | 570 | ||
| 567 | 571 | 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. | |
| 568 | 572 | ||