Krolyxon
Commit 83c5894

Branches

Refs →

budctl83c5894

budctl touch cycle <mode> <mode> [<mode>] (W9)

This sets the noise-control modes a hold steps through with
0x0404 02 01 <mask> (bitmap bit 8), then reads the cycle back with
0x010C 02 01. The modes are off, transparency and anc: at least two and
none twice. The order given does not matter, because the mask has none.
Level bits are never written.

Verified on the owner's T310 (W9, 2026-10-27): masks 0B and 09 were
each acked within 60 ms and read back on the first try. A hold then
stepped through the new cycle.

This also records that the owner confirmed the W8 gesture change by ear.

No new dependencies.
Author
krolyxon
Date
2026-10-27 20:20 +0530
Parent
5e24422
Commit
83c5894c9bcd4fd6226e78bf5152cbe1e3a1aa40

+220 −8

7 files changed

  1. M crates/budctl-cli/src/main.rs +42 −1
  2. M crates/budctl-cli/src/output.rs +17 −0
  3. M crates/budctl-cli/tests/cli.rs +14 −0
  4. M crates/budctl-core/src/devices/mod.rs +8 −0
  5. M crates/budctl-core/src/devices/realme_t310/codec.rs +69 −4
  6. M crates/budctl-core/src/devices/realme_t310/mod.rs +61 −0
  7. M docs/protocol/realme_t310.md +9 −3
