Krolyxon
Commit 4c49f6c

Branches

Refs →

budctl4c49f6c

budctl fit-test [start|stop], as the spec describes it (W10)

This sends 0x0405 01 (bitmap bit 9) and waits up to 15 s for the
event-04 result push. If no result comes it sends 0x0405 00 and exits 4.
It runs only when the session subscribed to event 04.
`fit-test stop` sends 0x0405 00.

On the owner's T310 (W10, 2026-10-27) this does not work: start and stop
were acked, but no frame and no sound came in 15 s. The next commit
makes the T310 refuse the fit test. This commit keeps the implementation
in history in case a realme Link capture shows another way.

No new dependencies.
Author
krolyxon
Date
2026-10-27 20:31 +0530
Parent
83c5894
Commit
4c49f6cddb2afe879b5a09a79466cf166729d227

+266 −9

8 files changed

  1. M crates/budctl-cli/src/main.rs +25 −4
  2. M crates/budctl-cli/src/output.rs +34 −1
  3. M crates/budctl-core/src/devices/mod.rs +15 −1
  4. M crates/budctl-core/src/devices/realme_t310/codec.rs +58 −1
  5. M crates/budctl-core/src/devices/realme_t310/link.rs +12 −0
  6. M crates/budctl-core/src/devices/realme_t310/mod.rs +105 −2
  7. M crates/budctl-core/src/status.rs +9 −0
  8. M docs/protocol/realme_t310.md +8 −0
