Krolyxon
Commit 7a29fd2

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-27 17:35 +0530
Parent
dd7c4d8
Commit
7a29fd20cd6927b3c8fef0d3031402e4183656b9

+121 −2

7 files changed

  1. M crates/budctl-cli/src/main.rs +30 −0
  2. M crates/budctl-cli/src/output.rs +20 −0
  3. M crates/budctl-cli/tests/cli.rs +7 −0
  4. M crates/budctl-core/src/devices/mod.rs +6 −0
  5. M crates/budctl-core/src/devices/realme_t310/codec.rs +13 −0
  6. M crates/budctl-core/src/devices/realme_t310/mod.rs +41 −0
  7. M docs/protocol/realme_t310.md +4 −2
M crates/budctl-cli/src/main.rs View
@@ -55,6 +55,14 @@ enum Command {
5555 #[arg(value_enum)]
5656 state: OnOff,
5757 },
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+ },
5866 /// Turn game mode (low latency) on or off, then read it back.
5967 Game {
6068 #[arg(value_enum)]
@@ -62,6 +70,12 @@ enum Command {
6270 },
6371 }
6472
73+#[derive(Clone, Copy, ValueEnum)]
74+enum FindAction {
75+ Start,
76+ Stop,
77+}
78+
6579 #[derive(Clone, Copy, ValueEnum)]
6680 enum OnOff {
6781 On,
@@ -124,6 +138,17 @@ fn main() -> ExitCode {
124138 }
125139
126140 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+ }
127152 let found = budctl_core::scan()?;
128153 if let Command::List = cli.command {
129154 return output::list(out, &found, cli.json);
@@ -153,6 +178,11 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
153178 let now = session.set_switch(switch, matches!(state, OnOff::On))?;
154179 output::switch(out, &device, model, &now, cli.json)
155180 }
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+ }
156186 Command::Game { state } => {
157187 let now = session.set_switch("game", matches!(state, OnOff::On))?;
158188 output::switch(out, &device, model, &now, cli.json)
M crates/budctl-cli/src/output.rs View
@@ -245,3 +245,23 @@ pub fn eq(
245245 writeln!(out, " EQ {now}")?;
246246 Ok(())
247247 }
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+}
M crates/budctl-cli/tests/cli.rs View
@@ -25,3 +25,10 @@ fn usage_errors_exit_1() {
2525 assert_eq!(out.status.code(), Some(1), "{args:?}");
2626 }
2727 }
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+}
M crates/budctl-core/src/devices/mod.rs View
@@ -55,6 +55,12 @@ pub trait Session: Send {
5555 ))
5656 }
5757
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+
5864 /// Turn an on/off setting ([`Switch::key`]) on or off, then read it back.
5965 fn set_switch(&mut self, key: &str, _on: bool) -> Result<Switch> {
6066 Err(Error::Unsupported(format!("budctl cannot change {key} on these buds")))
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -25,6 +25,13 @@ pub(super) const NOISE_CONTROL_WRITE: u16 = 0x0404;
2525 pub(super) const SWITCH_WRITE: u16 = 0x0403;
2626 /// Spec: realme_t310.md#eq-write
2727 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+}
2835 /// Spec: realme_t310.md#eq-read
2936 pub(super) const EQ_PRESET: u16 = 0x010F;
3037 /// Spec: realme_t310.md#codec
@@ -583,6 +590,12 @@ mod tests {
583590 }
584591 }
585592
593+ #[test]
594+ fn find_payloads() {
595+ assert_eq!(find_payload(true), [0x01]);
596+ assert_eq!(find_payload(false), [0x00]);
597+ }
598+
586599 #[test]
587600 fn write_replies() {
588601 assert!(check_write_reply(&hex("00"), "the write").is_ok());
M crates/budctl-core/src/devices/realme_t310/mod.rs View
@@ -292,6 +292,15 @@ impl Session for T310Session {
292292 )
293293 }
294294
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+
295304 /// Spec: realme_t310.md#switch-write, #write-read-back
296305 fn set_switch(&mut self, key: &str, on: bool) -> Result<Switch> {
297306 let Some(id) = codec::switch_id(key) else {
@@ -736,4 +745,36 @@ mod tests {
736745 other => panic!("expected a usage error, got {other:?}"),
737746 }
738747 }
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+ }
739780 }
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*). 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.
365365
366366 **General rule**:
367367 - A write reply is `0x84xx <status …>`, and `00` means accepted.
@@ -418,8 +418,9 @@ Evidence: *vendor*
418418 Evidence: *buds4*
419419
420420 - `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.
422422 - 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.
423424
424425 <a id="gesture-write"></a>
425426 ### 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
516517
517518 | Date | Checkpoint | Result | Notes |
518519 |---|---|---|---|
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) |
519521 | 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) |
520522 | 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) |
521523 | 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) |