Krolyxon
Commit 95ea4d7

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-27 20:31 +0530
Parent
4c49f6c
Commit
95ea4d79bd0de79394837ec893fd5583cfc493e8

+18 −172

4 files changed

  1. M crates/budctl-core/src/devices/realme_t310/codec.rs +1 −58
  2. M crates/budctl-core/src/devices/realme_t310/link.rs +0 −12
  3. M crates/budctl-core/src/devices/realme_t310/mod.rs +11 −92
  4. M docs/protocol/realme_t310.md +6 −10
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -3,7 +3,7 @@
33
44 use crate::error::{Error, Result};
55 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};
77
88 /// Spec: realme_t310.md#bitmap
99 pub(super) const HANDSHAKE: u16 = 0x0100;
@@ -47,43 +47,6 @@ pub(super) const EVENTS_OFFERED: u16 = 0x0200;
4747 pub(super) const SUBSCRIBE: u16 = 0x0205;
4848 /// Spec: realme_t310.md#fit-test
4949 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−}
8750
8851 /// Spec: realme_t310.md#identity
8952 pub(super) const T310_PRODUCT_ID: u32 = 0x06_3C_12;
@@ -939,24 +902,4 @@ mod tests {
939902 .unwrap();
940903 assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 04 04 06 03 00 02 01 0B");
941904 }
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− }
962905 }
M crates/budctl-core/src/devices/realme_t310/link.rs View
@@ -71,18 +71,6 @@ impl Link {
7171 Ok(())
7272 }
7373
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−
8674 /// Send a request and return the payload of its reply.
8775 pub(super) fn request(&mut self, cmd: u16, payload: &[u8]) -> Result<Vec<u8>> {
8876 let seq = self.send(cmd, payload)?;
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -45,9 +45,6 @@ const TOUCH_READ_BACK: ReadBack = ReadBack {
4545 first: Duration::from_millis(600),
4646 window: Duration::from_secs(2),
4747 };
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);
5148 /// Spec: realme_t310.md#write-read-back
5249 const READ_BACK_PAUSE: Duration = Duration::from_millis(300);
5350
@@ -105,8 +102,6 @@ fn unix_time() -> u32 {
105102 struct T310Session {
106103 link: Link,
107104 commands: Commands,
108− /// Whether the session subscribed to fit-test results. Spec: realme_t310.md#subscribe
109− fit_results: bool,
110105 }
111106
112107 /// Collects what a series of best-effort reads produced.
@@ -139,17 +134,11 @@ impl T310Session {
139134
140135 let offered = codec::decode_events_offered(&link.request(codec::EVENTS_OFFERED, &[])?)?;
141136 let subscription = codec::subscribe_payload(&offered, &commands);
142− let mut fit_results = false;
143137 if commands.allows(codec::SUBSCRIBE) && subscription.len() > 1 {
144138 // Some buds never acknowledge this, so budctl does not wait. Spec: realme_t310.md#subscribe
145139 link.send(codec::SUBSCRIBE, &subscription)?;
146− fit_results = subscription.iter().skip(1).any(|&code| code == codec::FIT_EVENT);
147140 }
148− Ok(Self {
149− link,
150− commands,
151− fit_results,
152− })
141+ Ok(Self { link, commands })
153142 }
154143
155144 /// 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 {
379368 })
380369 }
381370
371+ /// The T310 acks `0x0405` but runs no test, so budctl does not send it.
382372 /// Spec: realme_t310.md#fit-test
383373 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+ ))
417378 }
418379
419380 /// Spec: realme_t310.md#hold-cycle-write, #write-read-back
@@ -1065,55 +1026,13 @@ mod tests {
10651026 }
10661027
10671028 #[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() {
10861030 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();
10941032 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(_))));
11161036 script.assert_done();
1117− assert!(matches!(result, Err(Error::Unsupported(_))));
11181037 }
11191038 }
M docs/protocol/realme_t310.md View
@@ -361,7 +361,7 @@ Evidence: *vendor*
361361 <a id="writes"></a>
362362 ## 10. Writes
363363
364−**One at a time.** budctl adds a write only after the previous one passes its hardware checkpoint (see the [roadmap](#roadmap)). Sent so far: [§10.1](#anc-write) off/transparency/on (W1, verified *ours-2026-10-27*), the light/medium/deep levels (W2, verified *ours-2026-10-27*), and [§10.2](#switch-write) game mode (W3, verified *ours-2026-10-27*), and [§10.3](#eq-write) the built-in EQ presets (W4, verified *ours-2026-10-27*), the bass-boost switch `1D` (W5a, verified *ours-2026-10-27*), 3D sound `1B` (W5b, verified *ours-2026-10-27*), wind-noise reduction `1A` (W5c, verified *ours-2026-10-27*), and dual connection `11` (W5d, verified *ours-2026-10-27*); [§10.5](#find) find (W7, verified *ours-2026-10-27*); [§10.6](#gesture-write) touch controls (W8, verified *ours-2026-10-27*); [§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.
365365
366366 **General rule**:
367367 - A write reply is `0x84xx <status …>`, and `00` means accepted.
@@ -463,14 +463,8 @@ Evidence: *buds4*
463463 - `01` starts the test and `00` stops it.
464464 - **Result:** arrives as push `04 (<dev> <result>)…`, where `1` = good, `0` = average and `6` = poor. The push needs event `04` subscribed.
465465 - **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.
474468
475469 <a id="custom-eq"></a>
476470 ### 10.9 Custom EQ: list `0x0122`, write `0x0418`
@@ -506,6 +500,7 @@ Evidence: *vendor*
506500 9. Whether the minimum 200 ms spacing between sends is needed.
507501 10. Why the T310 ignores `0x0124` although bit 38 is set ([§9.7](#bass-read)). Needs a realme Link capture before W6.
508502 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.
509504
510505 <a id="roadmap"></a>
511506 ## 13. Roadmap and hardware checkpoints
@@ -529,7 +524,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go
529524 | W7 | `find` |
530525 | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) |
531526 | W9 | `touch cycle` |
532−| W10 | `fit-test` |
527+| W10 | `fit-test`: acked but does nothing on the T310; parked ([§10.8](#fit-test)) |
533528 | W11 | custom EQ (create, then save, then delete) |
534529
535530 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
539534
540535 | Date | Checkpoint | Result | Notes |
541536 |---|---|---|---|
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) |
542538 | 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) |
543539 | 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) |
544540 | 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) |