M crates/budctl-cli/src/main.rs View
@@ -77,11 +77,17 @@ enum Command {
7777 /// Ring the buds so you can find them (loud: take them out of your ears first).
7878 Find {
7979 #[arg(value_enum)]
80− action: FindAction,
80+ action: StartStop,
8181 /// Confirm that the buds are out of your ears. Needed to start ringing.
8282 #[arg(long)]
8383 yes: bool,
8484 },
85+ /// Check how well each bud seals in your ear. Keep both buds in your ears; it takes about 10 s.
86+ FitTest {
87+ /// `stop` ends a test that is still running.
88+ #[arg(value_enum, default_value = "start")]
89+ action: StartStop,
90+ },
8591 /// Turn game mode (low latency) on or off, then read it back.
8692 Game {
8793 #[arg(value_enum)]
@@ -90,7 +96,7 @@ enum Command {
9096 }
9197
9298 #[derive(Clone, Copy, ValueEnum)]
93−enum FindAction {
99+enum StartStop {
94100 Start,
95101 Stop,
96102 }
@@ -206,7 +212,7 @@ fn main() -> ExitCode {
206212 fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
207213 // Checked before connecting: ringing is loud. Spec: realme_t310.md#find
208214 if let Command::Find {
209− action: FindAction::Start,
215+ action: StartStop::Start,
210216 yes: false,
211217 } = cli.command
212218 {
@@ -263,10 +269,25 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
263269 output::switch(out, &device, model, &now, cli.json)
264270 }
265271 Command::Find { action, .. } => {
266− let ring = matches!(action, FindAction::Start);
272+ let ring = matches!(action, StartStop::Start);
267273 session.find(ring)?;
268274 output::find(out, &device, model, ring, cli.json)
269275 }
276+ Command::FitTest {
277+ action: StartStop::Start,
278+ } => {
279+ if !cli.json {
280+ eprintln!("Running the fit test: keep both buds in your ears…");
281+ }
282+ let buds = session.fit_test()?;
283+ output::fit_test(out, &device, model, &buds, cli.json)
284+ }
285+ Command::FitTest {
286+ action: StartStop::Stop,
287+ } => {
288+ session.stop_fit_test()?;
289+ output::fit_test_stopped(out, &device, model, cli.json)
290+ }
270291 Command::Game { state } => {
271292 let now = session.set_switch("game", matches!(state, OnOff::On))?;
272293 output::switch(out, &device, model, &now, cli.json)
M crates/budctl-cli/src/output.rs View
@@ -4,7 +4,7 @@ use std::io::{self, Write};
44
55 use budctl_core::bluez::PairedDevice;
66 use budctl_core::devices::Device;
7−use budctl_core::status::{Charge, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls};
7+use budctl_core::status::{BudFit, Charge, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls};
88 use budctl_core::{Error, Found};
99 use serde_json::Value;
1010
@@ -294,6 +294,39 @@ pub fn eq(
294294 Ok(())
295295 }
296296
297+/// The fit-test result of each bud.
298+pub fn fit_test(
299+ out: &mut impl Write,
300+ device: &PairedDevice,
301+ model: &dyn Device,
302+ buds: &[BudFit],
303+ as_json: bool,
304+) -> Result<(), Failure> {
305+ if as_json {
306+ return json(out, Some((device, model)), serde_json::json!({ "fit": buds }));
307+ }
308+ header(out, device, model)?;
309+ for bud in buds {
310+ writeln!(out, " {:<6} {}", bud.side, bud.fit)?;
311+ }
312+ Ok(())
313+}
314+
315+/// After `fit-test stop`: the buds acked.
316+pub fn fit_test_stopped(
317+ out: &mut impl Write,
318+ device: &PairedDevice,
319+ model: &dyn Device,
320+ as_json: bool,
321+) -> Result<(), Failure> {
322+ if as_json {
323+ return json(out, Some((device, model)), serde_json::json!({ "fit_test": "stopped" }));
324+ }
325+ header(out, device, model)?;
326+ writeln!(out, " Fit test stopped.")?;
327+ Ok(())
328+}
329+
297330 /// After `find start` or `find stop`: the buds acked; there is nothing to read back.
298331 pub fn find(
299332 out: &mut impl Write,
M crates/budctl-core/src/devices/mod.rs View
@@ -6,7 +6,7 @@
66 use crate::bluez::PairedDevice;
77 use crate::bt::Uuid;
88 use crate::error::{Error, Result};
9−use crate::status::{NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls};
9+use crate::status::{BudFit, NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls};
1010 use crate::transport::Transport;
1111
1212 pub mod realme_t310;
@@ -66,6 +66,20 @@ pub trait Session: Send {
6666 Err(Error::Unsupported(format!("budctl cannot change {key} on these buds")))
6767 }
6868
69+ /// Run a fit test and wait for its result. Both buds must be in the ears.
70+ fn fit_test(&mut self) -> Result<Vec<BudFit>> {
71+ Err(Error::Unsupported(
72+ "these buds have no fit test budctl can run".to_owned(),
73+ ))
74+ }
75+
76+ /// Stop a fit test that is still running.
77+ fn stop_fit_test(&mut self) -> Result<()> {
78+ Err(Error::Unsupported(
79+ "these buds have no fit test budctl can run".to_owned(),
80+ ))
81+ }
82+
6983 /// Set the noise-control modes a hold steps through (`off`, `transparency`, `anc`), then
7084 /// read it back. Returns the modes the buds now report.
7185 fn set_hold_cycle(&mut self, _modes: &[&str]) -> Result<Vec<String>> {
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, Charge, Gesture, NoiseControl, NoiseMode, Switch};
6+use crate::status::{AncLevel, Battery, BudFit, Charge, Gesture, NoiseControl, NoiseMode, Switch};
77
88 /// Spec: realme_t310.md#bitmap
99 pub(super) const HANDSHAKE: u16 = 0x0100;
@@ -47,6 +47,43 @@ 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+}
5087
5188 /// Spec: realme_t310.md#identity
5289 pub(super) const T310_PRODUCT_ID: u32 = 0x06_3C_12;
@@ -902,4 +939,24 @@ mod tests {
902939 .unwrap();
903940 assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 04 04 06 03 00 02 01 0B");
904941 }
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+ }
905962 }
M crates/budctl-core/src/devices/realme_t310/link.rs View
@@ -71,6 +71,18 @@ 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+
7486 /// Send a request and return the payload of its reply.
7587 pub(super) fn request(&mut self, cmd: u16, payload: &[u8]) -> Result<Vec<u8>> {
7688 let seq = self.send(cmd, payload)?;
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -9,7 +9,9 @@ use crate::bluez::PairedDevice;
99 use crate::bt::Uuid;
1010 use crate::devices::{Device, Session};
1111 use crate::error::{Error, Kind, Result};
12−use crate::status::{GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls};
12+use crate::status::{
13+ BudFit, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls,
14+};
1315 use crate::transport::Transport;
1416
1517 mod codec;
@@ -43,6 +45,9 @@ const TOUCH_READ_BACK: ReadBack = ReadBack {
4345 first: Duration::from_millis(600),
4446 window: Duration::from_secs(2),
4547 };
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);
4651 /// Spec: realme_t310.md#write-read-back
4752 const READ_BACK_PAUSE: Duration = Duration::from_millis(300);
4853
@@ -100,6 +105,8 @@ fn unix_time() -> u32 {
100105 struct T310Session {
101106 link: Link,
102107 commands: Commands,
108+ /// Whether the session subscribed to fit-test results. Spec: realme_t310.md#subscribe
109+ fit_results: bool,
103110 }
104111
105112 /// Collects what a series of best-effort reads produced.
@@ -132,11 +139,17 @@ impl T310Session {
132139
133140 let offered = codec::decode_events_offered(&link.request(codec::EVENTS_OFFERED, &[])?)?;
134141 let subscription = codec::subscribe_payload(&offered, &commands);
142+ let mut fit_results = false;
135143 if commands.allows(codec::SUBSCRIBE) && subscription.len() > 1 {
136144 // Some buds never acknowledge this, so budctl does not wait. Spec: realme_t310.md#subscribe
137145 link.send(codec::SUBSCRIBE, &subscription)?;
146+ fit_results = subscription.iter().skip(1).any(|&code| code == codec::FIT_EVENT);
138147 }
139− Ok(Self { link, commands })
148+ Ok(Self {
149+ link,
150+ commands,
151+ fit_results,
152+ })
140153 }
141154
142155 /// Read and decode one value, if the buds offer the command. A read the buds answer badly
@@ -366,6 +379,43 @@ impl Session for T310Session {
366379 })
367380 }
368381
382+ /// Spec: realme_t310.md#fit-test
383+ 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")
417+ }
418+
369419 /// Spec: realme_t310.md#hold-cycle-write, #write-read-back
370420 fn set_hold_cycle(&mut self, modes: &[&str]) -> Result<Vec<String>> {
371421 let mask = codec::hold_cycle_mask(modes)?;
@@ -1013,4 +1063,57 @@ mod tests {
10131063 script.assert_done();
10141064 assert!(matches!(result, Err(Error::Usage(_))));
10151065 }
1066+
1067+ #[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() {
1086+ 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+ ]);
1094+ 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();
1116+ script.assert_done();
1117+ assert!(matches!(result, Err(Error::Unsupported(_))));
1118+ }
10161119 }
M crates/budctl-core/src/status.rs View
@@ -113,6 +113,15 @@ pub struct Gesture {
113113 pub group: Option<u8>,
114114 }
115115
116+/// One bud's result in a fit test.
117+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118+pub struct BudFit {
119+ /// `left` or `right`.
120+ pub side: String,
121+ /// `good`, `average` or `poor`, or `unknown (…)` for a value the device module cannot name.
122+ pub fit: String,
123+}
124+
116125 /// A touch-control change: what changes, the write that does it and, once sent, the table the
117126 /// buds report afterwards.
118127 #[derive(Debug, Serialize)]
M docs/protocol/realme_t310.md View
@@ -463,6 +463,14 @@ 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.
466474
467475 <a id="custom-eq"></a>
468476 ### 10.9 Custom EQ: list `0x0122`, write `0x0418`