budctl684a13f
budctl eq custom-<id> <hz>=<gain>… saves a custom preset (W11b)
0x0418 02 with the preset copied from the 0x0122 list; only the named bands' gains change. Verified on the owner's T310: acked with 00 04 and read back on the first try. No new dependencies.
+336 −17
7 files changed
- M crates/budctl-cli/src/main.rs +31 −4
- M crates/budctl-cli/src/output.rs +24 −1
- M crates/budctl-cli/tests/cli.rs +14 −0
- M crates/budctl-core/src/devices/mod.rs +9 −1
- M crates/budctl-core/src/devices/realme_t310/codec.rs +132 −7
- M crates/budctl-core/src/devices/realme_t310/mod.rs +116 −2
- M docs/protocol/realme_t310.md +10 −2
| @@ -9,7 +9,7 @@ use std::io::{self, Write}; | |||
| 9 | 9 | use std::process::ExitCode; | |
| 10 | 10 | ||
| 11 | 11 | use budctl_core::bt::BdAddr; | |
| 12 | − | use budctl_core::status::{AncLevel, NoiseMode}; | |
| 12 | + | use budctl_core::status::{AncLevel, EqBand, NoiseMode}; | |
| 13 | 13 | use budctl_core::{Error, Kind}; | |
| 14 | 14 | use clap::{Parser, Subcommand, ValueEnum}; | |
| 15 | 15 | ||
| @@ -65,8 +65,16 @@ enum Command { | |||
| 65 | 65 | #[arg(value_enum)] | |
| 66 | 66 | mode: AncArg, | |
| 67 | 67 | }, | |
| 68 | − | /// List the EQ presets, or select one (pure-raw, deep-bass, pure-vocals, pure-bass) and read it back. | |
| 69 | − | Eq { preset: Option<String> }, | |
| 68 | + | /// List the EQ presets, or select one (pure-raw, deep-bass, pure-vocals, pure-bass, custom-<id>) and read it back. | |
| 69 | + | /// | |
| 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`. | |
| 72 | + | Eq { | |
| 73 | + | preset: Option<String>, | |
| 74 | + | /// Bands to change, as `<hz>=<gain>`. Custom presets only. | |
| 75 | + | #[arg(requires = "preset", value_parser = parse_band, allow_negative_numbers = true)] | |
| 76 | + | bands: Vec<EqBand>, | |
| 77 | + | }, | |
| 70 | 78 | /// Turn an on/off setting on or off, then read it back. Switches: game, bass-boost, spatial (3D sound), wind, dual. | |
| 71 | 79 | Set { | |
| 72 | 80 | /// The switch's name, as `status --json` lists it. | |
| @@ -260,12 +268,20 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 260 | 268 | let now = session.set_noise_control(mode.into())?; | |
| 261 | 269 | output::noise_control(out, &device, model, &now, cli.json) | |
| 262 | 270 | } | |
| 263 | − | Command::Eq { preset: None } => { | |
| 271 | + | Command::Eq { preset: None, .. } => { | |
| 264 | 272 | let presets = session.eq_presets()?; | |
| 265 | 273 | output::eq_presets(out, &device, model, &presets, cli.json) | |
| 266 | 274 | } | |
| 267 | 275 | Command::Eq { | |
| 268 | 276 | preset: Some(ref preset), | |
| 277 | + | ref bands, | |
| 278 | + | } if !bands.is_empty() => { | |
| 279 | + | let now = session.set_custom_eq(preset, bands)?; | |
| 280 | + | output::custom_eq(out, &device, model, &now, cli.json) | |
| 281 | + | } | |
| 282 | + | Command::Eq { | |
| 283 | + | preset: Some(ref preset), | |
| 284 | + | .. | |
| 269 | 285 | } => { | |
| 270 | 286 | let now = session.set_eq(preset)?; | |
| 271 | 287 | output::eq(out, &device, model, &now, cli.json) | |
| @@ -290,6 +306,17 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 290 | 306 | } | |
| 291 | 307 | } | |
| 292 | 308 | ||
| 309 | + | /// `50=+3` → 50 Hz at +3 dB. | |
| 310 | + | fn parse_band(text: &str) -> Result<EqBand, String> { | |
| 311 | + | let (hz, gain) = text | |
| 312 | + | .split_once('=') | |
| 313 | + | .ok_or_else(|| format!("{text} is not <hz>=<gain>, e.g. 50=+3"))?; | |
| 314 | + | Ok(EqBand { | |
| 315 | + | hz: hz.parse().map_err(|_| format!("{hz} is not a frequency in Hz"))?, | |
| 316 | + | gain: gain.parse().map_err(|_| format!("{gain} is not a gain in whole dB"))?, | |
| 317 | + | }) | |
| 318 | + | } | |
| 319 | + | ||
| 293 | 320 | /// Exit codes from CLAUDE.md: 1 usage, 2 not found, 3 busy, 4 rejected or timed out. | |
| 294 | 321 | fn exit_code(error: &Error) -> u8 { | |
| 295 | 322 | match error.kind() { | |
| @@ -4,7 +4,9 @@ use std::io::{self, Write}; | |||
| 4 | 4 | ||
| 5 | 5 | use budctl_core::bluez::PairedDevice; | |
| 6 | 6 | use budctl_core::devices::Device; | |
| 7 | − | use budctl_core::status::{Charge, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls}; | |
| 7 | + | use budctl_core::status::{ | |
| 8 | + | Charge, EqPreset, EqPresets, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls, | |
| 9 | + | }; | |
| 8 | 10 | use budctl_core::{Error, Found}; | |
| 9 | 11 | use serde_json::Value; | |
| 10 | 12 | ||
| @@ -319,6 +321,27 @@ pub fn eq_presets( | |||
| 319 | 321 | Ok(()) | |
| 320 | 322 | } | |
| 321 | 323 | ||
| 324 | + | /// One custom preset after it was saved and selected, as the buds list it on reading back. | |
| 325 | + | pub fn custom_eq( | |
| 326 | + | out: &mut impl Write, | |
| 327 | + | device: &PairedDevice, | |
| 328 | + | model: &dyn Device, | |
| 329 | + | now: &EqPreset, | |
| 330 | + | as_json: bool, | |
| 331 | + | ) -> Result<(), Failure> { | |
| 332 | + | if as_json { | |
| 333 | + | return json( | |
| 334 | + | out, | |
| 335 | + | Some((device, model)), | |
| 336 | + | serde_json::json!({ "eq": format!("custom-{}", now.id), "preset": now }), | |
| 337 | + | ); | |
| 338 | + | } | |
| 339 | + | header(out, device, model)?; | |
| 340 | + | let bands: Vec<String> = now.bands.iter().map(|b| format!("{} Hz {:+}", b.hz, b.gain)).collect(); | |
| 341 | + | writeln!(out, " EQ custom-{} \"{}\": {}", now.id, now.name, bands.join(", "))?; | |
| 342 | + | Ok(()) | |
| 343 | + | } | |
| 344 | + | ||
| 322 | 345 | /// The EQ preset after a change, as the buds report it on reading back. | |
| 323 | 346 | pub fn eq( | |
| 324 | 347 | out: &mut impl Write, | |
| @@ -74,3 +74,17 @@ fn bass_needs_a_level_from_minus_5_to_5() { | |||
| 74 | 74 | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| 75 | 75 | } | |
| 76 | 76 | } | |
| 77 | + | ||
| 78 | + | #[test] | |
| 79 | + | fn eq_bands_must_be_hz_equals_gain() { | |
| 80 | + | for args in [ | |
| 81 | + | &["eq", "custom-4", "50"][..], | |
| 82 | + | &["eq", "custom-4", "fifty=3"], | |
| 83 | + | &["eq", "custom-4", "50=loud"], | |
| 84 | + | &["eq", "custom-4", "50=+3.5"], | |
| 85 | + | &["eq", "custom-4", "50=300"], | |
| 86 | + | ] { | |
| 87 | + | let out = budctl(args); | |
| 88 | + | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| 89 | + | } | |
| 90 | + | } | |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | use crate::bluez::PairedDevice; | |
| 7 | 7 | use crate::bt::Uuid; | |
| 8 | 8 | use crate::error::{Error, Result}; | |
| 9 | − | use crate::status::{EqPresets, NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls}; | |
| 9 | + | use crate::status::{EqBand, EqPreset, EqPresets, NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls}; | |
| 10 | 10 | use crate::transport::Transport; | |
| 11 | 11 | ||
| 12 | 12 | pub mod realme_t310; | |
| @@ -62,6 +62,14 @@ pub trait Session: Send { | |||
| 62 | 62 | )) | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | + | /// Change the gains of some bands of a custom EQ preset (`custom-<id>`), save it and select | |
| 66 | + | /// it, then read it back. With no bands, it only selects the preset. | |
| 67 | + | fn set_custom_eq(&mut self, preset: &str, _bands: &[EqBand]) -> Result<EqPreset> { | |
| 68 | + | Err(Error::Unsupported(format!( | |
| 69 | + | "budctl cannot change {preset} on these buds" | |
| 70 | + | ))) | |
| 71 | + | } | |
| 72 | + | ||
| 65 | 73 | /// Set the bass level. Only the ack can be checked when the buds cannot report the level. | |
| 66 | 74 | fn set_bass_level(&mut self, _level: i8) -> Result<()> { | |
| 67 | 75 | Err(Error::Unsupported( | |
| @@ -56,6 +56,10 @@ pub(super) const EQ_PRESET: u16 = 0x010F; | |||
| 56 | 56 | pub(super) const CODEC: u16 = 0x0114; | |
| 57 | 57 | /// Spec: realme_t310.md#custom-eq | |
| 58 | 58 | pub(super) const CUSTOM_EQ_LIST: u16 = 0x0122; | |
| 59 | + | /// Spec: realme_t310.md#custom-eq | |
| 60 | + | pub(super) const CUSTOM_EQ_WRITE: u16 = 0x0418; | |
| 61 | + | /// The `0x0418` action that saves a whole preset and selects it. Spec: realme_t310.md#custom-eq | |
| 62 | + | const CUSTOM_EQ_SAVE: u8 = 0x02; | |
| 59 | 63 | /// Spec: realme_t310.md#events-offered | |
| 60 | 64 | pub(super) const EVENTS_OFFERED: u16 = 0x0200; | |
| 61 | 65 | /// Spec: realme_t310.md#subscribe | |
| @@ -482,16 +486,40 @@ pub(super) fn builtin_eqs() -> Vec<EqPreset> { | |||
| 482 | 486 | .collect() | |
| 483 | 487 | } | |
| 484 | 488 | ||
| 489 | + | /// One custom preset as the list holds it. Spec: realme_t310.md#custom-eq | |
| 490 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 491 | + | pub(super) struct CustomEq { | |
| 492 | + | pub(super) selected: bool, | |
| 493 | + | /// Gain range, dB. | |
| 494 | + | pub(super) min: i8, | |
| 495 | + | pub(super) max: i8, | |
| 496 | + | pub(super) id: u8, | |
| 497 | + | /// The stored name, byte for byte, so a save writes it back unchanged. | |
| 498 | + | pub(super) name: Vec<u8>, | |
| 499 | + | pub(super) bands: Vec<EqBand>, | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | impl CustomEq { | |
| 503 | + | pub(super) fn preset(&self) -> EqPreset { | |
| 504 | + | EqPreset { | |
| 505 | + | name: String::from_utf8_lossy(&self.name).into_owned(), | |
| 506 | + | id: self.id, | |
| 507 | + | custom: true, | |
| 508 | + | bands: self.bands.clone(), | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | ||
| 485 | 513 | /// `00 <count>`, then per preset `<selected> <min> <max> <id> <nameLen> <name> <bands> | |
| 486 | 514 | /// (<freq u16 LE> <gain s8>)…`. Spec: realme_t310.md#custom-eq | |
| 487 | − | pub(super) fn decode_custom_eqs(reply: &[u8]) -> Result<Vec<EqPreset>> { | |
| 515 | + | pub(super) fn decode_custom_eq_list(reply: &[u8]) -> Result<Vec<CustomEq>> { | |
| 488 | 516 | let mut r = ok_reply(reply, "the custom EQ list")?; | |
| 489 | 517 | let mut presets = Vec::new(); | |
| 490 | 518 | for _ in 0..r.u8()? { | |
| 491 | − | let (_selected, _min, _max) = (r.u8()?, r.i8()?, r.i8()?); | |
| 492 | − | let id = r.u8()?; | |
| 519 | + | let selected = r.u8()? == 0x01; | |
| 520 | + | let (min, max, id) = (r.i8()?, r.i8()?, r.u8()?); | |
| 493 | 521 | let name_len = r.u8()?; | |
| 494 | − | let name = String::from_utf8_lossy(r.take(usize::from(name_len))?).into_owned(); | |
| 522 | + | let name = r.take(usize::from(name_len))?.to_vec(); | |
| 495 | 523 | let mut bands = Vec::new(); | |
| 496 | 524 | for _ in 0..r.u8()? { | |
| 497 | 525 | bands.push(EqBand { | |
| @@ -499,16 +527,74 @@ pub(super) fn decode_custom_eqs(reply: &[u8]) -> Result<Vec<EqPreset>> { | |||
| 499 | 527 | gain: r.i8()?, | |
| 500 | 528 | }); | |
| 501 | 529 | } | |
| 502 | − | presets.push(EqPreset { | |
| 503 | − | name, | |
| 530 | + | presets.push(CustomEq { | |
| 531 | + | selected, | |
| 532 | + | min, | |
| 533 | + | max, | |
| 504 | 534 | id, | |
| 505 | − | custom: true, | |
| 535 | + | name, | |
| 506 | 536 | bands, | |
| 507 | 537 | }); | |
| 508 | 538 | } | |
| 509 | 539 | Ok(presets) | |
| 510 | 540 | } | |
| 511 | 541 | ||
| 542 | + | /// Spec: realme_t310.md#custom-eq | |
| 543 | + | pub(super) fn decode_custom_eqs(reply: &[u8]) -> Result<Vec<EqPreset>> { | |
| 544 | + | Ok(decode_custom_eq_list(reply)?.iter().map(CustomEq::preset).collect()) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | /// The id in `custom-<id>`, the name [`decode_eq`] gives a custom preset. | |
| 548 | + | /// Spec: realme_t310.md#eq-read | |
| 549 | + | pub(super) fn custom_eq_id(name: &str) -> Option<u8> { | |
| 550 | + | name.strip_prefix("custom-")?.parse().ok() | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | /// The preset with the given bands' gains changed, and nothing else. Every band must be one the | |
| 554 | + | /// preset has, at most once, with a gain inside the preset's range. | |
| 555 | + | /// Spec: realme_t310.md#custom-eq | |
| 556 | + | pub(super) fn retune(preset: &CustomEq, changes: &[EqBand]) -> Result<CustomEq> { | |
| 557 | + | let mut tuned = preset.clone(); | |
| 558 | + | for (i, change) in changes.iter().enumerate() { | |
| 559 | + | if changes.iter().take(i).any(|earlier| earlier.hz == change.hz) { | |
| 560 | + | return Err(Error::Usage(format!("the {} Hz band is given twice", change.hz))); | |
| 561 | + | } | |
| 562 | + | let Some(band) = tuned.bands.iter_mut().find(|b| b.hz == change.hz) else { | |
| 563 | + | let have: Vec<String> = preset.bands.iter().map(|b| b.hz.to_string()).collect(); | |
| 564 | + | return Err(Error::Usage(format!( | |
| 565 | + | "custom-{} has no {} Hz band; its bands are {} Hz", | |
| 566 | + | preset.id, | |
| 567 | + | change.hz, | |
| 568 | + | have.join(", ") | |
| 569 | + | ))); | |
| 570 | + | }; | |
| 571 | + | if !(preset.min..=preset.max).contains(&change.gain) { | |
| 572 | + | return Err(Error::Usage(format!( | |
| 573 | + | "a gain of {:+} dB is outside {:+}..{:+} dB", | |
| 574 | + | change.gain, preset.min, preset.max | |
| 575 | + | ))); | |
| 576 | + | } | |
| 577 | + | band.gain = change.gain; | |
| 578 | + | } | |
| 579 | + | Ok(tuned) | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | /// `02 <min> <max> <id> <nameLen> <name> <bands> (<freq u16 LE> <gain s8>)…`: save the whole | |
| 583 | + | /// preset and select it. Spec: realme_t310.md#custom-eq | |
| 584 | + | pub(super) fn custom_eq_save_payload(preset: &CustomEq) -> Result<Vec<u8>> { | |
| 585 | + | let too_long = |what: &str| Error::Protocol(format!("the preset's {what} does not fit in a byte")); | |
| 586 | + | let mut payload = vec![CUSTOM_EQ_SAVE]; | |
| 587 | + | payload.extend([preset.min.to_le_bytes()[0], preset.max.to_le_bytes()[0], preset.id]); | |
| 588 | + | payload.push(u8::try_from(preset.name.len()).map_err(|_| too_long("name"))?); | |
| 589 | + | payload.extend(&preset.name); | |
| 590 | + | payload.push(u8::try_from(preset.bands.len()).map_err(|_| too_long("band count"))?); | |
| 591 | + | for band in &preset.bands { | |
| 592 | + | payload.extend(band.hz.to_le_bytes()); | |
| 593 | + | payload.push(band.gain.to_le_bytes()[0]); | |
| 594 | + | } | |
| 595 | + | Ok(payload) | |
| 596 | + | } | |
| 597 | + | ||
| 512 | 598 | /// One gesture-table entry: `<dev> <btn> <act> <fn>`. Spec: realme_t310.md#gestures | |
| 513 | 599 | pub(super) type GestureEntry = [u8; 4]; | |
| 514 | 600 | ||
| @@ -978,4 +1064,43 @@ mod tests { | |||
| 978 | 1064 | .unwrap(); | |
| 979 | 1065 | assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 1B 04 05 03 00 FB 05 03"); | |
| 980 | 1066 | } | |
| 1067 | + | ||
| 1068 | + | /// The owner's custom EQ list (W11a): "Custom1", id 04, 6 flat bands from 50 Hz. | |
| 1069 | + | const OWNER_CUSTOM_EQS: &str = | |
| 1070 | + | "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"; | |
| 1071 | + | ||
| 1072 | + | #[test] | |
| 1073 | + | fn retunes_the_owners_custom_preset() { | |
| 1074 | + | let list = decode_custom_eq_list(&hex(OWNER_CUSTOM_EQS)).unwrap(); | |
| 1075 | + | let custom1 = list.first().unwrap(); | |
| 1076 | + | assert!(custom1.selected); | |
| 1077 | + | assert_eq!((custom1.min, custom1.max, custom1.id), (-6, 6, 4)); | |
| 1078 | + | assert_eq!(custom_eq_id("custom-4"), Some(4)); | |
| 1079 | + | assert_eq!(custom_eq_id("pure-bass"), None); | |
| 1080 | + | assert_eq!(custom_eq_id("custom-x"), None); | |
| 1081 | + | ||
| 1082 | + | // Unchanged, the save carries the preset as listed. | |
| 1083 | + | assert_eq!( | |
| 1084 | + | custom_eq_save_payload(&retune(custom1, &[]).unwrap()).unwrap(), | |
| 1085 | + | hex("02 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") | |
| 1086 | + | ); | |
| 1087 | + | let tuned = retune(custom1, &[EqBand { hz: 50, gain: 3 }, EqBand { hz: 16000, gain: -2 }]).unwrap(); | |
| 1088 | + | let payload = custom_eq_save_payload(&tuned).unwrap(); | |
| 1089 | + | assert_eq!( | |
| 1090 | + | payload, | |
| 1091 | + | hex("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") | |
| 1092 | + | ); | |
| 1093 | + | // The frame the spec gives (realme_t310.md#custom-eq). | |
| 1094 | + | let frame = crate::devices::realme_t310::frame::encode(CUSTOM_EQ_WRITE, 0x06, &payload).unwrap(); | |
| 1095 | + | assert_eq!( | |
| 1096 | + | crate::debug::hex(&frame), | |
| 1097 | + | "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" | |
| 1098 | + | ); | |
| 1099 | + | ||
| 1100 | + | let usage = |changes: &[EqBand]| matches!(retune(custom1, changes), Err(Error::Usage(_))); | |
| 1101 | + | assert!(usage(&[EqBand { hz: 62, gain: 1 }])); | |
| 1102 | + | assert!(usage(&[EqBand { hz: 50, gain: 7 }])); | |
| 1103 | + | assert!(usage(&[EqBand { hz: 50, gain: -7 }])); | |
| 1104 | + | assert!(usage(&[EqBand { hz: 50, gain: 1 }, EqBand { hz: 50, gain: 2 }])); | |
| 1105 | + | } | |
| 981 | 1106 | } | |
| @@ -10,7 +10,8 @@ use crate::bt::Uuid; | |||
| 10 | 10 | use crate::devices::{Device, Session}; | |
| 11 | 11 | use crate::error::{Error, Kind, Result}; | |
| 12 | 12 | use crate::status::{ | |
| 13 | − | EqPresets, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls, | |
| 13 | + | EqBand, EqPreset, EqPresets, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, | |
| 14 | + | TouchControls, | |
| 14 | 15 | }; | |
| 15 | 16 | use crate::transport::Transport; | |
| 16 | 17 | ||
| @@ -295,9 +296,12 @@ impl Session for T310Session { | |||
| 295 | 296 | ||
| 296 | 297 | /// Built-in presets only; custom EQs come with W11. Spec: realme_t310.md#eq-write | |
| 297 | 298 | fn set_eq(&mut self, preset: &str) -> Result<String> { | |
| 299 | + | if codec::custom_eq_id(preset).is_some() { | |
| 300 | + | return self.set_custom_eq(preset, &[]).map(|now| format!("custom-{}", now.id)); | |
| 301 | + | } | |
| 298 | 302 | let Some(id) = codec::eq_id(preset) else { | |
| 299 | 303 | return Err(Error::Usage(format!( | |
| 300 | − | "no EQ preset called {preset}; these buds have: {}", | |
| 304 | + | "no EQ preset called {preset}; these buds have: {}, and the custom-<id> ones `budctl eq` lists", | |
| 301 | 305 | codec::eq_names().join(", ") | |
| 302 | 306 | ))); | |
| 303 | 307 | }; | |
| @@ -319,6 +323,56 @@ impl Session for T310Session { | |||
| 319 | 323 | ) | |
| 320 | 324 | } | |
| 321 | 325 | ||
| 326 | + | /// Spec: realme_t310.md#custom-eq, #write-read-back | |
| 327 | + | fn set_custom_eq(&mut self, preset: &str, bands: &[EqBand]) -> Result<EqPreset> { | |
| 328 | + | let Some(id) = codec::custom_eq_id(preset) else { | |
| 329 | + | return Err(Error::Usage(format!( | |
| 330 | + | "{preset} is not a custom preset; those are named custom-<id>, as `budctl eq` lists them" | |
| 331 | + | ))); | |
| 332 | + | }; | |
| 333 | + | if !self.commands.allows(codec::CUSTOM_EQ_WRITE) || !self.commands.allows(codec::CUSTOM_EQ_LIST) { | |
| 334 | + | return Err(Error::Unsupported( | |
| 335 | + | "these buds do not offer custom EQ presets".to_owned(), | |
| 336 | + | )); | |
| 337 | + | } | |
| 338 | + | // A save carries the whole preset, so it starts from the list read just now. | |
| 339 | + | let list = codec::decode_custom_eq_list(&self.link.request(codec::CUSTOM_EQ_LIST, &[])?)?; | |
| 340 | + | let Some(current) = list.iter().find(|p| p.id == id) else { | |
| 341 | + | let have: Vec<String> = list.iter().map(|p| format!("custom-{}", p.id)).collect(); | |
| 342 | + | return Err(Error::Usage(format!( | |
| 343 | + | "these buds have no {preset}; their custom presets: {}", | |
| 344 | + | if have.is_empty() { | |
| 345 | + | "none".to_owned() | |
| 346 | + | } else { | |
| 347 | + | have.join(", ") | |
| 348 | + | } | |
| 349 | + | ))); | |
| 350 | + | }; | |
| 351 | + | let wanted = codec::retune(current, bands)?; | |
| 352 | + | let reply = self | |
| 353 | + | .link | |
| 354 | + | .request(codec::CUSTOM_EQ_WRITE, &codec::custom_eq_save_payload(&wanted)?)?; | |
| 355 | + | codec::check_write_reply(&reply, "saving the custom EQ")?; | |
| 356 | + | ||
| 357 | + | let now = self.read_back( | |
| 358 | + | READ_BACK, | |
| 359 | + | codec::CUSTOM_EQ_LIST, | |
| 360 | + | &[], | |
| 361 | + | |reply| { | |
| 362 | + | let now = codec::decode_custom_eq_list(reply)?.into_iter().find(|p| p.id == id); | |
| 363 | + | let applied = now.as_ref().is_some_and(|p| p.bands == wanted.bands && p.selected); | |
| 364 | + | Ok((applied, now)) | |
| 365 | + | }, | |
| 366 | + | |now| match now { | |
| 367 | + | None => format!("the buds accepted {preset}, but their list no longer has it"), | |
| 368 | + | Some(p) if p.bands == wanted.bands => format!("the buds saved {preset}, but did not select it"), | |
| 369 | + | Some(_) => format!("the buds accepted {preset}, but still report the old bands"), | |
| 370 | + | }, | |
| 371 | + | )?; | |
| 372 | + | now.map(|p| p.preset()) | |
| 373 | + | .ok_or_else(|| Error::Protocol(format!("{preset} vanished from the list"))) | |
| 374 | + | } | |
| 375 | + | ||
| 322 | 376 | /// Ack-only: the T310 never answers the bass-level read. Spec: realme_t310.md#bass-write | |
| 323 | 377 | fn set_bass_level(&mut self, level: i8) -> Result<()> { | |
| 324 | 378 | let payload = codec::bass_write_payload(level)?; | |
| @@ -1098,4 +1152,64 @@ mod tests { | |||
| 1098 | 1152 | script.assert_done(); | |
| 1099 | 1153 | assert!(matches!(result, Err(Error::Usage(_)))); | |
| 1100 | 1154 | } | |
| 1155 | + | ||
| 1156 | + | #[test] | |
| 1157 | + | fn retunes_a_custom_preset_and_reads_it_back() { | |
| 1158 | + | let tuned = | |
| 1159 | + | "00 01 01 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 00"; | |
| 1160 | + | let mut steps = handshake(); | |
| 1161 | + | steps.extend([ | |
| 1162 | + | tx(0x0122, 0x05, ""), | |
| 1163 | + | rx(0x8122, 0x05, OWNER_CUSTOM_EQS), | |
| 1164 | + | tx( | |
| 1165 | + | 0x0418, | |
| 1166 | + | 0x06, | |
| 1167 | + | "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 00", | |
| 1168 | + | ), | |
| 1169 | + | rx(0x8418, 0x06, "00 04"), | |
| 1170 | + | tx(0x0122, 0x07, ""), | |
| 1171 | + | rx(0x8122, 0x07, tuned), | |
| 1172 | + | ]); | |
| 1173 | + | let script = Script::new(steps); | |
| 1174 | + | let now = open(&script) | |
| 1175 | + | .unwrap() | |
| 1176 | + | .set_custom_eq("custom-4", &[EqBand { hz: 50, gain: 3 }]) | |
| 1177 | + | .unwrap(); | |
| 1178 | + | script.assert_done(); | |
| 1179 | + | assert_eq!((now.name.as_str(), now.id), ("Custom1", 4)); | |
| 1180 | + | assert_eq!(now.bands.first(), Some(&EqBand { hz: 50, gain: 3 })); | |
| 1181 | + | } | |
| 1182 | + | ||
| 1183 | + | #[test] | |
| 1184 | + | fn eq_custom_selects_by_saving_the_preset_unchanged() { | |
| 1185 | + | let saved = "02 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"; | |
| 1186 | + | let mut steps = handshake(); | |
| 1187 | + | steps.extend([ | |
| 1188 | + | tx(0x0122, 0x05, ""), | |
| 1189 | + | rx(0x8122, 0x05, OWNER_CUSTOM_EQS), | |
| 1190 | + | tx(0x0418, 0x06, saved), | |
| 1191 | + | rx(0x8418, 0x06, "00 04"), | |
| 1192 | + | tx(0x0122, 0x07, ""), | |
| 1193 | + | rx(0x8122, 0x07, OWNER_CUSTOM_EQS), | |
| 1194 | + | ]); | |
| 1195 | + | let script = Script::new(steps); | |
| 1196 | + | let now = open(&script).unwrap().set_eq("custom-4").unwrap(); | |
| 1197 | + | script.assert_done(); | |
| 1198 | + | assert_eq!(now, "custom-4"); | |
| 1199 | + | } | |
| 1200 | + | ||
| 1201 | + | #[test] | |
| 1202 | + | fn a_custom_preset_the_buds_lack_is_never_written() { | |
| 1203 | + | let mut steps = handshake(); | |
| 1204 | + | steps.extend([tx(0x0122, 0x05, ""), rx(0x8122, 0x05, OWNER_CUSTOM_EQS)]); | |
| 1205 | + | let script = Script::new(steps); | |
| 1206 | + | let result = open(&script) | |
| 1207 | + | .unwrap() | |
| 1208 | + | .set_custom_eq("custom-5", &[EqBand { hz: 50, gain: 3 }]); | |
| 1209 | + | script.assert_done(); | |
| 1210 | + | match result { | |
| 1211 | + | Err(Error::Usage(message)) => assert!(message.ends_with("custom-4")), | |
| 1212 | + | other => panic!("expected a usage error, got {other:?}"), | |
| 1213 | + | } | |
| 1214 | + | } | |
| 1101 | 1215 | } | |
| @@ -496,7 +496,14 @@ Evidence: *vendor* | |||
| 496 | 496 | - **The lowest band is 50 Hz, not 62.** The vendor default above does not hold on the T310; use the bands the buds report. | |
| 497 | 497 | - **The preset was already there** when budctl first read it. Its origin is unknown: it may be the factory state, or left over from realme Link. | |
| 498 | 498 | - **It was in use:** `0x010F` answered `00 04` at the same time, so `custom-4` is a valid current EQ, and the list's `selected` byte (`01`) agrees. | |
| 499 | − | - **What budctl does** (W11, one step per checkpoint, no app capture): `budctl eq` with no preset reads `0x010F` and the list (W11a, read-only). A custom preset shows as `custom-<id>`, the name `0x010F` decoding uses, followed by its stored name and bands. Create, save and delete follow one at a time. | |
| 499 | + | - **What budctl does** (W11, one step per checkpoint, no app capture): | |
| 500 | + | - **W11a** (read-only): `budctl eq` with no preset reads `0x010F` and the list. A custom preset shows as `custom-<id>`, the name `0x010F` decoding uses, followed by its stored name and bands. | |
| 501 | + | - <a id="custom-eq-save"></a>**W11b, save** (`02`): `budctl eq custom-<id> <hz>=<gain>…` reads the list and copies the preset as listed: range bytes, id, name bytes, and the bands and their order. It changes only the gains of the named bands. A band the preset lacks, a band named twice, or a gain outside the preset's range is refused before anything is sent. `budctl eq custom-<id>` with no bands sends the preset unchanged, which only selects it. budctl never selects a custom preset with `0x0406`, since this spec does not say that works. | |
| 502 | + | - **Check:** read the list again, as for [read-back](#write-read-back). The preset must have the written bands and its `selected` byte set. | |
| 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 | + | - **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 | + | - **Not shown yet:** that `02` selects a preset that was not in use. `custom-4` was already the current EQ in every W11b run. | |
| 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. | |
| 500 | 507 | ||
| 501 | 508 | <a id="absent"></a> | |
| 502 | 509 | ## 11. Not on the T310 | |
| @@ -543,7 +550,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go | |||
| 543 | 550 | | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) | | |
| 544 | 551 | | W9 | `touch cycle` | | |
| 545 | 552 | | W10 | ~~`fit-test`~~: dropped, the T310 runs no test ([§10.8](#fit-test)) | | |
| 546 | − | | W11 | W11a: `eq` lists the custom presets (read-only); then create, save and delete, one checkpoint each | | |
| 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 | | |
| 547 | 554 | ||
| 548 | 555 | 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. | |
| 549 | 556 | ||
| @@ -552,6 +559,7 @@ The owner will not install realme Link (2026-10-27), so there is no app capture. | |||
| 552 | 559 | ||
| 553 | 560 | | Date | Checkpoint | Result | Notes | | |
| 554 | 561 | |---|---|---|---| | |
| 562 | + | | 2026-10-27 | W11b `eq custom-4`, `eq custom-4 50=+3 16000=-2`, then `50=0 16000=0` | pass (protocol) | The write matched the derived frame, was acked with `00 04` and read back on the first try. The preset is flat again. Selecting a custom preset that was not in use is untested. See [§10.9](#custom-eq-save) | | |
| 555 | 563 | | 2026-10-27 | W6b `bass -5`, `bass 5`, `bass 0` (bass boost off) | pass (protocol) | All acked with `0x841B 00`. No read-back is possible. By ear, at most a slight difference. See [§10.4](#bass-write) | | |
| 556 | 564 | | 2026-10-27 | W11a `eq` (custom list `0x0122`) | pass | One preset, "Custom1" (id `04`, 6 flat bands from 50 Hz), and it is the EQ in use. See [§10.9](#custom-eq) | | |
| 557 | 565 | | 2026-10-27 | W6a `status` with bass boost on (`0x0124`) | fail: no reply | Still no answer in 3 s. budctl does not send it. See [§9.7](#bass-read) | | |