Krolyxon
Commit 684a13f

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-27 20:53 +0530
Parent
00f061b
Commit
684a13f70f024b4d88907ef2bb060682c70b3a59

+336 −17

7 files changed

  1. M crates/budctl-cli/src/main.rs +31 −4
  2. M crates/budctl-cli/src/output.rs +24 −1
  3. M crates/budctl-cli/tests/cli.rs +14 −0
  4. M crates/budctl-core/src/devices/mod.rs +9 −1
  5. M crates/budctl-core/src/devices/realme_t310/codec.rs +132 −7
  6. M crates/budctl-core/src/devices/realme_t310/mod.rs +116 −2
  7. M docs/protocol/realme_t310.md +10 −2
M crates/budctl-cli/src/main.rs View
@@ -9,7 +9,7 @@ use std::io::{self, Write};
99 use std::process::ExitCode;
1010
1111 use budctl_core::bt::BdAddr;
12−use budctl_core::status::{AncLevel, NoiseMode};
12+use budctl_core::status::{AncLevel, EqBand, NoiseMode};
1313 use budctl_core::{Error, Kind};
1414 use clap::{Parser, Subcommand, ValueEnum};
1515
@@ -65,8 +65,16 @@ enum Command {
6565 #[arg(value_enum)]
6666 mode: AncArg,
6767 },
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+ },
7078 /// Turn an on/off setting on or off, then read it back. Switches: game, bass-boost, spatial (3D sound), wind, dual.
7179 Set {
7280 /// The switch's name, as `status --json` lists it.
@@ -260,12 +268,20 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
260268 let now = session.set_noise_control(mode.into())?;
261269 output::noise_control(out, &device, model, &now, cli.json)
262270 }
263− Command::Eq { preset: None } => {
271+ Command::Eq { preset: None, .. } => {
264272 let presets = session.eq_presets()?;
265273 output::eq_presets(out, &device, model, &presets, cli.json)
266274 }
267275 Command::Eq {
268276 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+ ..
269285 } => {
270286 let now = session.set_eq(preset)?;
271287 output::eq(out, &device, model, &now, cli.json)
@@ -290,6 +306,17 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
290306 }
291307 }
292308
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+
293320 /// Exit codes from CLAUDE.md: 1 usage, 2 not found, 3 busy, 4 rejected or timed out.
294321 fn exit_code(error: &Error) -> u8 {
295322 match error.kind() {
M crates/budctl-cli/src/output.rs View
@@ -4,7 +4,9 @@ use std::io::{self, Write};
44
55 use budctl_core::bluez::PairedDevice;
66 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+};
810 use budctl_core::{Error, Found};
911 use serde_json::Value;
1012
@@ -319,6 +321,27 @@ pub fn eq_presets(
319321 Ok(())
320322 }
321323
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+
322345 /// The EQ preset after a change, as the buds report it on reading back.
323346 pub fn eq(
324347 out: &mut impl Write,
M crates/budctl-cli/tests/cli.rs View
@@ -74,3 +74,17 @@ fn bass_needs_a_level_from_minus_5_to_5() {
7474 assert_eq!(out.status.code(), Some(1), "{args:?}");
7575 }
7676 }
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+}
M crates/budctl-core/src/devices/mod.rs View
@@ -6,7 +6,7 @@
66 use crate::bluez::PairedDevice;
77 use crate::bt::Uuid;
88 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};
1010 use crate::transport::Transport;
1111
1212 pub mod realme_t310;
@@ -62,6 +62,14 @@ pub trait Session: Send {
6262 ))
6363 }
6464
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+
6573 /// Set the bass level. Only the ack can be checked when the buds cannot report the level.
6674 fn set_bass_level(&mut self, _level: i8) -> Result<()> {
6775 Err(Error::Unsupported(
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -56,6 +56,10 @@ pub(super) const EQ_PRESET: u16 = 0x010F;
5656 pub(super) const CODEC: u16 = 0x0114;
5757 /// Spec: realme_t310.md#custom-eq
5858 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;
5963 /// Spec: realme_t310.md#events-offered
6064 pub(super) const EVENTS_OFFERED: u16 = 0x0200;
6165 /// Spec: realme_t310.md#subscribe
@@ -482,16 +486,40 @@ pub(super) fn builtin_eqs() -> Vec<EqPreset> {
482486 .collect()
483487 }
484488
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+
485513 /// `00 <count>`, then per preset `<selected> <min> <max> <id> <nameLen> <name> <bands>
486514 /// (<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>> {
488516 let mut r = ok_reply(reply, "the custom EQ list")?;
489517 let mut presets = Vec::new();
490518 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()?);
493521 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();
495523 let mut bands = Vec::new();
496524 for _ in 0..r.u8()? {
497525 bands.push(EqBand {
@@ -499,16 +527,74 @@ pub(super) fn decode_custom_eqs(reply: &[u8]) -> Result<Vec<EqPreset>> {
499527 gain: r.i8()?,
500528 });
501529 }
502− presets.push(EqPreset {
503− name,
530+ presets.push(CustomEq {
531+ selected,
532+ min,
533+ max,
504534 id,
505− custom: true,
535+ name,
506536 bands,
507537 });
508538 }
509539 Ok(presets)
510540 }
511541
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+
512598 /// One gesture-table entry: `<dev> <btn> <act> <fn>`. Spec: realme_t310.md#gestures
513599 pub(super) type GestureEntry = [u8; 4];
514600
@@ -978,4 +1064,43 @@ mod tests {
9781064 .unwrap();
9791065 assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 1B 04 05 03 00 FB 05 03");
9801066 }
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+ }
9811106 }
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -10,7 +10,8 @@ use crate::bt::Uuid;
1010 use crate::devices::{Device, Session};
1111 use crate::error::{Error, Kind, Result};
1212 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,
1415 };
1516 use crate::transport::Transport;
1617
@@ -295,9 +296,12 @@ impl Session for T310Session {
295296
296297 /// Built-in presets only; custom EQs come with W11. Spec: realme_t310.md#eq-write
297298 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+ }
298302 let Some(id) = codec::eq_id(preset) else {
299303 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",
301305 codec::eq_names().join(", ")
302306 )));
303307 };
@@ -319,6 +323,56 @@ impl Session for T310Session {
319323 )
320324 }
321325
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+
322376 /// Ack-only: the T310 never answers the bass-level read. Spec: realme_t310.md#bass-write
323377 fn set_bass_level(&mut self, level: i8) -> Result<()> {
324378 let payload = codec::bass_write_payload(level)?;
@@ -1098,4 +1152,64 @@ mod tests {
10981152 script.assert_done();
10991153 assert!(matches!(result, Err(Error::Usage(_))));
11001154 }
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+ }
11011215 }
M docs/protocol/realme_t310.md View
@@ -496,7 +496,14 @@ Evidence: *vendor*
496496 - **The lowest band is 50 Hz, not 62.** The vendor default above does not hold on the T310; use the bands the buds report.
497497 - **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.
498498 - **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.
500507
501508 <a id="absent"></a>
502509 ## 11. Not on the T310
@@ -543,7 +550,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go
543550 | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) |
544551 | W9 | `touch cycle` |
545552 | 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 |
547554
548555 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.
549556
@@ -552,6 +559,7 @@ The owner will not install realme Link (2026-10-27), so there is no app capture.
552559
553560 | Date | Checkpoint | Result | Notes |
554561 |---|---|---|---|
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) |
555563 | 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) |
556564 | 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) |
557565 | 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) |