M crates/budctl-cli/src/main.rs View
@@ -43,8 +43,11 @@ enum Command {
4343 ///
4444 /// Gestures: double-tap, triple-tap, hold. Actions: none, play-pause, next, previous,
4545 /// voice-assistant, volume-up, volume-down, noise-cycle, game-mode (not every gesture takes
46− /// every action).
46+ /// every action). `budctl touch cycle …` sets what a hold steps through.
47+ #[command(args_conflicts_with_subcommands = true)]
4748 Touch {
49+ #[command(subcommand)]
50+ cycle: Option<TouchCommand>,
4851 /// The bud to change: left, right, or both.
4952 #[arg(value_enum, requires = "gesture")]
5053 side: Option<Side>,
@@ -92,6 +95,35 @@ enum FindAction {
9295 Stop,
9396 }
9497
98+#[derive(Subcommand)]
99+enum TouchCommand {
100+ /// Set the noise-control modes a hold steps through (both buds), then read it back.
101+ ///
102+ /// At least two modes. The buds step through them in their own order.
103+ Cycle {
104+ #[arg(value_enum, required = true, num_args = 2..=3, value_name = "MODE")]
105+ modes: Vec<CycleMode>,
106+ },
107+}
108+
109+#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
110+enum CycleMode {
111+ Off,
112+ Transparency,
113+ /// Noise cancelling.
114+ Anc,
115+}
116+
117+impl CycleMode {
118+ fn name(self) -> &'static str {
119+ match self {
120+ CycleMode::Off => "off",
121+ CycleMode::Transparency => "transparency",
122+ CycleMode::Anc => "anc",
123+ }
124+ }
125+}
126+
95127 #[derive(Clone, Copy, ValueEnum)]
96128 enum Side {
97129 Left,
@@ -196,11 +228,20 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
196228 let status = session.status()?;
197229 output::status(out, &device, model, &status, cli.json)
198230 }
231+ Command::Touch {
232+ cycle: Some(TouchCommand::Cycle { ref modes }),
233+ ..
234+ } => {
235+ let names: Vec<&str> = modes.iter().map(|m| m.name()).collect();
236+ let now = session.set_hold_cycle(&names)?;
237+ output::hold_cycle(out, &device, model, &now, cli.json)
238+ }
199239 Command::Touch {
200240 side: Some(side),
201241 gesture: Some(ref gesture),
202242 action: Some(ref action),
203243 dry_run,
244+ ..
204245 } => {
205246 let change = session.set_touch(side.name(), gesture, action, dry_run)?;
206247 output::touch_change(out, &device, model, &change, cli.json)
M crates/budctl-cli/src/output.rs View
@@ -186,6 +186,23 @@ pub fn touch(
186186 Ok(())
187187 }
188188
189+/// The hold cycle after a change, as the buds report it on reading back. JSON uses the same
190+/// `hold_cycle` list as `touch`.
191+pub fn hold_cycle(
192+ out: &mut impl Write,
193+ device: &PairedDevice,
194+ model: &dyn Device,
195+ now: &[String],
196+ as_json: bool,
197+) -> Result<(), Failure> {
198+ if as_json {
199+ return json(out, Some((device, model)), serde_json::json!({ "hold_cycle": now }));
200+ }
201+ header(out, device, model)?;
202+ writeln!(out, " Hold cycles through: {}", now.join(" → "))?;
203+ Ok(())
204+}
205+
189206 /// A touch-control change: what changed (or would change, on a dry run) and the write.
190207 pub fn touch_change(
191208 out: &mut impl Write,
M crates/budctl-cli/tests/cli.rs View
@@ -45,3 +45,17 @@ fn touch_changes_need_side_gesture_and_action() {
4545 assert_eq!(out.status.code(), Some(1), "{args:?}");
4646 }
4747 }
48+
49+#[test]
50+fn the_hold_cycle_needs_two_or_three_known_modes() {
51+ for args in [
52+ &["touch", "cycle"][..],
53+ &["touch", "cycle", "anc"],
54+ &["touch", "cycle", "anc", "deep"],
55+ &["touch", "cycle", "off", "transparency", "anc", "off"],
56+ &["touch", "cycle", "anc", "off", "--dry-run"],
57+ ] {
58+ let out = budctl(args);
59+ assert_eq!(out.status.code(), Some(1), "{args:?}");
60+ }
61+}
M crates/budctl-core/src/devices/mod.rs View
@@ -66,6 +66,14 @@ pub trait Session: Send {
6666 Err(Error::Unsupported(format!("budctl cannot change {key} on these buds")))
6767 }
6868
69+ /// Set the noise-control modes a hold steps through (`off`, `transparency`, `anc`), then
70+ /// read it back. Returns the modes the buds now report.
71+ fn set_hold_cycle(&mut self, _modes: &[&str]) -> Result<Vec<String>> {
72+ Err(Error::Unsupported(
73+ "budctl cannot change the hold cycle on these buds".to_owned(),
74+ ))
75+ }
76+
6977 /// Give a touch gesture a new action, on `side` (`left`, `right` or `both`), then read the
7078 /// table back. With `dry_run`, read the table and work out the write, but send nothing.
7179 fn set_touch(&mut self, _side: &str, _gesture: &str, _action: &str, _dry_run: bool) -> Result<TouchChange> {
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -324,12 +324,18 @@ pub(super) fn noise_write_mask(mode: NoiseMode) -> u32 {
324324 /// Spec: realme_t310.md#anc-write
325325 pub(super) fn noise_write_payload(mode: NoiseMode) -> Vec<u8> {
326326 let mut payload = NOISE_MODE_QUERY.to_vec();
327− let mask = noise_write_mask(mode).to_le_bytes();
328− let used = mask.iter().rposition(|&b| b != 0).map_or(1, |last| last + 1);
329− payload.extend(mask.iter().take(used));
327+ payload.extend(fewest_bytes(noise_write_mask(mode)));
330328 payload
331329 }
332330
331+/// A mask in the fewest little-endian bytes that hold it, at least one.
332+/// Spec: realme_t310.md#anc-write, #hold-cycle-write
333+fn fewest_bytes(mask: u32) -> Vec<u8> {
334+ let bytes = mask.to_le_bytes();
335+ let used = bytes.iter().rposition(|&b| b != 0).map_or(1, |last| last + 1);
336+ bytes.iter().take(used).copied().collect()
337+}
338+
333339 /// The mask in a noise-control read reply. Spec: realme_t310.md#anc-read
334340 pub(super) fn decode_noise_mask(reply: &[u8]) -> Result<u32> {
335341 noise_mask(reply, NOISE_MODE_QUERY, "the noise-control reply")
@@ -342,7 +348,7 @@ pub(super) fn check_write_reply(reply: &[u8], what: &'static str) -> Result<()>
342348
343349 /// Spec: realme_t310.md#hold-cycle
344350 pub(super) fn decode_hold_cycle(reply: &[u8]) -> Result<Vec<String>> {
345− let mask = noise_mask(reply, HOLD_CYCLE_QUERY, "the hold-cycle reply")?;
351+ let mask = decode_hold_cycle_mask(reply)?;
346352 let mut modes: Vec<String> = CYCLE_MODES
347353 .iter()
348354 .filter(|(bit, _)| mask & bit != 0)
@@ -355,6 +361,42 @@ pub(super) fn decode_hold_cycle(reply: &[u8]) -> Result<Vec<String>> {
355361 Ok(modes)
356362 }
357363
364+/// The mask for a hold cycle through `modes` (`off`, `transparency`, `anc`): at least two,
365+/// none twice. The buds keep their own order, so the order given does not matter.
366+/// Spec: realme_t310.md#hold-cycle-write
367+pub(super) fn hold_cycle_mask(modes: &[&str]) -> Result<u32> {
368+ let mut mask = 0;
369+ for &mode in modes {
370+ let Some(&(bit, _)) = CYCLE_MODES.iter().find(|(_, name)| *name == mode) else {
371+ let names: Vec<&str> = CYCLE_MODES.iter().map(|(_, name)| *name).collect();
372+ return Err(Error::Usage(format!(
373+ "no mode called {mode} in the hold cycle; use: {}",
374+ names.join(", ")
375+ )));
376+ };
377+ if mask & bit != 0 {
378+ return Err(Error::Usage(format!("{mode} is in the hold cycle twice")));
379+ }
380+ mask |= bit;
381+ }
382+ if mask.count_ones() < 2 {
383+ return Err(Error::Usage("the hold cycle needs at least two modes".to_owned()));
384+ }
385+ Ok(mask)
386+}
387+
388+/// `02 01 <mask>`, the mask in the fewest bytes. Spec: realme_t310.md#hold-cycle-write
389+pub(super) fn hold_cycle_write_payload(mask: u32) -> Vec<u8> {
390+ let mut payload = HOLD_CYCLE_QUERY.to_vec();
391+ payload.extend(fewest_bytes(mask));
392+ payload
393+}
394+
395+/// The mask in a hold-cycle read reply. Spec: realme_t310.md#hold-cycle
396+pub(super) fn decode_hold_cycle_mask(reply: &[u8]) -> Result<u32> {
397+ noise_mask(reply, HOLD_CYCLE_QUERY, "the hold-cycle reply")
398+}
399+
358400 /// `00 <echo of the query> <mask, 1-4 bytes LE>`. Spec: realme_t310.md#anc-read
359401 fn noise_mask(reply: &[u8], query: [u8; 2], what: &'static str) -> Result<u32> {
360402 let mut r = ok_reply(reply, what)?;
@@ -837,4 +879,27 @@ mod tests {
837879 let left = touch_target("left", "double-tap", "none").unwrap();
838880 assert!(matches!(retarget_gestures(&table, &left), Err(Error::Unsupported(_))));
839881 }
882+
883+ #[test]
884+ fn hold_cycle_writes() {
885+ assert_eq!(hold_cycle_mask(&["transparency", "anc"]).unwrap(), 0x0A);
886+ assert_eq!(hold_cycle_mask(&["anc", "off", "transparency"]).unwrap(), 0x0B);
887+ assert_eq!(hold_cycle_write_payload(0x0B), hex("02 01 0B"));
888+ assert_eq!(hold_cycle_write_payload(0x09), hex("02 01 09"));
889+ let usage = |modes: &[&str]| matches!(hold_cycle_mask(modes), Err(Error::Usage(_)));
890+ assert!(usage(&["anc"]));
891+ assert!(usage(&[]));
892+ assert!(usage(&["anc", "anc"]));
893+ assert!(usage(&["anc", "deep"]));
894+ // What is written reads back as the same modes.
895+ let mut reply = hex("00");
896+ reply.extend(hold_cycle_write_payload(0x0B));
897+ assert_eq!(decode_hold_cycle_mask(&reply).unwrap(), 0x0B);
898+ assert_eq!(decode_hold_cycle(&reply).unwrap(), ["off", "transparency", "anc"]);
899+ // The frame the spec gives (realme_t310.md#hold-cycle-write).
900+ let frame =
901+ crate::devices::realme_t310::frame::encode(NOISE_CONTROL_WRITE, 0x06, &hold_cycle_write_payload(0x0B))
902+ .unwrap();
903+ assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 04 04 06 03 00 02 01 0B");
904+ }
840905 }
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -366,6 +366,39 @@ impl Session for T310Session {
366366 })
367367 }
368368
369+ /// Spec: realme_t310.md#hold-cycle-write, #write-read-back
370+ fn set_hold_cycle(&mut self, modes: &[&str]) -> Result<Vec<String>> {
371+ let mask = codec::hold_cycle_mask(modes)?;
372+ if !self.commands.allows(codec::NOISE_CONTROL_WRITE) || !self.commands.allows(codec::NOISE_CONTROL) {
373+ return Err(Error::Unsupported(
374+ "these buds do not offer noise-control settings".to_owned(),
375+ ));
376+ }
377+ let reply = self
378+ .link
379+ .request(codec::NOISE_CONTROL_WRITE, &codec::hold_cycle_write_payload(mask))?;
380+ codec::check_write_reply(&reply, "the hold-cycle change")?;
381+
382+ self.read_back(
383+ READ_BACK,
384+ codec::NOISE_CONTROL,
385+ &codec::HOLD_CYCLE_QUERY,
386+ |reply| {
387+ Ok((
388+ codec::decode_hold_cycle_mask(reply)? == mask,
389+ codec::decode_hold_cycle(reply)?,
390+ ))
391+ },
392+ |now| {
393+ format!(
394+ "the buds accepted the hold cycle {}, but still report {}",
395+ modes.join(" → "),
396+ now.join(" → ")
397+ )
398+ },
399+ )
400+ }
401+
369402 /// Spec: realme_t310.md#gesture-write, #gesture-choices
370403 fn set_touch(&mut self, side: &str, gesture: &str, action: &str, dry_run: bool) -> Result<TouchChange> {
371404 let target = codec::touch_target(side, gesture, action)?;
@@ -952,4 +985,32 @@ mod tests {
952985 script.assert_done();
953986 assert!(matches!(result, Err(Error::Usage(_))));
954987 }
988+
989+ #[test]
990+ fn sets_the_hold_cycle_and_reads_it_back() {
991+ let mut steps = handshake();
992+ steps.extend([
993+ tx(0x0404, 0x05, "02 01 0B"),
994+ rx(0x8404, 0x05, "00"),
995+ tx(0x010C, 0x06, "02 01"),
996+ rx(0x810C, 0x06, "00 02 01 0B"),
997+ ]);
998+ let script = Script::new(steps);
999+ let now = open(&script)
1000+ .unwrap()
1001+ .set_hold_cycle(&["off", "transparency", "anc"])
1002+ .unwrap();
1003+ script.assert_done();
1004+ assert_eq!(now, ["off", "transparency", "anc"]);
1005+ }
1006+
1007+ #[test]
1008+ fn a_one_mode_hold_cycle_is_refused_before_anything_is_sent() {
1009+ let mut steps = handshake();
1010+ steps.pop();
1011+ let script = Script::new(steps);
1012+ let result = open(&script).unwrap().set_hold_cycle(&["anc"]);
1013+ script.assert_done();
1014+ assert!(matches!(result, Err(Error::Usage(_))));
1015+ }
9551016 }
M docs/protocol/realme_t310.md View
@@ -361,7 +361,7 @@ Evidence: *vendor*
361361 <a id="writes"></a>
362362 ## 10. Writes
363363
364−**One at a time.** budctl adds a write only after the previous one passes its hardware checkpoint (see the [roadmap](#roadmap)). Sent so far: [§10.1](#anc-write) off/transparency/on (W1, verified *ours-2026-10-27*), the light/medium/deep levels (W2, verified *ours-2026-10-27*), and [§10.2](#switch-write) game mode (W3, verified *ours-2026-10-27*), and [§10.3](#eq-write) the built-in EQ presets (W4, verified *ours-2026-10-27*), the bass-boost switch `1D` (W5a, verified *ours-2026-10-27*), 3D sound `1B` (W5b, verified *ours-2026-10-27*), wind-noise reduction `1A` (W5c, verified *ours-2026-10-27*), and dual connection `11` (W5d, verified *ours-2026-10-27*); [§10.5](#find) find (W7, verified *ours-2026-10-27*); [§10.6](#gesture-write) touch controls (W8, verified *ours-2026-10-27*). Bass level (W6) waits for a realme Link capture. budctl reads it back at once, because the spec gives it no special timing. The others are not sent yet.
364+**One at a time.** budctl adds a write only after the previous one passes its hardware checkpoint (see the [roadmap](#roadmap)). Sent so far: [§10.1](#anc-write) off/transparency/on (W1, verified *ours-2026-10-27*), the light/medium/deep levels (W2, verified *ours-2026-10-27*), and [§10.2](#switch-write) game mode (W3, verified *ours-2026-10-27*), and [§10.3](#eq-write) the built-in EQ presets (W4, verified *ours-2026-10-27*), the bass-boost switch `1D` (W5a, verified *ours-2026-10-27*), 3D sound `1B` (W5b, verified *ours-2026-10-27*), wind-noise reduction `1A` (W5c, verified *ours-2026-10-27*), and dual connection `11` (W5d, verified *ours-2026-10-27*); [§10.5](#find) find (W7, verified *ours-2026-10-27*); [§10.6](#gesture-write) touch controls (W8, verified *ours-2026-10-27*); [§10.7](#hold-cycle-write) the hold cycle (W9, verified *ours-2026-10-27*). Bass level (W6) waits for a realme Link capture. budctl reads it back at once, because the spec gives it no special timing. The others are not sent yet.
365365
366366 **General rule**:
367367 - A write reply is `0x84xx <status …>`, and `00` means accepted.
@@ -438,7 +438,7 @@ The command and layout are *buds4*; the realme rules are *vendor*.
438438 - If the entry already has the action, it sends nothing.
439439 - `--dry-run` reads the table and prints the payload it would send, then stops.
440440 - After the ack it reads the table back 600 ms later, then every 300 ms until 2 s after the ack ([read-back](#write-read-back)).
441−- **On the owner's T310** (*ours-2026-10-27*, W8): `touch left triple-tap volume-up` sent exactly the expected frame below. The ack came within 25 ms, and the first read-back (600 ms after the ack) already showed `fn 0B`. A second write, `volume-up` → `previous` (`fn 05`), behaved the same way. No push came. Not yet confirmed by ear: that the new triple tap really changes the volume or track.
441+- **On the owner's T310** (*ours-2026-10-27*, W8): `touch left triple-tap volume-up` sent exactly the expected frame below. The ack came within 25 ms, and the first read-back (600 ms after the ack) already showed `fn 0B`. A second write, `volume-up` → `previous` (`fn 05`), behaved the same way. No push came. The owner confirmed that the new triple tap works.
442442 - **Expected frame** (*derived*, matched on the wire at W8), for `touch left triple-tap volume-up` on the owner's HW1 table, with seq `06`: `AA 20 00 00 01 04 06 19 00 06 01 01 02 01 01 01 03 0B 01 01 04 08 02 01 02 01 02 01 03 06 02 01 04 08`.
443443
444444 <a id="hold-cycle-write"></a>
@@ -449,6 +449,11 @@ Evidence: *vendor*
449449 - **Mask:** the fewest bytes that hold it, and at least 2 modes (*vendor*).
450450 - **Level bits.** The buds silently drop level bits (*buds4*).
451451 - **Check:** read `0x010C 02 01`.
452+- **Reply:** `0x8404 <status>`, as for [§10.1](#anc-write).
453+- **What budctl does** (budctl's own policy): `budctl touch cycle <mode> <mode> [<mode>]` accepts `off` (`01`), `transparency` (`02`) and `anc` (`08`), at least two of them and none twice, in any order, because the mask has no order. It never sets the level bits `04` and `10`. It reads the cycle back as for [§10.1](#write-read-back).
454+- **Expected frame** (*derived*, matched on the wire at W9 apart from the seq byte), for all three modes with seq `06`: `AA 0A 00 00 04 04 06 03 00 02 01 0B`.
455+- **On the owner's T310** (*ours-2026-10-27*, W9): `02 01 0B` (off, transparency, noise cancelling) and `02 01 09` (off, noise cancelling) were each acked with `0x8404 00` within 60 ms. The first read-back already showed the new mask. The owner confirmed that a hold steps through the new cycle. A cycle that includes `off` is accepted.
456+- **On the owner's buds** the cycle is `0A` (transparency and noise cancelling, HW1), and every hold gesture is `fn 08`, so the cycle applies to both buds.
452457
453458 <a id="fit-test"></a>
454459 ### 10.8 Fit test: `0x0405 01` / `0x0405 00`
@@ -526,7 +531,8 @@ Ask for an Android HCI snoop of realme Link before W11, and for any write that d
526531
527532 | Date | Checkpoint | Result | Notes |
528533 |---|---|---|---|
529−| 2026-10-27 | W8 `touch left triple-tap volume-up` (`--dry-run`, then sent), then `previous` | pass (protocol) | The dry run's payload matched the derived frame. Both writes were acked and read back on the first try, 600 ms after the ack. The ack echoes the old table, cut short. Not yet confirmed by ear. The left triple tap is now `previous`. See [§10.6](#gesture-write) |
534+| 2026-10-27 | W9 `touch cycle off transparency anc`, then `off anc` | pass | Masks `0B` and `09` were acked and read back on the first try. The owner confirmed the new cycle with the hold gesture. The cycle is now off → anc. See [§10.7](#hold-cycle-write) |
535+| 2026-10-27 | W8 `touch left triple-tap volume-up` (`--dry-run`, then sent), then `previous` | pass | The dry run's payload matched the derived frame. Both writes were acked and read back on the first try, 600 ms after the ack. The ack echoes the old table, cut short. The owner confirmed that the new gesture works. The left triple tap is now `previous`. See [§10.6](#gesture-write) |
530536 | 2026-10-27 | W7 `find start --yes`, `find stop` | pass | Both acked. Both buds rang, kept ringing after budctl disconnected, and stopped on `find stop`. See [§10.5](#find) |
531537 | 2026-10-27 | W5d `set dual on`, `off` | pass | Acked, the immediate read-back showed the change, and the laptop stayed connected. Only one host was connected. See [§10.2](#switch-write) |
532538 | 2026-10-27 | W5c `set wind off`, `on` | pass (protocol) | Acked, and the immediate read-back showed the change. Not audible in music. See [§10.2](#switch-write) |