budctl9c570ea
Drop the fit test (W10)
The owner dropped the fit test: the T310 acks 0x0405 but runs no test. budctl has no `fit-test` command any more, and the code is back to its W9 state. The spec keeps the W10 result, and the implementation is in 8fdeefb. No new dependencies.
+13 −113
7 files changed
- M CLAUDE.md +2 −2
- M crates/budctl-cli/src/main.rs +4 −25
- M crates/budctl-cli/src/output.rs +1 −34
- M crates/budctl-core/src/devices/mod.rs +1 −15
- M crates/budctl-core/src/devices/realme_t310/mod.rs +1 −23
- M crates/budctl-core/src/status.rs +0 −9
- M docs/protocol/realme_t310.md +4 −5
| @@ -81,7 +81,7 @@ budctl --json status # machine-readable output for scripts/status ba | |||
| 81 | 81 | budctl --debug ... # hex-dump every frame sent/received (stderr) | |
| 82 | 82 | ``` | |
| 83 | 83 | ||
| 84 | − | Write commands (`anc`, `eq`, `game`, `set`, `bass`, `touch <side> …`, `find`, `fit-test`, custom EQ) are added one at a time, following rule 4. The plan is in `docs/protocol/realme_t310.md#roadmap`. | |
| 84 | + | Write commands (`anc`, `eq`, `game`, `set`, `bass`, `touch <side> …`, `find`, custom EQ) are added one at a time, following rule 4. The plan is in `docs/protocol/realme_t310.md#roadmap`. | |
| 85 | 85 | ||
| 86 | 86 | Exit codes: | |
| 87 | 87 | ||
| @@ -107,7 +107,7 @@ Exit codes: | |||
| 107 | 107 | 7. find | |
| 108 | 108 | 8. touch controls | |
| 109 | 109 | 9. hold cycle | |
| 110 | − | 10. fit test | |
| 110 | + | 10. ~~fit test~~ (dropped: the T310 acks it but runs no test) | |
| 111 | 111 | 11. custom EQ | |
| 112 | 112 | 4. **GUI**: Quickshell/Aurora via `--json`, or a Rust GUI on top of the same core. | |
| 113 | 113 | 5. **More devices**: as acquired. | |
| @@ -77,17 +77,11 @@ 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: StartStop, | |
| 80 | + | action: FindAction, | |
| 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 | − | }, | |
| 91 | 85 | /// Turn game mode (low latency) on or off, then read it back. | |
| 92 | 86 | Game { | |
| 93 | 87 | #[arg(value_enum)] | |
| @@ -96,7 +90,7 @@ enum Command { | |||
| 96 | 90 | } | |
| 97 | 91 | ||
| 98 | 92 | #[derive(Clone, Copy, ValueEnum)] | |
| 99 | − | enum StartStop { | |
| 93 | + | enum FindAction { | |
| 100 | 94 | Start, | |
| 101 | 95 | Stop, | |
| 102 | 96 | } | |
| @@ -212,7 +206,7 @@ fn main() -> ExitCode { | |||
| 212 | 206 | fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |
| 213 | 207 | // Checked before connecting: ringing is loud. Spec: realme_t310.md#find | |
| 214 | 208 | if let Command::Find { | |
| 215 | − | action: StartStop::Start, | |
| 209 | + | action: FindAction::Start, | |
| 216 | 210 | yes: false, | |
| 217 | 211 | } = cli.command | |
| 218 | 212 | { | |
| @@ -269,25 +263,10 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 269 | 263 | output::switch(out, &device, model, &now, cli.json) | |
| 270 | 264 | } | |
| 271 | 265 | Command::Find { action, .. } => { | |
| 272 | − | let ring = matches!(action, StartStop::Start); | |
| 266 | + | let ring = matches!(action, FindAction::Start); | |
| 273 | 267 | session.find(ring)?; | |
| 274 | 268 | output::find(out, &device, model, ring, cli.json) | |
| 275 | 269 | } | |
| 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 | − | } | |
| 291 | 270 | Command::Game { state } => { | |
| 292 | 271 | let now = session.set_switch("game", matches!(state, OnOff::On))?; | |
| 293 | 272 | 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::{BudFit, Charge, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls}; | |
| 7 | + | use budctl_core::status::{Charge, JSON_SCHEMA, NoiseControl, Status, Switch, TouchChange, TouchControls}; | |
| 8 | 8 | use budctl_core::{Error, Found}; | |
| 9 | 9 | use serde_json::Value; | |
| 10 | 10 | ||
| @@ -294,39 +294,6 @@ 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 | − | ||
| 330 | 297 | /// After `find start` or `find stop`: the buds acked; there is nothing to read back. | |
| 331 | 298 | pub fn find( | |
| 332 | 299 | 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::{BudFit, NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls}; | |
| 9 | + | use crate::status::{NoiseControl, NoiseMode, Status, Switch, TouchChange, TouchControls}; | |
| 10 | 10 | use crate::transport::Transport; | |
| 11 | 11 | ||
| 12 | 12 | pub mod realme_t310; | |
| @@ -66,20 +66,6 @@ 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 | − | ||
| 83 | 69 | /// Set the noise-control modes a hold steps through (`off`, `transparency`, `anc`), then | |
| 84 | 70 | /// read it back. Returns the modes the buds now report. | |
| 85 | 71 | fn set_hold_cycle(&mut self, _modes: &[&str]) -> Result<Vec<String>> { | |
| @@ -9,9 +9,7 @@ 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::{ | |
| 13 | − | BudFit, GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls, | |
| 14 | − | }; | |
| 12 | + | use crate::status::{GestureChange, NoiseControl, NoiseMode, RawWrite, Status, Switch, TouchChange, TouchControls}; | |
| 15 | 13 | use crate::transport::Transport; | |
| 16 | 14 | ||
| 17 | 15 | mod codec; | |
| @@ -368,15 +366,6 @@ impl Session for T310Session { | |||
| 368 | 366 | }) | |
| 369 | 367 | } | |
| 370 | 368 | ||
| 371 | − | /// The T310 acks `0x0405` but runs no test, so budctl does not send it. | |
| 372 | − | /// Spec: realme_t310.md#fit-test | |
| 373 | − | fn fit_test(&mut self) -> Result<Vec<BudFit>> { | |
| 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 | − | )) | |
| 378 | − | } | |
| 379 | − | ||
| 380 | 369 | /// Spec: realme_t310.md#hold-cycle-write, #write-read-back | |
| 381 | 370 | fn set_hold_cycle(&mut self, modes: &[&str]) -> Result<Vec<String>> { | |
| 382 | 371 | let mask = codec::hold_cycle_mask(modes)?; | |
| @@ -1024,15 +1013,4 @@ mod tests { | |||
| 1024 | 1013 | script.assert_done(); | |
| 1025 | 1014 | assert!(matches!(result, Err(Error::Usage(_)))); | |
| 1026 | 1015 | } | |
| 1027 | − | ||
| 1028 | − | #[test] | |
| 1029 | − | fn the_fit_test_is_refused_and_nothing_is_sent() { | |
| 1030 | − | let mut steps = handshake(); | |
| 1031 | − | steps.pop(); | |
| 1032 | − | let script = Script::new(steps); | |
| 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(_)))); | |
| 1036 | − | script.assert_done(); | |
| 1037 | − | } | |
| 1038 | 1016 | } | |
| @@ -113,15 +113,6 @@ 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 | − | ||
| 125 | 116 | /// A touch-control change: what changes, the write that does it and, once sent, the table the | |
| 126 | 117 | /// buds report afterwards. | |
| 127 | 118 | #[derive(Debug, Serialize)] | |
| @@ -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*). 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. | |
| 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; it is dropped. 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. | |
| @@ -464,7 +464,7 @@ Evidence: *buds4* | |||
| 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 | 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. | |
| 467 | + | - **budctl does not send it.** The owner dropped the fit test (2026-10-27), and budctl has no `fit-test` command. The W10 implementation is in git history (commit `8fdeefb`): it waited 15 s for the event-`04` push and sent stop if none came. | |
| 468 | 468 | ||
| 469 | 469 | <a id="custom-eq"></a> | |
| 470 | 470 | ### 10.9 Custom EQ: list `0x0122`, write `0x0418` | |
| @@ -500,7 +500,6 @@ Evidence: *vendor* | |||
| 500 | 500 | 9. Whether the minimum 200 ms spacing between sends is needed. | |
| 501 | 501 | 10. Why the T310 ignores `0x0124` although bit 38 is set ([§9.7](#bass-read)). Needs a realme Link capture before W6. | |
| 502 | 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. | |
| 504 | 503 | ||
| 505 | 504 | <a id="roadmap"></a> | |
| 506 | 505 | ## 13. Roadmap and hardware checkpoints | |
| @@ -524,7 +523,7 @@ The owner runs each checkpoint with `--debug` and pastes the output. Results go | |||
| 524 | 523 | | W7 | `find` | | |
| 525 | 524 | | W8 | `touch <side> <gesture> <action>` (`--dry-run` first) | | |
| 526 | 525 | | W9 | `touch cycle` | | |
| 527 | − | | W10 | `fit-test`: acked but does nothing on the T310; parked ([§10.8](#fit-test)) | | |
| 526 | + | | W10 | ~~`fit-test`~~: dropped, the T310 runs no test ([§10.8](#fit-test)) | | |
| 528 | 527 | | W11 | custom EQ (create, then save, then delete) | | |
| 529 | 528 | ||
| 530 | 529 | Ask for an Android HCI snoop of realme Link before W11, and for any write that does nothing. | |
| @@ -534,7 +533,7 @@ Ask for an Android HCI snoop of realme Link before W11, and for any write that d | |||
| 534 | 533 | ||
| 535 | 534 | | Date | Checkpoint | Result | Notes | | |
| 536 | 535 | |---|---|---|---| | |
| 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) | | |
| 536 | + | | 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. The owner dropped the fit test. See [§10.8](#fit-test) | | |
| 538 | 537 | | 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) | | |
| 539 | 538 | | 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) | | |
| 540 | 539 | | 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) | | |