Krolyxon
Commit 5e24422

Branches

Refs →

budctl5e24422

budctl touch <side> <gesture> <action> [--dry-run] (W8)

This gives one touch gesture a new action. It reads the gesture table
(0x0108), changes only the fn byte of the targeted entries and writes
the whole table back with 0x0401, gated by bitmap bit 3. It then reads
the table back 600 ms after the ack. Plain `budctl touch` still only
reads the table.

- Only the choices realme Link offers per gesture are accepted. Anything
  else is a usage error before anything is sent.
- Entries the buds did not report are never added.
- `both` changes the left and the right bud in one write.
- Nothing is sent when the gesture already has the action.
- --dry-run reads the table and prints the payload without sending.

Verified on the owner's T310 (W8, 2026-10-27): the dry run matched the
derived frame. `left triple-tap volume-up`, then `previous`, were each
acked within 25 ms and read back on the first try. The ack carries 00
followed by the old table, cut to the write's length.

No new dependencies.
Author
krolyxon
Date
2026-10-27 20:13 +0530
Parent
7a29fd2
Commit
5e244220002c5ed0508db2765972abe1a1e3b345

+515 −19

8 files changed

  1. M crates/budctl-cli/src/main.rs +46 −3
  2. M crates/budctl-cli/src/output.rs +32 −1
  3. M crates/budctl-cli/tests/cli.rs +13 −0
  4. M crates/budctl-core/src/devices/mod.rs +9 −1
  5. M crates/budctl-core/src/devices/realme_t310/codec.rs +184 −10
  6. M crates/budctl-core/src/devices/realme_t310/mod.rs +176 −1
  7. M crates/budctl-core/src/status.rs +42 −0
  8. M docs/protocol/realme_t310.md +13 −3
