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.
+266 −9
8 files changed
- M crates/budctl-cli/src/main.rs +25 −4
- M crates/budctl-cli/src/output.rs +34 −1
- M crates/budctl-core/src/devices/mod.rs +15 −1
- M crates/budctl-core/src/devices/realme_t310/codec.rs +58 −1
- M crates/budctl-core/src/devices/realme_t310/link.rs +12 −0
- M crates/budctl-core/src/devices/realme_t310/mod.rs +105 −2
- M crates/budctl-core/src/status.rs +9 −0
- M docs/protocol/realme_t310.md +8 −0
| @@ -77,11 +77,17 @@ enum Command { | |||
| 77 | 77 | /// Ring the buds so you can find them (loud: take them out of your ears first). | |
| 78 | 78 | Find { | |
| 79 | 79 | #[arg(value_enum)] | |
| 80 | − | action: FindAction, | |
| 80 | + | action: StartStop, | |
| 81 | 81 | /// Confirm that the buds are out of your ears. Needed to start ringing. | |
| 82 | 82 | #[arg(long)] | |
| 83 | 83 | yes: bool, | |
| 84 | 84 | }, | |
| 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 | + | }, | |
| 85 | 91 | /// Turn game mode (low latency) on or off, then read it back. | |
| 86 | 92 | Game { | |
| 87 | 93 | #[arg(value_enum)] | |
| @@ -90,7 +96,7 @@ enum Command { | |||
| 90 | 96 | } | |
| 91 | 97 | ||
| 92 | 98 | #[derive(Clone, Copy, ValueEnum)] | |
| 93 | − | enum FindAction { | |
| 99 | + | enum StartStop { | |
| 94 | 100 | Start, | |
| 95 | 101 | Stop, | |
| 96 | 102 | } | |
| @@ -206,7 +212,7 @@ fn main() -> ExitCode { | |||
| 206 | 212 | fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |
| 207 | 213 | // Checked before connecting: ringing is loud. Spec: realme_t310.md#find | |
| 208 | 214 | if let Command::Find { | |
| 209 | − | action: FindAction::Start, | |
| 215 | + | action: StartStop::Start, | |
| 210 | 216 | yes: false, | |
| 211 | 217 | } = cli.command | |
| 212 | 218 | { | |
| @@ -263,10 +269,25 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 263 | 269 | output::switch(out, &device, model, &now, cli.json) | |
| 264 | 270 | } | |
| 265 | 271 | Command::Find { action, .. } => { | |
| 266 | − | let ring = matches!(action, FindAction::Start); | |
| 272 | + | let ring = matches!(action, StartStop::Start); | |
| 267 | 273 | session.find(ring)?; | |
| 268 | 274 | output::find(out, &device, model, ring, cli.json) | |
| 269 | 275 | } | |
| 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 | + | } | |
| 270 | 291 | Command::Game { state } => { | |
| 271 | 292 | let now = session.set_switch("game", matches!(state, OnOff::On))?; | |
| 272 | 293 | output::switch(out, &device, model, &now, cli.json) | |
| @@ -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, TouchChange, TouchControls}; | |
| 7 | + | use budctl_core::status::{BudFit, Charge, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls}; | |
| 8 | 8 | use budctl_core::{Error, Found}; | |
| 9 | 9 | use serde_json::Value; | |
| 10 | 10 | ||
| @@ -294,6 +294,39 @@ pub fn eq( | |||
| 294 | 294 | Ok(()) | |
| 295 | 295 | } | |
| 296 | 296 | ||
| 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 | + | ||
| 297 | 330 | /// After `find start` or `find stop`: the buds acked; there is nothing to read back. | |
| 298 | 331 | pub fn find( | |
| 299 | 332 | out: &mut impl Write, | |
| @@ -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, TouchChange, TouchControls}; | |
| 9 | + | use crate::status::{BudFit, NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls}; | |
| 10 | 10 | use crate::transport::Transport; | |
| 11 | 11 | ||
| 12 | 12 | pub mod realme_t310; | |
| @@ -66,6 +66,20 @@ pub trait Session: Send { | |||
| 66 | 66 | Err(Error::Unsupported(format!("budctl cannot change {key} on these buds"))) | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 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 | + | ||
| 69 | 83 | /// Set the noise-control modes a hold steps through (`off`, `transparency`, `anc`), then | |
| 70 | 84 | /// read it back. Returns the modes the buds now report. | |
| 71 | 85 | fn set_hold_cycle(&mut self, _modes: &[&str]) -> Result<Vec<String>> { | |
| @@ -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, Charge, Gesture, NoiseControl, NoiseMode, Switch}; | |
| 6 | + | use crate::status::{AncLevel, Battery, BudFit, Charge, Gesture, NoiseControl, NoiseMode, Switch}; | |
| 7 | 7 | ||
| 8 | 8 | /// Spec: realme_t310.md#bitmap | |
| 9 | 9 | pub(super) const HANDSHAKE: u16 = 0x0100; | |
| @@ -47,6 +47,43 @@ 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 | + | } | |
| 50 | 87 | ||
| 51 | 88 | /// Spec: realme_t310.md#identity | |
| 52 | 89 | pub(super) const T310_PRODUCT_ID: u32 = 0x06_3C_12; | |
| @@ -902,4 +939,24 @@ mod tests { | |||
| 902 | 939 | .unwrap(); | |
| 903 | 940 | assert_eq!(crate::debug::hex(&frame), "AA 0A 00 00 04 04 06 03 00 02 01 0B"); | |
| 904 | 941 | } | |
| 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 | + | } | |
| 905 | 962 | } | |
| @@ -71,6 +71,18 @@ 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 | + | ||
| 74 | 86 | /// Send a request and return the payload of its reply. | |
| 75 | 87 | pub(super) fn request(&mut self, cmd: u16, payload: &[u8]) -> Result<Vec<u8>> { | |
| 76 | 88 | let seq = self.send(cmd, payload)?; | |
| @@ -9,7 +9,9 @@ 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::{GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls}; | |
| 12 | + | use crate::status::{ | |
| 13 | + | BudFit, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls, | |
| 14 | + | }; | |
| 13 | 15 | use crate::transport::Transport; | |
| 14 | 16 | ||
| 15 | 17 | mod codec; | |
| @@ -43,6 +45,9 @@ const TOUCH_READ_BACK: ReadBack = ReadBack { | |||
| 43 | 45 | first: Duration::from_millis(600), | |
| 44 | 46 | window: Duration::from_secs(2), | |
| 45 | 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); | |
| 46 | 51 | /// Spec: realme_t310.md#write-read-back | |
| 47 | 52 | const READ_BACK_PAUSE: Duration = Duration::from_millis(300); | |
| 48 | 53 | ||
| @@ -100,6 +105,8 @@ fn unix_time() -> u32 { | |||
| 100 | 105 | struct T310Session { | |
| 101 | 106 | link: Link, | |
| 102 | 107 | commands: Commands, | |
| 108 | + | /// Whether the session subscribed to fit-test results. Spec: realme_t310.md#subscribe | |
| 109 | + | fit_results: bool, | |
| 103 | 110 | } | |
| 104 | 111 | ||
| 105 | 112 | /// Collects what a series of best-effort reads produced. | |
| @@ -132,11 +139,17 @@ impl T310Session { | |||
| 132 | 139 | ||
| 133 | 140 | let offered = codec::decode_events_offered(&link.request(codec::EVENTS_OFFERED, &[])?)?; | |
| 134 | 141 | let subscription = codec::subscribe_payload(&offered, &commands); | |
| 142 | + | let mut fit_results = false; | |
| 135 | 143 | if commands.allows(codec::SUBSCRIBE) && subscription.len() > 1 { | |
| 136 | 144 | // Some buds never acknowledge this, so budctl does not wait. Spec: realme_t310.md#subscribe | |
| 137 | 145 | link.send(codec::SUBSCRIBE, &subscription)?; | |
| 146 | + | fit_results = subscription.iter().skip(1).any(|&code| code == codec::FIT_EVENT); | |
| 138 | 147 | } | |
| 139 | − | Ok(Self { link, commands }) | |
| 148 | + | Ok(Self { | |
| 149 | + | link, | |
| 150 | + | commands, | |
| 151 | + | fit_results, | |
| 152 | + | }) | |
| 140 | 153 | } | |
| 141 | 154 | ||
| 142 | 155 | /// 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 { | |||
| 366 | 379 | }) | |
| 367 | 380 | } | |
| 368 | 381 | ||
| 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 | + | ||
| 369 | 419 | /// Spec: realme_t310.md#hold-cycle-write, #write-read-back | |
| 370 | 420 | fn set_hold_cycle(&mut self, modes: &[&str]) -> Result<Vec<String>> { | |
| 371 | 421 | let mask = codec::hold_cycle_mask(modes)?; | |
| @@ -1013,4 +1063,57 @@ mod tests { | |||
| 1013 | 1063 | script.assert_done(); | |
| 1014 | 1064 | assert!(matches!(result, Err(Error::Usage(_)))); | |
| 1015 | 1065 | } | |
| 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 | + | } | |
| 1016 | 1119 | } | |
| @@ -113,6 +113,15 @@ pub struct Gesture { | |||
| 113 | 113 | pub group: Option<u8>, | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 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 | + | ||
| 116 | 125 | /// A touch-control change: what changes, the write that does it and, once sent, the table the | |
| 117 | 126 | /// buds report afterwards. | |
| 118 | 127 | #[derive(Debug, Serialize)] | |
| @@ -463,6 +463,14 @@ 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 | 474 | ||
| 467 | 475 | <a id="custom-eq"></a> | |
| 468 | 476 | ### 10.9 Custom EQ: list `0x0122`, write `0x0418` | |