budctl7a29fd2
budctl find start --yes | stop (W7)
This rings both buds with 0x0400 01 and stops them with 0x0400 00, gated by bitmap bit 5. It is ack-only: there is no state to read back. Ringing is loud, so `find start` exits 1 without --yes, before it connects. Verified on the owner's T310 (W7, 2026-10-27): both commands got 0x8400 00 within 18 ms. Both buds rang, kept ringing after budctl disconnected, and stopped on `find stop`. W6 (bass level) is parked until a realme Link capture shows how the app reads it, because the buds never answer 0x0124. No new dependencies.
+121 −2
7 files changed
- M crates/budctl-cli/src/main.rs +30 −0
- M crates/budctl-cli/src/output.rs +20 −0
- M crates/budctl-cli/tests/cli.rs +7 −0
- M crates/budctl-core/src/devices/mod.rs +6 −0
- M crates/budctl-core/src/devices/realme_t310/codec.rs +13 −0
- M crates/budctl-core/src/devices/realme_t310/mod.rs +41 −0
- M docs/protocol/realme_t310.md +4 −2
| @@ -55,6 +55,14 @@ enum Command { | |||
| 55 | 55 | #[arg(value_enum)] | |
| 56 | 56 | state: OnOff, | |
| 57 | 57 | }, | |
| 58 | + | /// Ring the buds so you can find them (loud: take them out of your ears first). | |
| 59 | + | Find { | |
| 60 | + | #[arg(value_enum)] | |
| 61 | + | action: FindAction, | |
| 62 | + | /// Confirm that the buds are out of your ears. Needed to start ringing. | |
| 63 | + | #[arg(long)] | |
| 64 | + | yes: bool, | |
| 65 | + | }, | |
| 58 | 66 | /// Turn game mode (low latency) on or off, then read it back. | |
| 59 | 67 | Game { | |
| 60 | 68 | #[arg(value_enum)] | |
| @@ -62,6 +70,12 @@ enum Command { | |||
| 62 | 70 | }, | |
| 63 | 71 | } | |
| 64 | 72 | ||
| 73 | + | #[derive(Clone, Copy, ValueEnum)] | |
| 74 | + | enum FindAction { | |
| 75 | + | Start, | |
| 76 | + | Stop, | |
| 77 | + | } | |
| 78 | + | ||
| 65 | 79 | #[derive(Clone, Copy, ValueEnum)] | |
| 66 | 80 | enum OnOff { | |
| 67 | 81 | On, | |
| @@ -124,6 +138,17 @@ fn main() -> ExitCode { | |||
| 124 | 138 | } | |
| 125 | 139 | ||
| 126 | 140 | fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |
| 141 | + | // Checked before connecting: ringing is loud. Spec: realme_t310.md#find | |
| 142 | + | if let Command::Find { | |
| 143 | + | action: FindAction::Start, | |
| 144 | + | yes: false, | |
| 145 | + | } = cli.command | |
| 146 | + | { | |
| 147 | + | return Err(Error::Usage( | |
| 148 | + | "this rings the buds loudly; take them out of your ears, then run `budctl find start --yes`".to_owned(), | |
| 149 | + | ) | |
| 150 | + | .into()); | |
| 151 | + | } | |
| 127 | 152 | let found = budctl_core::scan()?; | |
| 128 | 153 | if let Command::List = cli.command { | |
| 129 | 154 | return output::list(out, &found, cli.json); | |
| @@ -153,6 +178,11 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> { | |||
| 153 | 178 | let now = session.set_switch(switch, matches!(state, OnOff::On))?; | |
| 154 | 179 | output::switch(out, &device, model, &now, cli.json) | |
| 155 | 180 | } | |
| 181 | + | Command::Find { action, .. } => { | |
| 182 | + | let ring = matches!(action, FindAction::Start); | |
| 183 | + | session.find(ring)?; | |
| 184 | + | output::find(out, &device, model, ring, cli.json) | |
| 185 | + | } | |
| 156 | 186 | Command::Game { state } => { | |
| 157 | 187 | let now = session.set_switch("game", matches!(state, OnOff::On))?; | |
| 158 | 188 | output::switch(out, &device, model, &now, cli.json) | |
| @@ -245,3 +245,23 @@ pub fn eq( | |||
| 245 | 245 | writeln!(out, " EQ {now}")?; | |
| 246 | 246 | Ok(()) | |
| 247 | 247 | } | |
| 248 | + | ||
| 249 | + | /// After `find start` or `find stop`: the buds acked; there is nothing to read back. | |
| 250 | + | pub fn find( | |
| 251 | + | out: &mut impl Write, | |
| 252 | + | device: &PairedDevice, | |
| 253 | + | model: &dyn Device, | |
| 254 | + | ring: bool, | |
| 255 | + | as_json: bool, | |
| 256 | + | ) -> Result<(), Failure> { | |
| 257 | + | if as_json { | |
| 258 | + | return json(out, Some((device, model)), serde_json::json!({ "ringing": ring })); | |
| 259 | + | } | |
| 260 | + | header(out, device, model)?; | |
| 261 | + | if ring { | |
| 262 | + | writeln!(out, " Ringing. Stop with: budctl find stop")?; | |
| 263 | + | } else { | |
| 264 | + | writeln!(out, " Stopped ringing.")?; | |
| 265 | + | } | |
| 266 | + | Ok(()) | |
| 267 | + | } | |
| @@ -25,3 +25,10 @@ fn usage_errors_exit_1() { | |||
| 25 | 25 | assert_eq!(out.status.code(), Some(1), "{args:?}"); | |
| 26 | 26 | } | |
| 27 | 27 | } | |
| 28 | + | ||
| 29 | + | #[test] | |
| 30 | + | fn find_start_needs_yes_before_connecting() { | |
| 31 | + | let out = budctl(&["find", "start"]); | |
| 32 | + | assert_eq!(out.status.code(), Some(1)); | |
| 33 | + | assert!(String::from_utf8_lossy(&out.stderr).contains("--yes")); | |
| 34 | + | } | |
| @@ -55,6 +55,12 @@ pub trait Session: Send { | |||
| 55 | 55 | )) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | + | /// Start (`true`) or stop ringing the buds so they can be found. Only the ack can be | |
| 59 | + | /// checked: there is no state to read back. | |
| 60 | + | fn find(&mut self, _ring: bool) -> Result<()> { | |
| 61 | + | Err(Error::Unsupported("these buds cannot ring through budctl".to_owned())) | |
| 62 | + | } | |
| 63 | + | ||
| 58 | 64 | /// Turn an on/off setting ([`Switch::key`]) on or off, then read it back. | |
| 59 | 65 | fn set_switch(&mut self, key: &str, _on: bool) -> Result<Switch> { | |
| 60 | 66 | Err(Error::Unsupported(format!("budctl cannot change {key} on these buds"))) | |
| @@ -25,6 +25,13 @@ pub(super) const NOISE_CONTROL_WRITE: u16 = 0x0404; | |||
| 25 | 25 | pub(super) const SWITCH_WRITE: u16 = 0x0403; | |
| 26 | 26 | /// Spec: realme_t310.md#eq-write | |
| 27 | 27 | pub(super) const EQ_SELECT: u16 = 0x0406; | |
| 28 | + | /// Spec: realme_t310.md#find | |
| 29 | + | pub(super) const FIND: u16 = 0x0400; | |
| 30 | + | ||
| 31 | + | /// `01` starts ringing, `00` stops it. Spec: realme_t310.md#find | |
| 32 | + | pub(super) fn find_payload(ring: bool) -> [u8; 1] { | |
| 33 | + | [u8::from(ring)] | |
| 34 | + | } | |
| 28 | 35 | /// Spec: realme_t310.md#eq-read | |
| 29 | 36 | pub(super) const EQ_PRESET: u16 = 0x010F; | |
| 30 | 37 | /// Spec: realme_t310.md#codec | |
| @@ -583,6 +590,12 @@ mod tests { | |||
| 583 | 590 | } | |
| 584 | 591 | } | |
| 585 | 592 | ||
| 593 | + | #[test] | |
| 594 | + | fn find_payloads() { | |
| 595 | + | assert_eq!(find_payload(true), [0x01]); | |
| 596 | + | assert_eq!(find_payload(false), [0x00]); | |
| 597 | + | } | |
| 598 | + | ||
| 586 | 599 | #[test] | |
| 587 | 600 | fn write_replies() { | |
| 588 | 601 | assert!(check_write_reply(&hex("00"), "the write").is_ok()); | |
| @@ -292,6 +292,15 @@ impl Session for T310Session { | |||
| 292 | 292 | ) | |
| 293 | 293 | } | |
| 294 | 294 | ||
| 295 | + | /// Spec: realme_t310.md#find | |
| 296 | + | fn find(&mut self, ring: bool) -> Result<()> { | |
| 297 | + | if !self.commands.allows(codec::FIND) { | |
| 298 | + | return Err(Error::Unsupported("these buds cannot ring through budctl".to_owned())); | |
| 299 | + | } | |
| 300 | + | let reply = self.link.request(codec::FIND, &codec::find_payload(ring))?; | |
| 301 | + | codec::check_write_reply(&reply, if ring { "ringing" } else { "stopping the ringing" }) | |
| 302 | + | } | |
| 303 | + | ||
| 295 | 304 | /// Spec: realme_t310.md#switch-write, #write-read-back | |
| 296 | 305 | fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> { | |
| 297 | 306 | let Some(id) = codec::switch_id(key) else { | |
| @@ -736,4 +745,36 @@ mod tests { | |||
| 736 | 745 | other => panic!("expected a usage error, got {other:?}"), | |
| 737 | 746 | } | |
| 738 | 747 | } | |
| 748 | + | ||
| 749 | + | #[test] | |
| 750 | + | fn rings_and_stops() { | |
| 751 | + | let mut steps = handshake(); | |
| 752 | + | steps.extend([ | |
| 753 | + | tx(0x0400, 0x05, "01"), | |
| 754 | + | rx(0x8400, 0x05, "00"), | |
| 755 | + | tx(0x0400, 0x06, "00"), | |
| 756 | + | rx(0x8400, 0x06, "00"), | |
| 757 | + | ]); | |
| 758 | + | let script = Script::new(steps); | |
| 759 | + | let mut session = open(&script).unwrap(); | |
| 760 | + | session.find(true).unwrap(); | |
| 761 | + | session.find(false).unwrap(); | |
| 762 | + | script.assert_done(); | |
| 763 | + | } | |
| 764 | + | ||
| 765 | + | #[test] | |
| 766 | + | fn find_is_not_sent_without_its_bitmap_bit() { | |
| 767 | + | // Bits 0-4 only: no bit 5. | |
| 768 | + | let script = Script::new(vec![ | |
| 769 | + | tx(0x0100, 0x01, ""), | |
| 770 | + | rx(0x8100, 0x01, "00 1F"), | |
| 771 | + | tx(0x0103, 0x02, ""), | |
| 772 | + | rx(0x8103, 0x02, "00 12 3C 06"), | |
| 773 | + | tx(0x0200, 0x03, ""), | |
| 774 | + | rx(0x8200, 0x03, "00 00"), | |
| 775 | + | ]); | |
| 776 | + | let result = open(&script).unwrap().find(true); | |
| 777 | + | script.assert_done(); | |
| 778 | + | assert!(matches!(result, Err(Error::Unsupported(_)))); | |
| 779 | + | } | |
| 739 | 780 | } | |
| @@ -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*). 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*). 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. | |
| @@ -418,8 +418,9 @@ Evidence: *vendor* | |||
| 418 | 418 | Evidence: *buds4* | |
| 419 | 419 | ||
| 420 | 420 | - `01` starts ringing both buds and `00` stops it. The reply is `0x8400 00`. | |
| 421 | − | - **It is loud**, so budctl warns before ringing. | |
| 421 | + | - **It is loud.** `budctl find start` refuses to run (exit 1) without `--yes`, and checks that before it connects. `find stop` needs no flag. | |
| 422 | 422 | - Only the ack can be checked: there is no state to read back. | |
| 423 | + | - **On the owner's T310** (*ours-2026-10-27*, W7): `0x0400 01` and `0x0400 00` were each acked with `0x8400 00` within 18 ms. `01` rang both buds. **The ringing continues after budctl disconnects**, until `00` silences it, so budctl does not need to hold the link while the buds ring. | |
| 423 | 424 | ||
| 424 | 425 | <a id="gesture-write"></a> | |
| 425 | 426 | ### 10.6 Touch controls: `0x0401 <count> (<dev> <btn> <act> <fn>)…` | |
| @@ -516,6 +517,7 @@ Ask for an Android HCI snoop of realme Link before W11, and for any write that d | |||
| 516 | 517 | ||
| 517 | 518 | | Date | Checkpoint | Result | Notes | | |
| 518 | 519 | |---|---|---|---| | |
| 520 | + | | 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) | | |
| 519 | 521 | | 2026-10-27 | W5d `set dual on`, `off` | pass | Acked, the immediate read-back showed the change, and the laptop stayed connected. Only one host was connected. See [§10.2](#switch-write) | | |
| 520 | 522 | | 2026-10-27 | W5c `set wind off`, `on` | pass (protocol) | Acked, and the immediate read-back showed the change. Not audible in music. See [§10.2](#switch-write) | | |
| 521 | 523 | | 2026-10-27 | W5b `set spatial on`, `off` | pass | Acked, the immediate read-back showed the change, and the owner heard a difference. See [§10.2](#switch-write) | | |