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.
+515 −19
8 files changed
- M crates/budctl-cli/src/main.rs +46 −3
- M crates/budctl-cli/src/output.rs +32 −1
- M crates/budctl-cli/tests/cli.rs +13 −0
- M crates/budctl-core/src/devices/mod.rs +9 −1
- M crates/budctl-core/src/devices/realme_t310/codec.rs +184 −10
- M crates/budctl-core/src/devices/realme_t310/mod.rs +176 −1
- M crates/budctl-core/src/status.rs +42 −0
- M docs/protocol/realme_t310.md +13 −3
| @@ -39,8 +39,24 @@ enum Command { | |||
| 39 | 39 | List, | |
| 40 | 40 | /// Battery, noise control, EQ and switches. | |
| 41 | 41 | 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 | + | }, | |
| 44 | 60 | /// Set noise control, then read it back. | |
| 45 | 61 | Anc { | |
| 46 | 62 | #[arg(value_enum)] | |
| @@ -76,6 +92,24 @@ enum FindAction { | |||
| 76 | 92 | Stop, | |
| 77 | 93 | } | |
| 78 | 94 | ||
| 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 | + | ||
| 79 | 113 | #[derive(Clone, Copy, ValueEnum)] | |
| 80 | 114 | enum OnOff { | |
| 81 | 115 | On, | |
| @@ -162,7 +196,16 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 162 | 196 | let status = session.status()?; | |
| 163 | 197 | output::status(out, &device, model, &status, cli.json) | |
| 164 | 198 | } | |
| 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 { .. } => { | |
| 166 | 209 | let touch = session.touch_controls()?; | |
| 167 | 210 | output::touch(out, &device, model, &touch, cli.json) | |
| 168 | 211 | } | |
| @@ -4,7 +4,7 @@ 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, JSON_SCHEMA, NoiseControl, Status, Switch, TouchControls}; | |
| 7 | + | use budctl_core::status::{Charge, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls}; | |
| 8 | 8 | use budctl_core::{Error, Found}; | |
| 9 | 9 | use serde_json::Value; | |
| 10 | 10 | ||
| @@ -186,6 +186,37 @@ pub fn touch( | |||
| 186 | 186 | Ok(()) | |
| 187 | 187 | } | |
| 188 | 188 | ||
| 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 | + | ||
| 189 | 220 | /// The noise-control mode after a change, as the buds report it on reading back. | |
| 190 | 221 | pub fn noise_control( | |
| 191 | 222 | out: &mut impl Write, | |
| @@ -32,3 +32,16 @@ fn find_start_needs_yes_before_connecting() { | |||
| 32 | 32 | assert_eq!(out.status.code(), Some(1)); | |
| 33 | 33 | assert!(String::from_utf8_lossy(&out.stderr).contains("--yes")); | |
| 34 | 34 | } | |
| 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 | + | } | |
| @@ -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::{NoiseControl, NoiseMode, Status, Switch, TouchControls}; | |
| 9 | + | use crate::status::{NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls}; | |
| 10 | 10 | use crate::transport::Transport; | |
| 11 | 11 | ||
| 12 | 12 | pub mod realme_t310; | |
| @@ -65,6 +65,14 @@ pub trait Session: Send { | |||
| 65 | 65 | fn set_switch(&mut self, key: &str, _on: bool) -> Result<Switch> { | |
| 66 | 66 | Err(Error::Unsupported(format!("budctl cannot change {key} on these buds"))) | |
| 67 | 67 | } | |
| 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 | + | } | |
| 68 | 76 | } | |
| 69 | 77 | ||
| 70 | 78 | /// The supported model a paired device is, if any. | |
| @@ -27,6 +27,8 @@ pub(super) const SWITCH_WRITE: u16 = 0x0403; | |||
| 27 | 27 | pub(super) const EQ_SELECT: u16 = 0x0406; | |
| 28 | 28 | /// Spec: realme_t310.md#find | |
| 29 | 29 | pub(super) const FIND: u16 = 0x0400; | |
| 30 | + | /// Spec: realme_t310.md#gesture-write | |
| 31 | + | pub(super) const GESTURE_WRITE: u16 = 0x0401; | |
| 30 | 32 | ||
| 31 | 33 | /// `01` starts ringing, `00` stops it. Spec: realme_t310.md#find | |
| 32 | 34 | pub(super) fn find_payload(ring: bool) -> [u8; 1] { | |
| @@ -421,20 +423,128 @@ pub(super) fn decode_bass_level(reply: &[u8]) -> Result<i8> { | |||
| 421 | 423 | Ok(level) | |
| 422 | 424 | } | |
| 423 | 425 | ||
| 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. | |
| 424 | 430 | /// 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>> { | |
| 426 | 432 | let mut r = ok_reply(reply, "the gesture table")?; | |
| 427 | − | let mut gestures = Vec::new(); | |
| 433 | + | let mut entries = Vec::new(); | |
| 428 | 434 | 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), | |
| 436 | 447 | } | |
| 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) | |
| 438 | 548 | } | |
| 439 | 549 | ||
| 440 | 550 | /// The handshake reply of the owner's T310 (HW1). Spec: realme_t310.md#bitmap-t310 | |
| @@ -663,4 +773,68 @@ mod tests { | |||
| 663 | 773 | assert_eq!(odd.first().map(|g| g.action.as_str()), Some("fn-0x42")); | |
| 664 | 774 | assert!(decode_gestures(&hex("00 02 01 01 02 01")).is_err()); | |
| 665 | 775 | } | |
| 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 | + | } | |
| 666 | 840 | } | |
| @@ -9,7 +9,7 @@ use crate::bluez::PairedDevice; | |||
| 9 | 9 | use crate::bt::Uuid; | |
| 10 | 10 | use crate::devices::{Device, Session}; | |
| 11 | 11 | 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}; | |
| 13 | 13 | use crate::transport::Transport; | |
| 14 | 14 | ||
| 15 | 15 | mod codec; | |
| @@ -38,6 +38,11 @@ const GAME_READ_BACK: ReadBack = ReadBack { | |||
| 38 | 38 | first: Duration::from_secs(1), | |
| 39 | 39 | window: Duration::from_secs(3), | |
| 40 | 40 | }; | |
| 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 | + | }; | |
| 41 | 46 | /// Spec: realme_t310.md#write-read-back | |
| 42 | 47 | const READ_BACK_PAUSE: Duration = Duration::from_millis(300); | |
| 43 | 48 | ||
| @@ -360,6 +365,94 @@ impl Session for T310Session { | |||
| 360 | 365 | warnings: reads.warnings, | |
| 361 | 366 | }) | |
| 362 | 367 | } | |
| 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 | + | } | |
| 363 | 456 | } | |
| 364 | 457 | ||
| 365 | 458 | #[cfg(test)] | |
| @@ -777,4 +870,86 @@ mod tests { | |||
| 777 | 870 | script.assert_done(); | |
| 778 | 871 | assert!(matches!(result, Err(Error::Unsupported(_)))); | |
| 779 | 872 | } | |
| 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 | + | } | |
| 780 | 955 | } | |
| @@ -113,6 +113,48 @@ pub struct Gesture { | |||
| 113 | 113 | pub group: Option<u8>, | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 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 | + | ||
| 116 | 158 | fn switches_as_map<S: Serializer>(switches: &[Switch], serializer: S) -> Result<S::Ok, S::Error> { | |
| 117 | 159 | serializer.collect_map(switches.iter().map(|s| (s.key, s.on))) | |
| 118 | 160 | } | |
| @@ -339,7 +339,7 @@ Evidence: *vendor*; the T310's command set lists it. | |||
| 339 | 339 | | `1C` | decline call | | |
| 340 | 340 | | `1D` | answer/end call | | |
| 341 | 341 | ||
| 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. | |
| 343 | 343 | ||
| 344 | 344 | | Gesture | Choices | | |
| 345 | 345 | |---|---| | |
| @@ -361,7 +361,7 @@ Evidence: *vendor* | |||
| 361 | 361 | <a id="writes"></a> | |
| 362 | 362 | ## 10. Writes | |
| 363 | 363 | ||
| 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. | |
| 365 | 365 | ||
| 366 | 366 | **General rule**: | |
| 367 | 367 | - A write reply is `0x84xx <status …>`, and `00` means accepted. | |
| @@ -428,9 +428,18 @@ Evidence: *buds4* | |||
| 428 | 428 | The command and layout are *buds4*; the realme rules are *vendor*. | |
| 429 | 429 | ||
| 430 | 430 | - **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. | |
| 432 | 432 | - **Check:** read `0x0108` again after about 600 ms and compare. | |
| 433 | 433 | - **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`. | |
| 434 | 443 | ||
| 435 | 444 | <a id="hold-cycle-write"></a> | |
| 436 | 445 | ### 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 | |||
| 517 | 526 | ||
| 518 | 527 | | Date | Checkpoint | Result | Notes | | |
| 519 | 528 | |---|---|---|---| | |
| 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) | | |
| 520 | 530 | | 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) | | |
| 521 | 531 | | 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) | | |
| 522 | 532 | | 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) | | |