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.
+220 −8
7 files changed
- M crates/budctl-cli/src/main.rs +42 −1
- M crates/budctl-cli/src/output.rs +17 −0
- M crates/budctl-cli/tests/cli.rs +14 −0
- M crates/budctl-core/src/devices/mod.rs +8 −0
- M crates/budctl-core/src/devices/realme_t310/codec.rs +69 −4
- M crates/budctl-core/src/devices/realme_t310/mod.rs +61 −0
- M docs/protocol/realme_t310.md +9 −3
| @@ -43,8 +43,11 @@ enum Command { | |||
| 43 | 43 | /// | |
| 44 | 44 | /// Gestures: double-tap, triple-tap, hold. Actions: none, play-pause, next, previous, | |
| 45 | 45 | /// 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)] | |
| 47 | 48 | Touch { | |
| 49 | + | #[command(subcommand)] | |
| 50 | + | cycle: Option<TouchCommand>, | |
| 48 | 51 | /// The bud to change: left, right, or both. | |
| 49 | 52 | #[arg(value_enum, requires = "gesture")] | |
| 50 | 53 | side: Option<Side>, | |
| @@ -92,6 +95,35 @@ enum FindAction { | |||
| 92 | 95 | Stop, | |
| 93 | 96 | } | |
| 94 | 97 | ||
| 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 | + | ||
| 95 | 127 | #[derive(Clone, Copy, ValueEnum)] | |
| 96 | 128 | enum Side { | |
| 97 | 129 | Left, | |
| @@ -196,11 +228,20 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 196 | 228 | let status = session.status()?; | |
| 197 | 229 | output::status(out, &device, model, &status, cli.json) | |
| 198 | 230 | } | |
| 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 | + | } | |
| 199 | 239 | Command::Touch { | |
| 200 | 240 | side: Some(side), | |
| 201 | 241 | gesture: Some(ref gesture), | |
| 202 | 242 | action: Some(ref action), | |
| 203 | 243 | dry_run, | |
| 244 | + | .. | |
| 204 | 245 | } => { | |
| 205 | 246 | let change = session.set_touch(side.name(), gesture, action, dry_run)?; | |
| 206 | 247 | output::touch_change(out, &device, model, &change, cli.json) | |
| @@ -186,6 +186,23 @@ pub fn touch( | |||
| 186 | 186 | Ok(()) | |
| 187 | 187 | } | |
| 188 | 188 | ||
| 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 | + | ||
| 189 | 206 | /// A touch-control change: what changed (or would change, on a dry run) and the write. | |
| 190 | 207 | pub fn touch_change( | |
| 191 | 208 | out: &mut impl Write, | |
| @@ -45,3 +45,17 @@ fn touch_changes_need_side_gesture_and_action() { | |||
| 45 | 45 | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| 46 | 46 | } | |
| 47 | 47 | } | |
| 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 | + | } | |
| @@ -66,6 +66,14 @@ pub trait Session: Send { | |||
| 66 | 66 | Err(Error::Unsupported(format!("budctl cannot change {key} on these buds"))) | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 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 | + | ||
| 69 | 77 | /// Give a touch gesture a new action, on `side` (`left`, `right` or `both`), then read the | |
| 70 | 78 | /// table back. With `dry_run`, read the table and work out the write, but send nothing. | |
| 71 | 79 | fn set_touch(&mut self, _side: &str, _gesture: &str, _action: &str, _dry_run: bool) -> Result<TouchChange> { | |
| @@ -324,12 +324,18 @@ pub(super) fn noise_write_mask(mode: NoiseMode) -> u32 { | |||
| 324 | 324 | /// Spec: realme_t310.md#anc-write | |
| 325 | 325 | pub(super) fn noise_write_payload(mode: NoiseMode) -> Vec<u8> { | |
| 326 | 326 | 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))); | |
| 330 | 328 | payload | |
| 331 | 329 | } | |
| 332 | 330 | ||
| 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 | + | ||
| 333 | 339 | /// The mask in a noise-control read reply. Spec: realme_t310.md#anc-read | |
| 334 | 340 | pub(super) fn decode_noise_mask(reply: &[u8]) -> Result<u32> { | |
| 335 | 341 | 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<()> | |||
| 342 | 348 | ||
| 343 | 349 | /// Spec: realme_t310.md#hold-cycle | |
| 344 | 350 | 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)?; | |
| 346 | 352 | let mut modes: Vec<String> = CYCLE_MODES | |
| 347 | 353 | .iter() | |
| 348 | 354 | .filter(|(bit, _)| mask & bit != 0) | |
| @@ -355,6 +361,42 @@ pub(super) fn decode_hold_cycle(reply: &[u8]) -> Result<Vec<String>> { | |||
| 355 | 361 | Ok(modes) | |
| 356 | 362 | } | |
| 357 | 363 | ||
| 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 | + | ||
| 358 | 400 | /// `00 <echo of the query> <mask, 1-4 bytes LE>`. Spec: realme_t310.md#anc-read | |
| 359 | 401 | fn noise_mask(reply: &[u8], query: [u8; 2], what: &'static str) -> Result<u32> { | |
| 360 | 402 | let mut r = ok_reply(reply, what)?; | |
| @@ -837,4 +879,27 @@ mod tests { | |||
| 837 | 879 | let left = touch_target("left", "double-tap", "none").unwrap(); | |
| 838 | 880 | assert!(matches!(retarget_gestures(&table, &left), Err(Error::Unsupported(_)))); | |
| 839 | 881 | } | |
| 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 | + | } | |
| 840 | 905 | } | |
| @@ -366,6 +366,39 @@ impl Session for T310Session { | |||
| 366 | 366 | }) | |
| 367 | 367 | } | |
| 368 | 368 | ||
| 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 | + | ||
| 369 | 402 | /// Spec: realme_t310.md#gesture-write, #gesture-choices | |
| 370 | 403 | fn set_touch(&mut self, side: &str, gesture: &str, action: &str, dry_run: bool) -> Result<TouchChange> { | |
| 371 | 404 | let target = codec::touch_target(side, gesture, action)?; | |
| @@ -952,4 +985,32 @@ mod tests { | |||
| 952 | 985 | script.assert_done(); | |
| 953 | 986 | assert!(matches!(result, Err(Error::Usage(_)))); | |
| 954 | 987 | } | |
| 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 | + | } | |
| 955 | 1016 | } | |
| @@ -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*); [§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. | |
| 365 | 365 | ||
| 366 | 366 | **General rule**: | |
| 367 | 367 | - 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*. | |||
| 438 | 438 | - If the entry already has the action, it sends nothing. | |
| 439 | 439 | - `--dry-run` reads the table and prints the payload it would send, then stops. | |
| 440 | 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. | |
| 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. | |
| 442 | 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`. | |
| 443 | 443 | ||
| 444 | 444 | <a id="hold-cycle-write"></a> | |
| @@ -449,6 +449,11 @@ Evidence: *vendor* | |||
| 449 | 449 | - **Mask:** the fewest bytes that hold it, and at least 2 modes (*vendor*). | |
| 450 | 450 | - **Level bits.** The buds silently drop level bits (*buds4*). | |
| 451 | 451 | - **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. | |
| 452 | 457 | ||
| 453 | 458 | <a id="fit-test"></a> | |
| 454 | 459 | ### 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 | |||
| 526 | 531 | ||
| 527 | 532 | | Date | Checkpoint | Result | Notes | | |
| 528 | 533 | |---|---|---|---| | |
| 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) | | |
| 530 | 536 | | 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) | | |
| 531 | 537 | | 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) | | |
| 532 | 538 | | 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) | | |