M crates/budctl-cli/src/main.rs View
@@ -39,8 +39,24 @@ enum Command {
3939 List,
4040 /// Battery, noise control, EQ and switches.
4141 Status,
42− /// Touch controls (gesture table) of each bud.
43− Touch,
42+ /// Touch controls (gesture table) of each bud, or give one gesture a new action.
43+ ///
44+ /// Gestures: double-tap, triple-tap, hold. Actions: none, play-pause, next, previous,
45+ /// voice-assistant, volume-up, volume-down, noise-cycle, game-mode (not every gesture takes
46+ /// every action).
47+ Touch {
48+ /// The bud to change: left, right, or both.
49+ #[arg(value_enum, requires = "gesture")]
50+ side: Option<Side>,
51+ /// The gesture to change.
52+ #[arg(requires = "action")]
53+ gesture: Option<String>,
54+ /// Its new action.
55+ action: Option<String>,
56+ /// Read the table and show the write, but send nothing.
57+ #[arg(long, requires = "side")]
58+ dry_run: bool,
59+ },
4460 /// Set noise control, then read it back.
4561 Anc {
4662 #[arg(value_enum)]
@@ -76,6 +92,24 @@ enum FindAction {
7692 Stop,
7793 }
7894
95+#[derive(Clone, Copy, ValueEnum)]
96+enum Side {
97+ Left,
98+ Right,
99+ /// The left and the right bud.
100+ Both,
101+}
102+
103+impl Side {
104+ fn name(self) -> &'static str {
105+ match self {
106+ Side::Left => "left",
107+ Side::Right => "right",
108+ Side::Both => "both",
109+ }
110+ }
111+}
112+
79113 #[derive(Clone, Copy, ValueEnum)]
80114 enum OnOff {
81115 On,
@@ -162,7 +196,16 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
162196 let status = session.status()?;
163197 output::status(out, &device, model, &status, cli.json)
164198 }
165− Command::Touch => {
199+ Command::Touch {
200+ side: Some(side),
201+ gesture: Some(ref gesture),
202+ action: Some(ref action),
203+ dry_run,
204+ } => {
205+ let change = session.set_touch(side.name(), gesture, action, dry_run)?;
206+ output::touch_change(out, &device, model, &change, cli.json)
207+ }
208+ Command::Touch { .. } => {
166209 let touch = session.touch_controls()?;
167210 output::touch(out, &device, model, &touch, cli.json)
168211 }
M crates/budctl-cli/src/output.rs View
@@ -4,7 +4,7 @@ use std::io::{self, Write};
44
55 use budctl_core::bluez::PairedDevice;
66 use budctl_core::devices::Device;
7−use budctl_core::status::{Charge, JSON_SCHEMA, NoiseControl, Status, Switch, TouchControls};
7+use budctl_core::status::{Charge, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls};
88 use budctl_core::{Error, Found};
99 use serde_json::Value;
1010
@@ -186,6 +186,37 @@ pub fn touch(
186186 Ok(())
187187 }
188188
189+/// A touch-control change: what changed (or would change, on a dry run) and the write.
190+pub fn touch_change(
191+ out: &mut impl Write,
192+ device: &PairedDevice,
193+ model: &dyn Device,
194+ change: &TouchChange,
195+ as_json: bool,
196+) -> Result<(), Failure> {
197+ if as_json {
198+ return json(out, Some((device, model)), serde_json::to_value(change)?);
199+ }
200+ header(out, device, model)?;
201+ if change.changes.is_empty() {
202+ writeln!(out, " Nothing to change: the buds already have that action.")?;
203+ return Ok(());
204+ }
205+ for c in &change.changes {
206+ writeln!(out, " {:<6} {:<12} {} → {}", c.side, c.gesture, c.from, c.to)?;
207+ }
208+ if let (false, Some(write)) = (change.sent, &change.write) {
209+ writeln!(out, " Dry run, nothing sent. The write would be:")?;
210+ writeln!(
211+ out,
212+ " 0x{:04X} {}",
213+ write.command,
214+ budctl_core::debug::hex(&write.payload)
215+ )?;
216+ }
217+ Ok(())
218+}
219+
189220 /// The noise-control mode after a change, as the buds report it on reading back.
190221 pub fn noise_control(
191222 out: &mut impl Write,
M crates/budctl-cli/tests/cli.rs View
@@ -32,3 +32,16 @@ fn find_start_needs_yes_before_connecting() {
3232 assert_eq!(out.status.code(), Some(1));
3333 assert!(String::from_utf8_lossy(&out.stderr).contains("--yes"));
3434 }
35+
36+#[test]
37+fn touch_changes_need_side_gesture_and_action() {
38+ for args in [
39+ &["touch", "left"][..],
40+ &["touch", "left", "hold"],
41+ &["touch", "--dry-run"],
42+ &["touch", "middle", "hold", "none"],
43+ ] {
44+ let out = budctl(args);
45+ assert_eq!(out.status.code(), Some(1), "{args:?}");
46+ }
47+}
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::{NoiseControl, NoiseMode, Status, Switch, TouchControls};
9+use crate::status::{NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls};
1010 use crate::transport::Transport;
1111
1212 pub mod realme_t310;
@@ -65,6 +65,14 @@ pub trait Session: Send {
6565 fn set_switch(&mut self, key: &str, _on: bool) -> Result<Switch> {
6666 Err(Error::Unsupported(format!("budctl cannot change {key} on these buds")))
6767 }
68+
69+ /// Give a touch gesture a new action, on `side` (`left`, `right` or `both`), then read the
70+ /// table back. With `dry_run`, read the table and work out the write, but send nothing.
71+ fn set_touch(&mut self, _side: &str, _gesture: &str, _action: &str, _dry_run: bool) -> Result<TouchChange> {
72+ Err(Error::Unsupported(
73+ "budctl cannot change the touch controls on these buds".to_owned(),
74+ ))
75+ }
6876 }
6977
7078 /// The supported model a paired device is, if any.
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -27,6 +27,8 @@ pub(super) const SWITCH_WRITE: u16 = 0x0403;
2727 pub(super) const EQ_SELECT: u16 = 0x0406;
2828 /// Spec: realme_t310.md#find
2929 pub(super) const FIND: u16 = 0x0400;
30+/// Spec: realme_t310.md#gesture-write
31+pub(super) const GESTURE_WRITE: u16 = 0x0401;
3032
3133 /// `01` starts ringing, `00` stops it. Spec: realme_t310.md#find
3234 pub(super) fn find_payload(ring: bool) -> [u8; 1] {
@@ -421,20 +423,128 @@ pub(super) fn decode_bass_level(reply: &[u8]) -> Result<i8> {
421423 Ok(level)
422424 }
423425
426+/// One gesture-table entry: `<dev> <btn> <act> <fn>`. Spec: realme_t310.md#gestures
427+pub(super) type GestureEntry = [u8; 4];
428+
429+/// The entries of a gesture-table reply, in the order the buds sent them.
424430 /// Spec: realme_t310.md#gestures
425−pub(super) fn decode_gestures(reply: &[u8]) -> Result<Vec<Gesture>> {
431+pub(super) fn decode_gesture_entries(reply: &[u8]) -> Result<Vec<GestureEntry>> {
426432 let mut r = ok_reply(reply, "the gesture table")?;
427− let mut gestures = Vec::new();
433+ let mut entries = Vec::new();
428434 for _ in 0..r.u8()? {
429− let [side, group, act, function] = [r.u8()?, r.u8()?, r.u8()?, r.u8()?];
430− gestures.push(Gesture {
431− side: name_of(&SIDES, side).map_or_else(|| format!("device-0x{side:02X}"), str::to_owned),
432− gesture: name_of(&ACTS, act).map_or_else(|| format!("act-0x{act:02X}"), str::to_owned),
433− action: name_of(&FUNCTIONS, function).map_or_else(|| format!("fn-0x{function:02X}"), str::to_owned),
434− group: (group != MAIN_GROUP).then_some(group),
435− });
435+ entries.push([r.u8()?, r.u8()?, r.u8()?, r.u8()?]);
436+ }
437+ Ok(entries)
438+}
439+
440+/// Spec: realme_t310.md#gestures
441+pub(super) fn name_gesture(&[side, group, act, function]: &GestureEntry) -> Gesture {
442+ Gesture {
443+ side: name_of(&SIDES, side).map_or_else(|| format!("device-0x{side:02X}"), str::to_owned),
444+ gesture: name_of(&ACTS, act).map_or_else(|| format!("act-0x{act:02X}"), str::to_owned),
445+ action: name_of(&FUNCTIONS, function).map_or_else(|| format!("fn-0x{function:02X}"), str::to_owned),
446+ group: (group != MAIN_GROUP).then_some(group),
436447 }
437− Ok(gestures)
448+}
449+
450+/// Spec: realme_t310.md#gestures
451+pub(super) fn decode_gestures(reply: &[u8]) -> Result<Vec<Gesture>> {
452+ Ok(decode_gesture_entries(reply)?.iter().map(name_gesture).collect())
453+}
454+
455+/// What `touch <side>` changes: `both` is the left and the right bud's entry.
456+/// Spec: realme_t310.md#gesture-choices
457+const TOUCH_SIDES: [(&str, &[u8]); 3] = [("left", &[0x01]), ("right", &[0x02]), ("both", &[0x01, 0x02])];
458+
459+/// The actions realme Link offers per gesture on the T310, in its order. budctl offers no
460+/// others. Spec: realme_t310.md#gesture-choices
461+const TOUCH_CHOICES: [(u8, &[u8]); 3] = [
462+ // double tap: none, play/pause, next, voice assistant
463+ (0x02, &[0x00, 0x01, 0x06, 0x03]),
464+ // triple tap: none, previous, next, voice assistant, volume up, volume down, game mode
465+ (0x03, &[0x00, 0x05, 0x06, 0x03, 0x0B, 0x0C, 0x11]),
466+ // hold: none, noise-control cycle, voice assistant, game mode
467+ (0x04, &[0x00, 0x08, 0x03, 0x11]),
468+];
469+
470+/// A checked `touch <side> <gesture> <action>`: the bud ids, the act and the fn to write.
471+#[derive(Debug, PartialEq, Eq)]
472+pub(super) struct TouchTarget {
473+ pub(super) devices: &'static [u8],
474+ pub(super) act: u8,
475+ pub(super) function: u8,
476+}
477+
478+/// Check the names against what the T310 offers. Every failure is a usage error that lists
479+/// the valid names. Spec: realme_t310.md#gesture-choices
480+pub(super) fn touch_target(side: &str, gesture: &str, action: &str) -> Result<TouchTarget> {
481+ let list = |names: Vec<&str>| names.join(", ");
482+ let Some(&(_, devices)) = TOUCH_SIDES.iter().find(|(name, _)| *name == side) else {
483+ return Err(Error::Usage(format!(
484+ "no side called {side}; use one of: {}",
485+ list(TOUCH_SIDES.iter().map(|(name, _)| *name).collect())
486+ )));
487+ };
488+ let gesture_names = || {
489+ list(
490+ TOUCH_CHOICES
491+ .iter()
492+ .filter_map(|(act, _)| name_of(&ACTS, *act))
493+ .collect(),
494+ )
495+ };
496+ let Some(&(act, choices)) = TOUCH_CHOICES
497+ .iter()
498+ .find(|(act, _)| name_of(&ACTS, *act) == Some(gesture))
499+ else {
500+ return Err(Error::Usage(format!(
501+ "these buds have no {gesture} gesture to set; use one of: {}",
502+ gesture_names()
503+ )));
504+ };
505+ let Some(&function) = choices.iter().find(|f| name_of(&FUNCTIONS, **f) == Some(action)) else {
506+ return Err(Error::Usage(format!(
507+ "{gesture} cannot be set to {action} on these buds; use one of: {}",
508+ list(choices.iter().filter_map(|f| name_of(&FUNCTIONS, *f)).collect())
509+ )));
510+ };
511+ Ok(TouchTarget { devices, act, function })
512+}
513+
514+/// The table with the target's entries changed, and nothing else. Every targeted entry must
515+/// already be in the table: budctl never adds entries. Spec: realme_t310.md#gesture-write
516+pub(super) fn retarget_gestures(table: &[GestureEntry], target: &TouchTarget) -> Result<Vec<GestureEntry>> {
517+ for &device in target.devices {
518+ if !table
519+ .iter()
520+ .any(|&[d, group, act, _]| d == device && group == MAIN_GROUP && act == target.act)
521+ {
522+ let entry = name_gesture(&[device, MAIN_GROUP, target.act, 0]);
523+ return Err(Error::Unsupported(format!(
524+ "the buds report no {} {} entry, so budctl will not write one",
525+ entry.side, entry.gesture
526+ )));
527+ }
528+ }
529+ Ok(table
530+ .iter()
531+ .map(|&[device, group, act, function]| {
532+ if target.devices.contains(&device) && group == MAIN_GROUP && act == target.act {
533+ [device, group, act, target.function]
534+ } else {
535+ [device, group, act, function]
536+ }
537+ })
538+ .collect())
539+}
540+
541+/// `<count> (<dev> <btn> <act> <fn>)…`: the whole table. Spec: realme_t310.md#gesture-write
542+pub(super) fn gesture_write_payload(table: &[GestureEntry]) -> Result<Vec<u8>> {
543+ let count = u8::try_from(table.len())
544+ .map_err(|_| Error::Protocol(format!("a gesture table of {} entries does not fit", table.len())))?;
545+ let mut payload = vec![count];
546+ payload.extend(table.iter().flatten());
547+ Ok(payload)
438548 }
439549
440550 /// The handshake reply of the owner's T310 (HW1). Spec: realme_t310.md#bitmap-t310
@@ -663,4 +773,68 @@ mod tests {
663773 assert_eq!(odd.first().map(|g| g.action.as_str()), Some("fn-0x42"));
664774 assert!(decode_gestures(&hex("00 02 01 01 02 01")).is_err());
665775 }
776+
777+ /// The owner's table (HW1): per bud, double tap play/pause, triple tap next, hold cycle.
778+ const HW1_GESTURES: &str = "00 06 01 01 02 01 01 01 03 06 01 01 04 08 02 01 02 01 02 01 03 06 02 01 04 08";
779+
780+ #[test]
781+ fn touch_targets_follow_the_t310_choices() {
782+ assert_eq!(
783+ touch_target("left", "triple-tap", "volume-up").unwrap(),
784+ TouchTarget {
785+ devices: &[0x01],
786+ act: 0x03,
787+ function: 0x0B
788+ }
789+ );
790+ assert_eq!(touch_target("both", "hold", "none").unwrap().devices, [0x01, 0x02]);
791+ let usage = |side, gesture, action| match touch_target(side, gesture, action) {
792+ Err(Error::Usage(message)) => message,
793+ other => panic!("expected a usage error, got {other:?}"),
794+ };
795+ assert!(usage("middle", "hold", "none").contains("left, right, both"));
796+ assert!(usage("left", "single-tap", "none").contains("double-tap, triple-tap, hold"));
797+ // Offered by the family, but not by realme Link for this gesture.
798+ assert!(usage("left", "double-tap", "volume-up").contains("none, play-pause, next, voice-assistant"));
799+ assert!(usage("left", "hold", "previous").contains("none, noise-cycle, voice-assistant, game-mode"));
800+ }
801+
802+ #[test]
803+ fn retargeting_changes_only_the_targeted_entries() {
804+ let table = decode_gesture_entries(&hex(HW1_GESTURES)).unwrap();
805+ let left = touch_target("left", "triple-tap", "volume-up").unwrap();
806+ let payload = gesture_write_payload(&retarget_gestures(&table, &left).unwrap()).unwrap();
807+ assert_eq!(
808+ payload,
809+ hex("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")
810+ );
811+ // The frame the spec gives for this change (realme_t310.md#gesture-write).
812+ let frame = crate::devices::realme_t310::frame::encode(GESTURE_WRITE, 0x06, &payload).unwrap();
813+ assert_eq!(
814+ crate::debug::hex(&frame),
815+ "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"
816+ );
817+ let both = touch_target("both", "hold", "game-mode").unwrap();
818+ let payload = gesture_write_payload(&retarget_gestures(&table, &both).unwrap()).unwrap();
819+ assert_eq!(
820+ payload,
821+ hex("06 01 01 02 01 01 01 03 06 01 01 04 11 02 01 02 01 02 01 03 06 02 01 04 11")
822+ );
823+ // Unchanged, the payload is the reply without its status byte.
824+ let same = touch_target("right", "double-tap", "play-pause").unwrap();
825+ let payload = gesture_write_payload(&retarget_gestures(&table, &same).unwrap()).unwrap();
826+ assert_eq!(payload, hex(HW1_GESTURES).get(1..).unwrap());
827+ }
828+
829+ #[test]
830+ fn entries_the_buds_did_not_report_are_never_written() {
831+ // Only the left bud's entries.
832+ let table = decode_gesture_entries(&hex("00 03 01 01 02 01 01 01 03 06 01 01 04 08")).unwrap();
833+ let both = touch_target("both", "hold", "none").unwrap();
834+ assert!(matches!(retarget_gestures(&table, &both), Err(Error::Unsupported(_))));
835+ // An on-call row (group 06) is not a main-group entry.
836+ let table = decode_gesture_entries(&hex("00 01 01 06 02 1D")).unwrap();
837+ let left = touch_target("left", "double-tap", "none").unwrap();
838+ assert!(matches!(retarget_gestures(&table, &left), Err(Error::Unsupported(_))));
839+ }
666840 }
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -9,7 +9,7 @@ use crate::bluez::PairedDevice;
99 use crate::bt::Uuid;
1010 use crate::devices::{Device, Session};
1111 use crate::error::{Error, Kind, Result};
12−use crate::status::{NoiseControl, NoiseMode, Status, Switch, TouchControls};
12+use crate::status::{GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls};
1313 use crate::transport::Transport;
1414
1515 mod codec;
@@ -38,6 +38,11 @@ const GAME_READ_BACK: ReadBack = ReadBack {
3838 first: Duration::from_secs(1),
3939 window: Duration::from_secs(3),
4040 };
41+/// The gesture table is read back about 600 ms after the ack. Spec: realme_t310.md#gesture-write
42+const TOUCH_READ_BACK: ReadBack = ReadBack {
43+ first: Duration::from_millis(600),
44+ window: Duration::from_secs(2),
45+};
4146 /// Spec: realme_t310.md#write-read-back
4247 const READ_BACK_PAUSE: Duration = Duration::from_millis(300);
4348
@@ -360,6 +365,94 @@ impl Session for T310Session {
360365 warnings: reads.warnings,
361366 })
362367 }
368+
369+ /// Spec: realme_t310.md#gesture-write, #gesture-choices
370+ fn set_touch(&mut self, side: &str, gesture: &str, action: &str, dry_run: bool) -> Result<TouchChange> {
371+ let target = codec::touch_target(side, gesture, action)?;
372+ if !self.commands.allows(codec::GESTURE_WRITE) || !self.commands.allows(codec::GESTURES) {
373+ return Err(Error::Unsupported(
374+ "these buds do not offer touch-control settings".to_owned(),
375+ ));
376+ }
377+ // The write carries the whole table, so it starts from the table read just now.
378+ let before = codec::decode_gesture_entries(&self.link.request(codec::GESTURES, &[])?)?;
379+ let after = codec::retarget_gestures(&before, &target)?;
380+ let changes: Vec<GestureChange> = before
381+ .iter()
382+ .zip(&after)
383+ .filter(|(old, new)| old != new)
384+ .map(|(old, new)| {
385+ let (old, new) = (codec::name_gesture(old), codec::name_gesture(new));
386+ GestureChange {
387+ side: new.side,
388+ gesture: new.gesture,
389+ from: old.action,
390+ to: new.action,
391+ }
392+ })
393+ .collect();
394+ let planned: Vec<_> = after.iter().map(codec::name_gesture).collect();
395+ if changes.is_empty() {
396+ return Ok(TouchChange {
397+ changes,
398+ write: None,
399+ sent: false,
400+ gestures: planned,
401+ });
402+ }
403+ let write = RawWrite {
404+ command: codec::GESTURE_WRITE,
405+ payload: codec::gesture_write_payload(&after)?,
406+ };
407+ if dry_run {
408+ return Ok(TouchChange {
409+ changes,
410+ write: Some(write),
411+ sent: false,
412+ gestures: planned,
413+ });
414+ }
415+
416+ let reply = self.link.request(write.command, &write.payload)?;
417+ codec::check_write_reply(&reply, "the touch-control change")?;
418+ let gestures = self.read_back(
419+ TOUCH_READ_BACK,
420+ codec::GESTURES,
421+ &[],
422+ |reply| {
423+ let now = codec::decode_gesture_entries(reply)?;
424+ // Applied when retargeting the table read back would change nothing.
425+ let applied = codec::retarget_gestures(&now, &target).is_ok_and(|again| again == now);
426+ Ok((applied, now))
427+ },
428+ |now| {
429+ let still: Vec<String> = changes
430+ .iter()
431+ .filter_map(|change| {
432+ now.iter()
433+ .map(codec::name_gesture)
434+ .find(|g| g.group.is_none() && g.side == change.side && g.gesture == change.gesture)
435+ })
436+ .filter(|g| g.action != action)
437+ .map(|g| format!("{} {} {}", g.side, g.gesture, g.action))
438+ .collect();
439+ if still.is_empty() {
440+ format!("the buds accepted {side} {gesture} {action}, but the table read back does not show it")
441+ } else {
442+ format!(
443+ "the buds accepted {side} {gesture} {action}, but still report {}",
444+ still.join(", ")
445+ )
446+ }
447+ },
448+ )?;
449+ Ok(TouchChange {
450+ changes,
451+ write: Some(write),
452+ sent: true,
453+ gestures: gestures.iter().map(codec::name_gesture).collect(),
454+ })
455+ }
363456 }
364457
365458 #[cfg(test)]
@@ -777,4 +870,86 @@ mod tests {
777870 script.assert_done();
778871 assert!(matches!(result, Err(Error::Unsupported(_))));
779872 }
873+
874+ /// The owner's gesture table (HW1) and the same table with the left triple tap on volume up.
875+ const HW1_TABLE: &str = "00 06 01 01 02 01 01 01 03 06 01 01 04 08 02 01 02 01 02 01 03 06 02 01 04 08";
876+ const LEFT_VOLUME_UP: &str = "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";
877+
878+ #[test]
879+ fn a_touch_dry_run_reads_the_table_and_sends_nothing() {
880+ let mut steps = handshake();
881+ steps.extend([tx(0x0108, 0x05, ""), rx(0x8108, 0x05, HW1_TABLE)]);
882+ let script = Script::new(steps);
883+ let change = open(&script)
884+ .unwrap()
885+ .set_touch("left", "triple-tap", "volume-up", true)
886+ .unwrap();
887+ script.assert_done();
888+ assert!(!change.sent);
889+ let write = change.write.unwrap();
890+ assert_eq!((write.command, write.payload), (0x0401, hex(LEFT_VOLUME_UP)));
891+ assert_eq!(
892+ change.changes,
893+ [GestureChange {
894+ side: "left".to_owned(),
895+ gesture: "triple-tap".to_owned(),
896+ from: "next".to_owned(),
897+ to: "volume-up".to_owned(),
898+ }]
899+ );
900+ let left_triple = change.gestures.get(1).unwrap();
901+ assert_eq!(left_triple.action, "volume-up");
902+ }
903+
904+ #[test]
905+ fn sets_a_gesture_and_reads_the_table_back() {
906+ let mut steps = handshake();
907+ steps.extend([
908+ tx(0x0108, 0x05, ""),
909+ rx(0x8108, 0x05, HW1_TABLE),
910+ tx(0x0401, 0x06, LEFT_VOLUME_UP),
911+ // The owner's ack (W8): 00, then the old table cut to the write's length.
912+ rx(
913+ 0x8401,
914+ 0x06,
915+ "00 06 01 01 02 01 01 01 03 06 01 01 04 08 02 01 02 01 02 01 03 06 02 01 04",
916+ ),
917+ tx(0x0108, 0x07, ""),
918+ rx(0x8108, 0x07, &format!("00 {LEFT_VOLUME_UP}")),
919+ ]);
920+ let script = Script::new(steps);
921+ let change = open(&script)
922+ .unwrap()
923+ .set_touch("left", "triple-tap", "volume-up", false)
924+ .unwrap();
925+ script.assert_done();
926+ assert!(change.sent);
927+ assert_eq!(change.changes.len(), 1);
928+ assert_eq!(change.gestures.get(1).unwrap().action, "volume-up");
929+ }
930+
931+ #[test]
932+ fn a_gesture_that_already_has_the_action_is_not_written() {
933+ let mut steps = handshake();
934+ steps.extend([tx(0x0108, 0x05, ""), rx(0x8108, 0x05, HW1_TABLE)]);
935+ let script = Script::new(steps);
936+ let change = open(&script)
937+ .unwrap()
938+ .set_touch("both", "hold", "noise-cycle", false)
939+ .unwrap();
940+ script.assert_done();
941+ assert!(change.changes.is_empty() && change.write.is_none() && !change.sent);
942+ }
943+
944+ #[test]
945+ fn a_bad_touch_action_is_refused_before_anything_is_sent() {
946+ let mut steps = handshake();
947+ steps.pop();
948+ let script = Script::new(steps);
949+ let result = open(&script)
950+ .unwrap()
951+ .set_touch("left", "double-tap", "volume-up", false);
952+ script.assert_done();
953+ assert!(matches!(result, Err(Error::Usage(_))));
954+ }
780955 }
M crates/budctl-core/src/status.rs View
@@ -113,6 +113,48 @@ pub struct Gesture {
113113 pub group: Option<u8>,
114114 }
115115
116+/// A touch-control change: what changes, the write that does it and, once sent, the table the
117+/// buds report afterwards.
118+#[derive(Debug, Serialize)]
119+pub struct TouchChange {
120+ /// The entries that change. Empty when every target already has the wanted action.
121+ pub changes: Vec<GestureChange>,
122+ /// The write that makes the change, or `None` when there is nothing to change.
123+ pub write: Option<RawWrite>,
124+ /// Whether the write went out. `false` on a dry run and when nothing changes.
125+ pub sent: bool,
126+ /// The whole table: as read back after the write, or as it would be after a dry run.
127+ pub gestures: Vec<Gesture>,
128+}
129+
130+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131+pub struct GestureChange {
132+ pub side: String,
133+ pub gesture: String,
134+ pub from: String,
135+ pub to: String,
136+}
137+
138+/// One command and its payload, as `--dry-run` shows them. The frame around it (sequence byte
139+/// and so on) is added when it is sent.
140+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141+pub struct RawWrite {
142+ /// `0x0401`.
143+ #[serde(serialize_with = "command_as_hex")]
144+ pub command: u16,
145+ /// `06 01 01 02 01 …`.
146+ #[serde(serialize_with = "bytes_as_hex")]
147+ pub payload: Vec<u8>,
148+}
149+
150+fn command_as_hex<S: Serializer>(command: &u16, serializer: S) -> Result<S::Ok, S::Error> {
151+ serializer.serialize_str(&format!("0x{command:04X}"))
152+}
153+
154+fn bytes_as_hex<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
155+ serializer.serialize_str(&crate::debug::hex(bytes))
156+}
157+
116158 fn switches_as_map<S: Serializer>(switches: &[Switch], serializer: S) -> Result<S::Ok, S::Error> {
117159 serializer.collect_map(switches.iter().map(|s| (s.key, s.on)))
118160 }
M docs/protocol/realme_t310.md View
@@ -339,7 +339,7 @@ Evidence: *vendor*; the T310's command set lists it.
339339 | `1C` | decline call |
340340 | `1D` | answer/end call |
341341
342−- **What realme Link offers for the T310** (*vendor*). Each bud is set on its own, as `dev` 01/02 with `btn` 01:
342+- <a id="gesture-choices"></a>**What realme Link offers for the T310** (*vendor*). Each bud is set on its own, as `dev` 01/02 with `btn` 01. budctl offers exactly these choices and no others, although the family defines more `fn` values. `budctl touch both …` changes the left and the right bud's entry in one write; it never writes a `dev 04` row.
343343
344344 | Gesture | Choices |
345345 |---|---|
@@ -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*). 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*). 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.
@@ -428,9 +428,18 @@ Evidence: *buds4*
428428 The command and layout are *buds4*; the realme rules are *vendor*.
429429
430430 - **Whole table.** The write carries the whole table. Start from a table read in the same session and change only the targeted entries.
431−- **Reply:** `0x8401 00`.
431+- **Reply:** `0x8401 00`. On the owner's T310 (*ours-2026-10-27*, W8), the `00` is followed by the table **as it was before the write**, cut to the length of the write's payload: 25 bytes, so the last `fn` byte is missing. budctl checks only the status byte, and reads the table back to see the change.
432432 - **Check:** read `0x0108` again after about 600 ms and compare.
433433 - **Wrong command ids fail silently.** For example, `0x0402` gets no reply at all.
434+- **What budctl does** (budctl's own policy, not a protocol fact):
435+ - It checks the side, gesture and action against [the T310's choices](#gesture-choices) before it sends anything.
436+ - It reads `0x0108` and changes the `fn` byte of the targeted `btn 01` entries only. Every other byte, including the order of the entries, is copied from the reply. The payload is the reply without its status byte.
437+ - It never adds an entry: if the buds did not report the targeted entry, it refuses.
438+ - If the entry already has the action, it sends nothing.
439+ - `--dry-run` reads the table and prints the payload it would send, then stops.
440+ - 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.
442+- **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`.
434443
435444 <a id="hold-cycle-write"></a>
436445 ### 10.7 Hold cycle: `0x0404 02 01 <mask>`
@@ -517,6 +526,7 @@ Ask for an Android HCI snoop of realme Link before W11, and for any write that d
517526
518527 | Date | Checkpoint | Result | Notes |
519528 |---|---|---|---|
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) |
520530 | 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) |
521531 | 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) |
522532 | 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) |