budctl95ea4d7
Park the fit test on the T310 (W10)
On the owner's T310 (2026-10-27), 0x0405 01 is acked within 19 ms, but the buds then send nothing for 15 s and play no sound. The stop is acked too, after 417 ms. Bitmap bit 9 is set, but no test runs. The T310 now refuses `budctl fit-test` before sending anything (exit 1). Its result decoding and push wait are removed. The generic `fit-test` command and the Session::fit_test method stay, so a fix from a realme Link capture only touches the T310 module. The working implementation is in 8fdeefb. Spec: §10.8 records the run, and open question 12 asks whether realme Link offers a fit test for the T310 at all. No new dependencies.
+18 −172
4 files changed
- M crates/budctl-core/src/devices/realme_t310/codec.rs +1 −58
- M crates/budctl-core/src/devices/realme_t310/link.rs +0 −12
- M crates/budctl-core/src/devices/realme_t310/mod.rs +11 −92
- M docs/protocol/realme_t310.md +6 −10
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use crate::error::{Error, Result}; | |
| 5 | 5 | use crate::reader::Reader; | |
| 6 | − | use crate::status::{AncLevel, Battery, BudFit, Charge, Gesture, NoiseControl, NoiseMode, Switch}; | |
| 6 | + | use crate::status::{AncLevel, Battery, Charge, Gesture, NoiseControl, NoiseMode, Switch}; | |
| 7 | 7 | ||
| 8 | 8 | /// Spec: realme_t310.md#bitmap | |
| 9 | 9 | pub(super) const HANDSHAKE: u16 = 0x0100; | |
| @@ -47,43 +47,6 @@ pub(super) const EVENTS_OFFERED: u16 = 0x0200; | |||
| 47 | 47 | pub(super) const SUBSCRIBE: u16 = 0x0205; | |
| 48 | 48 | /// Spec: realme_t310.md#fit-test | |
| 49 | 49 | pub(super) const FIT_TEST: u16 = 0x0405; | |
| 50 | − | /// Spec: realme_t310.md#pushes | |
| 51 | − | pub(super) const EVENT_PUSH: u16 = 0x0204; | |
| 52 | − | /// The event code of a fit-test result. Spec: realme_t310.md#pushes | |
| 53 | − | pub(super) const FIT_EVENT: u8 = 0x04; | |
| 54 | − | ||
| 55 | − | /// Fit-test results by value. Spec: realme_t310.md#fit-test | |
| 56 | − | const FIT_RESULTS: [(u8, &str); 3] = [(0x01, "good"), (0x00, "average"), (0x06, "poor")]; | |
| 57 | − | ||
| 58 | − | /// `01` starts the fit test, `00` stops it. Spec: realme_t310.md#fit-test | |
| 59 | − | pub(super) fn fit_test_payload(start: bool) -> [u8; 1] { | |
| 60 | − | [u8::from(start)] | |
| 61 | − | } | |
| 62 | − | ||
| 63 | − | /// Whether a frame is the fit-test result push. Spec: realme_t310.md#pushes | |
| 64 | − | pub(super) fn is_fit_push(cmd: u16, payload: &[u8]) -> bool { | |
| 65 | − | cmd == EVENT_PUSH && payload.first() == Some(&FIT_EVENT) | |
| 66 | − | } | |
| 67 | − | ||
| 68 | − | /// `04 (<dev> <result>)…`, with no count. Spec: realme_t310.md#pushes, #fit-test | |
| 69 | − | pub(super) fn decode_fit_push(payload: &[u8]) -> Result<Vec<BudFit>> { | |
| 70 | − | let mut r = Reader::new(payload, "the fit-test result"); | |
| 71 | − | if r.u8()? != FIT_EVENT { | |
| 72 | − | return Err(Error::Protocol("the push is not a fit-test result".to_owned())); | |
| 73 | − | } | |
| 74 | − | let mut buds = Vec::new(); | |
| 75 | − | while !r.is_empty() { | |
| 76 | − | let [side, result] = [r.u8()?, r.u8()?]; | |
| 77 | − | buds.push(BudFit { | |
| 78 | − | side: name_of(&SIDES, side).map_or_else(|| format!("device-0x{side:02X}"), str::to_owned), | |
| 79 | − | fit: name_of(&FIT_RESULTS, result).map_or_else(|| format!("unknown (0x{result:02X})"), str::to_owned), | |
| 80 | − | }); | |
| 81 | − | } | |
| 82 | − | if buds.is_empty() { | |
| 83 | − | return Err(Error::Protocol("the fit-test result names no bud".to_owned())); | |
| 84 | − | } | |
| 85 | − | Ok(buds) | |
| 86 | − | } | |
| 87 | 50 | ||
| 88 | 51 | /// Spec: realme_t310.md#identity | |
| 89 | 52 | pub(super) const T310_PRODUCT_ID: u32 = 0x06_3C_12; | |
| @@ -939,24 +902,4 @@ mod tests { | |||
| 939 | 902 | .unwrap(); | |
| 940 | 903 | assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 04 04 06 03 00 02 01 0B"); | |
| 941 | 904 | } | |
| 942 | − | ||
| 943 | − | #[test] | |
| 944 | − | fn fit_test_results() { | |
| 945 | − | assert_eq!(fit_test_payload(true), [0x01]); | |
| 946 | − | assert_eq!(fit_test_payload(false), [0x00]); | |
| 947 | − | assert!(is_fit_push(0x0204, &hex("04 01 01 02 06"))); | |
| 948 | − | assert!(!is_fit_push(0x0204, &hex("01 03 01 64"))); | |
| 949 | − | assert!(!is_fit_push(0x8405, &hex("04"))); | |
| 950 | − | let buds = decode_fit_push(&hex("04 01 01 02 06")).unwrap(); | |
| 951 | − | let rows: Vec<(&str, &str)> = buds.iter().map(|b| (b.side.as_str(), b.fit.as_str())).collect(); | |
| 952 | − | assert_eq!(rows, [("left", "good"), ("right", "poor")]); | |
| 953 | − | let odd = decode_fit_push(&hex("04 02 00 01 07")).unwrap(); | |
| 954 | − | let rows: Vec<(&str, &str)> = odd.iter().map(|b| (b.side.as_str(), b.fit.as_str())).collect(); | |
| 955 | − | assert_eq!(rows, [("right", "average"), ("left", "unknown (0x07)")]); | |
| 956 | − | assert!(decode_fit_push(&hex("04")).is_err()); | |
| 957 | − | assert!(decode_fit_push(&hex("04 01")).is_err()); | |
| 958 | − | // The frames the spec gives (realme_t310.md#fit-test). | |
| 959 | − | let start = crate::devices::realme_t310::frame::encode(FIT_TEST, 0x05, &fit_test_payload(true)).unwrap(); | |
| 960 | − | assert_eq!(crate::debug::hex(&start), "AA 08 00 00 05 04 05 01 00 01"); | |
| 961 | − | } | |
| 962 | 905 | } | |
| @@ -71,18 +71,6 @@ impl Link { | |||
| 71 | 71 | Ok(()) | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | − | /// Like [`Self::pump_until`], but stop at the first frame `wanted` picks and return what it | |
| 75 | − | /// returned. `None` when `deadline` passes first. | |
| 76 | − | pub(super) fn wait_for<T>(&mut self, deadline: Instant, wanted: impl Fn(&Frame) -> Option<T>) -> Result<Option<T>> { | |
| 77 | − | while let Some(frame) = self.next_frame(deadline)? { | |
| 78 | − | if let Some(found) = wanted(&frame) { | |
| 79 | − | return Ok(Some(found)); | |
| 80 | − | } | |
| 81 | − | self.handle_unsolicited(frame)?; | |
| 82 | − | } | |
| 83 | − | Ok(None) | |
| 84 | − | } | |
| 85 | − | ||
| 86 | 74 | /// Send a request and return the payload of its reply. | |
| 87 | 75 | pub(super) fn request(&mut self, cmd: u16, payload: &[u8]) -> Result<Vec<u8>> { | |
| 88 | 76 | let seq = self.send(cmd, payload)?; | |
| @@ -45,9 +45,6 @@ const TOUCH_READ_BACK: ReadBack = ReadBack { | |||
| 45 | 45 | first: Duration::from_millis(600), | |
| 46 | 46 | window: Duration::from_secs(2), | |
| 47 | 47 | }; | |
| 48 | − | /// How long budctl waits for the fit-test result after the ack: Buds 4 take about 8.6 s. | |
| 49 | − | /// Spec: realme_t310.md#fit-test | |
| 50 | − | const FIT_TEST_WAIT: Duration = Duration::from_secs(15); | |
| 51 | 48 | /// Spec: realme_t310.md#write-read-back | |
| 52 | 49 | const READ_BACK_PAUSE: Duration = Duration::from_millis(300); | |
| 53 | 50 | ||
| @@ -105,8 +102,6 @@ fn unix_time() -> u32 { | |||
| 105 | 102 | struct T310Session { | |
| 106 | 103 | link: Link, | |
| 107 | 104 | commands: Commands, | |
| 108 | − | /// Whether the session subscribed to fit-test results. Spec: realme_t310.md#subscribe | |
| 109 | − | fit_results: bool, | |
| 110 | 105 | } | |
| 111 | 106 | ||
| 112 | 107 | /// Collects what a series of best-effort reads produced. | |
| @@ -139,17 +134,11 @@ impl T310Session { | |||
| 139 | 134 | ||
| 140 | 135 | let offered = codec::decode_events_offered(&link.request(codec::EVENTS_OFFERED, &[])?)?; | |
| 141 | 136 | let subscription = codec::subscribe_payload(&offered, &commands); | |
| 142 | − | let mut fit_results = false; | |
| 143 | 137 | if commands.allows(codec::SUBSCRIBE) && subscription.len() > 1 { | |
| 144 | 138 | // Some buds never acknowledge this, so budctl does not wait. Spec: realme_t310.md#subscribe | |
| 145 | 139 | link.send(codec::SUBSCRIBE, &subscription)?; | |
| 146 | − | fit_results = subscription.iter().skip(1).any(|&code| code == codec::FIT_EVENT); | |
| 147 | 140 | } | |
| 148 | − | Ok(Self { | |
| 149 | − | link, | |
| 150 | − | commands, | |
| 151 | − | fit_results, | |
| 152 | − | }) | |
| 141 | + | Ok(Self { link, commands }) | |
| 153 | 142 | } | |
| 154 | 143 | ||
| 155 | 144 | /// Read and decode one value, if the buds offer the command. A read the buds answer badly | |
| @@ -379,41 +368,13 @@ impl Session for T310Session { | |||
| 379 | 368 | }) | |
| 380 | 369 | } | |
| 381 | 370 | ||
| 371 | + | /// The T310 acks `0x0405` but runs no test, so budctl does not send it. | |
| 382 | 372 | /// Spec: realme_t310.md#fit-test | |
| 383 | 373 | fn fit_test(&mut self) -> Result<Vec<BudFit>> { | |
| 384 | − | if !self.commands.allows(codec::FIT_TEST) || !self.fit_results { | |
| 385 | − | return Err(Error::Unsupported("these buds do not offer a fit test".to_owned())); | |
| 386 | − | } | |
| 387 | − | let reply = self.link.request(codec::FIT_TEST, &codec::fit_test_payload(true))?; | |
| 388 | − | codec::check_write_reply(&reply, "starting the fit test")?; | |
| 389 | − | ||
| 390 | − | let deadline = Instant::now() + FIT_TEST_WAIT; | |
| 391 | − | let result = self.link.wait_for(deadline, |frame| { | |
| 392 | − | codec::is_fit_push(frame.cmd, &frame.payload).then(|| codec::decode_fit_push(&frame.payload)) | |
| 393 | − | })?; | |
| 394 | − | match result { | |
| 395 | − | Some(buds) => buds, | |
| 396 | − | None => { | |
| 397 | − | // Do not leave the test running. Spec: realme_t310.md#fit-test | |
| 398 | − | let stopped = match self.stop_fit_test() { | |
| 399 | − | Ok(()) => "budctl stopped the test".to_owned(), | |
| 400 | − | Err(error) => format!("stopping it failed too: {error}"), | |
| 401 | − | }; | |
| 402 | − | Err(Error::Timeout(format!( | |
| 403 | − | "no fit-test result within {} s; {stopped}", | |
| 404 | − | FIT_TEST_WAIT.as_secs() | |
| 405 | − | ))) | |
| 406 | − | } | |
| 407 | − | } | |
| 408 | − | } | |
| 409 | − | ||
| 410 | − | /// Spec: realme_t310.md#fit-test | |
| 411 | − | fn stop_fit_test(&mut self) -> Result<()> { | |
| 412 | − | if !self.commands.allows(codec::FIT_TEST) { | |
| 413 | − | return Err(Error::Unsupported("these buds do not offer a fit test".to_owned())); | |
| 414 | − | } | |
| 415 | − | let reply = self.link.request(codec::FIT_TEST, &codec::fit_test_payload(false))?; | |
| 416 | − | codec::check_write_reply(&reply, "stopping the fit test") | |
| 374 | + | Err(Error::Unsupported( | |
| 375 | + | "the T310 accepts the fit-test command but runs no test (no sound, no result), so budctl does not offer it" | |
| 376 | + | .to_owned(), | |
| 377 | + | )) | |
| 417 | 378 | } | |
| 418 | 379 | ||
| 419 | 380 | /// Spec: realme_t310.md#hold-cycle-write, #write-read-back | |
| @@ -1065,55 +1026,13 @@ mod tests { | |||
| 1065 | 1026 | } | |
| 1066 | 1027 | ||
| 1067 | 1028 | #[test] | |
| 1068 | − | fn runs_a_fit_test_and_waits_for_the_result() { | |
| 1069 | − | let mut steps = handshake(); | |
| 1070 | − | steps.extend([ | |
| 1071 | − | tx(0x0405, 0x05, "01"), | |
| 1072 | − | rx(0x8405, 0x05, "00"), | |
| 1073 | − | // Other pushes may come first; they are skipped. | |
| 1074 | − | rx(0x0204, 0x20, "01 03 01 5A 02 5A 03 00"), | |
| 1075 | − | rx(0x0204, 0x21, "04 01 01 02 00"), | |
| 1076 | − | ]); | |
| 1077 | − | let script = Script::new(steps); | |
| 1078 | − | let buds = open(&script).unwrap().fit_test().unwrap(); | |
| 1079 | − | script.assert_done(); | |
| 1080 | − | let rows: Vec<(&str, &str)> = buds.iter().map(|b| (b.side.as_str(), b.fit.as_str())).collect(); | |
| 1081 | − | assert_eq!(rows, [("left", "good"), ("right", "average")]); | |
| 1082 | − | } | |
| 1083 | − | ||
| 1084 | − | #[test] | |
| 1085 | − | fn a_fit_test_with_no_result_is_stopped() { | |
| 1029 | + | fn the_fit_test_is_refused_and_nothing_is_sent() { | |
| 1086 | 1030 | let mut steps = handshake(); | |
| 1087 | − | steps.extend([ | |
| 1088 | − | tx(0x0405, 0x05, "01"), | |
| 1089 | − | rx(0x8405, 0x05, "00"), | |
| 1090 | − | // No result: the wait runs out, and budctl stops the test. | |
| 1091 | − | tx(0x0405, 0x06, "00"), | |
| 1092 | − | rx(0x8405, 0x06, "00"), | |
| 1093 | − | ]); | |
| 1031 | + | steps.pop(); | |
| 1094 | 1032 | let script = Script::new(steps); | |
| 1095 | − | let result = open(&script).unwrap().fit_test(); | |
| 1096 | − | script.assert_done(); | |
| 1097 | − | match result { | |
| 1098 | − | Err(Error::Timeout(message)) => assert!(message.ends_with("budctl stopped the test")), | |
| 1099 | − | other => panic!("expected a timeout, got {other:?}"), | |
| 1100 | − | } | |
| 1101 | − | } | |
| 1102 | − | ||
| 1103 | − | #[test] | |
| 1104 | − | fn no_fit_test_without_the_result_subscription() { | |
| 1105 | − | // The buds offer no event 04, so no result could ever arrive: nothing is sent. | |
| 1106 | − | let script = Script::new(vec![ | |
| 1107 | − | tx(0x0100, 0x01, ""), | |
| 1108 | − | rx(0x8100, 0x01, codec::T310_BITMAP_REPLY), | |
| 1109 | − | tx(0x0103, 0x02, ""), | |
| 1110 | − | rx(0x8103, 0x02, "00 12 3C 06"), | |
| 1111 | − | tx(0x0200, 0x03, ""), | |
| 1112 | − | rx(0x8200, 0x03, "00 03 01 02 03"), | |
| 1113 | − | tx(0x0205, 0x04, "03 01 02 03"), | |
| 1114 | − | ]); | |
| 1115 | − | let result = open(&script).unwrap().fit_test(); | |
| 1033 | + | let mut session = open(&script).unwrap(); | |
| 1034 | + | assert!(matches!(session.fit_test(), Err(Error::Unsupported(_)))); | |
| 1035 | + | assert!(matches!(session.stop_fit_test(), Err(Error::Unsupported(_)))); | |
| 1116 | 1036 | script.assert_done(); | |
| 1117 | − | assert!(matches!(result, Err(Error::Unsupported(_)))); | |
| 1118 | 1037 | } | |
| 1119 | 1038 | } | |
| @@ -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*); [§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. | |
| 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*). The fit test ([§10.8](#fit-test), W10) is acked but does nothing on the T310, so budctl does not send it. 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. | |
| @@ -463,14 +463,8 @@ Evidence: *buds4* | |||
| 463 | 463 | - `01` starts the test and `00` stops it. | |
| 464 | 464 | - **Result:** arrives as push `04 (<dev> <result>)…`, where `1` = good, `0` = average and `6` = poor. The push needs event `04` subscribed. | |
| 465 | 465 | - **Timing:** about 8.6 s on Buds 4. Both buds must be in the ears. | |
| 466 | − | - **What budctl does** (budctl's own policy): | |
| 467 | − | - `budctl fit-test` runs only if `0x0405` is allowed and the session subscribed to event `04` ([subscribe](#subscribe)). Without the subscription no result could arrive. | |
| 468 | − | - It sends `0x0405 01`, checks the `0x8405` status, then waits up to **15 s** for the event-`04` push, skipping and answering other frames meanwhile. | |
| 469 | − | - If no result comes, it sends `0x0405 00` to stop the test and exits 4. | |
| 470 | − | - After a result it sends nothing more. Whether the buds end the test on their own is still to be checked at W10. | |
| 471 | − | - `budctl fit-test stop` sends `0x0405 00`. | |
| 472 | − | - **Expected frames** (*derived*), with seq `05`: start `AA 08 00 00 05 04 05 01 00 01`, stop `AA 08 00 00 05 04 05 01 00 00`. | |
| 473 | − | - **Not yet seen on a T310.** What the buds do while testing (a tone?), whether they reply `0E` without an in-ear sensor, and the shape of the result are all to be checked at W10. | |
| 466 | + | - **The T310 runs no test** (*ours-2026-10-27*, W10). With both buds in the owner's ears, `AA 08 00 00 05 04 05 01 00 01` (start) got `0x8405 00` within 19 ms. Then nothing came for 15 s: no push of any kind, and no sound. `0x0405 00` (stop) got `0x8405 00`, but after 417 ms, where other writes take about 20 ms. Event `04` was subscribed and acked. Bit 9 of the bitmap is set, so the bitmap overstates what the T310 does here. | |
| 467 | + | - **budctl does not send it.** `budctl fit-test` on the T310 is refused before anything is sent (exit 1). It could return if a realme Link capture shows a fit test on the T310 ([open questions](#open)). The first W10 implementation is in git history (commit `8fdeefb`): it waited 15 s for the event-`04` push and sent stop if none came. | |
| 474 | 468 | ||
| 475 | 469 | <a id="custom-eq"></a> | |
| 476 | 470 | ### 10.9 Custom EQ: list `0x0122`, write `0x0418` | |
| @@ -506,6 +500,7 @@ Evidence: *vendor* | |||
| 506 | 500 | 9. Whether the minimum 200 ms spacing between sends is needed. | |
| 507 | 501 | 10. Why the T310 ignores `0x0124` although bit 38 is set ([§9.7](#bass-read)). Needs a realme Link capture before W6. | |
| 508 | 502 | 11. How to turn noise cancelling on at the level the buds saved, without forcing deep ([§10.1](#anc-write)). Needs a realme Link snoop. | |
| 503 | + | 12. Whether realme Link offers a fit test for the T310 at all. The T310 acks `0x0405 01` but runs no test ([§10.8](#fit-test)). If the app has one, a snoop of it would show what the app sends. | |
| 509 | 504 | ||
| 510 | 505 | <a id="roadmap"></a> | |
| 511 | 506 | ## 13. Roadmap and hardware checkpoints | |
| @@ -529,7 +524,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go | |||
| 529 | 524 | | W7 | `find` | | |
| 530 | 525 | | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) | | |
| 531 | 526 | | W9 | `touch cycle` | | |
| 532 | − | | W10 | `fit-test` | | |
| 527 | + | | W10 | `fit-test`: acked but does nothing on the T310; parked ([§10.8](#fit-test)) | | |
| 533 | 528 | | W11 | custom EQ (create, then save, then delete) | | |
| 534 | 529 | ||
| 535 | 530 | Ask for an Android HCI snoop of realme Link before W11, and for any write that does nothing. | |
| @@ -539,6 +534,7 @@ Ask for an Android HCI snoop of realme Link before W11, and for any write that d | |||
| 539 | 534 | ||
| 540 | 535 | | Date | Checkpoint | Result | Notes | | |
| 541 | 536 | |---|---|---|---| | |
| 537 | + | | 2026-10-27 | W10 `fit-test` | fail: no test | Start and stop were acked, but no result push and no sound came in 15 s. budctl now refuses the fit test on the T310. See [§10.8](#fit-test) | | |
| 542 | 538 | | 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) | | |
| 543 | 539 | | 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) | | |
| 544 | 540 | | 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) | | |