budctl
budctl is a Linux CLI and GUI for controlling wireless earbuds without the vendor app. It has no telemetry and needs no account.
The first supported device is the realme Buds T310. More devices are added as plugins when the owner gets new earbuds.
Goals
- Control earbud features from Linux: ANC / transparency / off, EQ presets, game mode, touch controls, battery readout, and anything else the buds offer.
- Front ends over one core: the
budctlCLI and thebudctl-guidesktop app, and next an Android app (plan:docs/android.md), all thin layers over the same library (budctl-core). Scripts and the owner's Quickshell (Aurora) can usebudctl --json. - One small, readable codebase that the owner fully understands.
- Device support is pluggable. Adding a new pair of buds means adding one module under
crates/budctl-core/src/devices/and registering it indevices/mod.rs, without touching transport, CLI or GUI code. - Coexist with the phone app. Settings live on the buds, so budctl never stores device state locally beyond user config (
~/.config/budctl/config.json,budctl_core::config: default device and GUI preferences, created with defaults when missing; a broken file is reported and the defaults are used).
Non-goals
- No firmware dumping, flashing or modification. We only speak the buds' existing control protocol.
- No telemetry of any kind: no analytics, crash reports or usage data, and no update checks.
- No support for models the owner doesn't have. Don't speculatively add devices.
- No Windows or macOS support.
Hard rules
- No telemetry or cloud. Nothing may ever report on the user: no analytics, crash reports, usage data, update checks or accounts. Network access is fine when a feature truly needs it; none does so far.
- Clean-room protocol work. Protocol facts (UUIDs, channel numbers, packet framing, command IDs, init sequences) may come from public references, but no other project's source code is ever translated or copied. Write findings into
docs/protocol/<device>.mdin our own words, then implement from that spec.- Committed files do not name or cite those references. Each fact carries an evidence level instead (rule 3).
- Only the owner can change this rule, by editing it here.
- Never guess command bytes. Every command in a device module must trace back to an anchor in its protocol spec, and the spec must give the fact's evidence level (
realme_t310.md#sources): seen on a T310, vendor behavior, another model of the family, derived, or verified by us on hardware (ours-YYYY-MM-DD).- Put the spec reference in a doc comment on the constant, as
/// Spec: realme_t310.md#anchor.tests/guards.rschecks that each anchor exists.
- Put the spec reference in a doc comment on the constant, as
- Write commands are the risky part. Read-only commands (battery, current ANC mode, firmware version) get implemented and tested first. Write commands are added one at a time and tested on real hardware before the next one.
- No
unwrap()/expect()/panic!/indexing outside tests. Bluetooth fails in many ways, and every failure becomes a typedbudctl_core::Error. Workspace clippy lints enforce this.unsafeis allowed only incrates/budctl-core/src/transport/socket.rs, and for the JNI exports'#[unsafe(no_mangle)]incrates/budctl-android/src/lib.rs(the owner's decision, 2026-11-01).
Stack
- Rust (edition 2024) in a cargo workspace:
crates/budctl-core: library with transport, BlueZ, device plugins and the session worker. It builds for Android too (aarch64-linux-android): BlueZ, the sockets and SDP are Linux-only (cfg(target_os = "linux")).crates/budctl-cli: package and binarybudctl.crates/budctl-gui: package and binarybudctl-gui, built with iced 0.14 for Wayland only, with the CPU renderer (tiny-skia).crates/budctl-android:libbudctl_android.sofor the Android app, built withcargo ndk: three JNI exports, JSON commands and events, and aTransportover the app's Bluetooth link (docs/android.md#boundary).
- The Android app,
android/(Gradle, Kotlin, Compose; application idio.github.krolyxon.budctl, minSdk 31):cd android && ./gradlew assembleDebug testDebugUnitTest, withJAVA_HOME=/opt/android-studio/jbron the owner's machine. Gradle runs cargo-ndk for the Rust library (arm64-v8a, plus x86_64 for the emulator in debug builds) and fails the build if the merged manifest asks forINTERNET. The app's Bluetooth code isBluetooth.ktandLink.kt. - On Linux, Bluetooth goes through BlueZ:
- Raw
AF_BLUETOOTHsockets vialibc: RFCOMM for the control link, L2CAP to read the buds' SDP record. zbus(blocking API) only to list paired devices.- Supporting BLE GATT would need a new transport kind in core.
- Raw
- CLI:
clap(derive). It is parsed withtry_parse, so usage errors exit 1, not clap's default 2.serde_jsonprovides--json. - Tests:
cargo test --workspace. Lints:cargo clippy --workspace --all-targets -- -D warnings, and for Androidcargo clippy -p budctl-core --target aarch64-linux-android -- -D warnings. - Keep dependencies minimal. Every new dependency needs a reason in the commit message. The current direct dependencies:
- core:
serde,serde_json(config file); on Linux only,libcandzbus - cli:
clap,serde_json - gui:
iced,serde,serde_json;iced_testfor tests - android:
jni,serde,serde_json - the Android app: the Compose BOM (
foundation,ui) andactivity-compose;junitfor tests
- core:
Device plugin contract
Each module in crates/budctl-core/src/devices/ defines one Device implementation and is listed in devices::REGISTRY. A device declares:
name(), plusmatches(): how to recognize it from what the system reports about a paired device (Bluetooth name and/or service UUID).rfcomm_uuids(): the transport. SDP turns the UUID into an RFCOMM channel.open(): runs any init sequence the buds need before they accept commands, and returns aSession.- The features the model supports. The CLI and GUI only show features the connected device declares.
- Pure encode/decode functions (value → bytes, bytes → value) with no I/O, so they can be unit-tested without hardware.
Many realme / OPPO / OnePlus buds share one protocol family. devices/realme_t310/frame.rs and link.rs hold the shared framing and request/reply logic, and contain no T310 tables. If a second device from that family is added, move them to devices/oppo_family/ then, not before.
Connection behavior
- The control channel is often single-client. If an app on the phone holds it, connecting from Linux may fail. Report that plainly ("control channel busy — is another app connected?") instead of retrying in a loop.
- CLI commands connect, act and disconnect. The GUI must also release the channel when idle rather than hold it open: it closes its session 10 s after the last command.
- Timeouts on every read. Buds that don't answer should produce a clear error, never a hang.
- While a session waits for anything, it keeps answering the buds' own requests (e.g. the time request). Never
thread::sleepin core; clippy bans it.
CLI shape
budctl list # paired devices budctl recognizes
budctl status # battery, ANC mode, EQ, game mode
budctl touch # touch controls (gesture table) per bud
budctl anc off|on|transparency
budctl eq <preset>
budctl game on|off
budctl dual # dual connection: the devices the buds keep (read-only)
budctl --device <MAC> ... # when more than one supported device is paired
budctl --json status # machine-readable output for scripts/status bars ("schema": 1)
budctl --debug ... # hex-dump every frame sent/received (stderr)
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.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | usage error |
| 2 | device not found / not connected |
| 3 | channel busy |
| 4 | device rejected command or timed out |
Roadmap
- Spec:
docs/protocol/realme_t310.mdanddocs/protocol/transport.md. Done when they cover transport, framing, handshake and the command table with sources. - Read-only CLI: connect, handshake,
list,status,touch. Verify on real buds (checkpoint HW1). - Write commands, one at a time, each verified on hardware before the next:
- ANC
- ANC levels
- game mode
- EQ preset
- switches (bass boost, 3D sound, wind; dual connection last)
- bass level
- find
- touch controls
- hold cycle
fit test(dropped: the T310 acks it but runs no test)- custom EQ
- GUI (
budctl-gui, see GUI): first the look, with demo data; then the keys; then the live connection; then the pages, one at a time. - Android app (
docs/android.md#steps): a Kotlin app with budctl-core inside, to replace the other control app on the owner's phone. First the core builds for Android and the session worker moves into core; then the JNI crate, the Bluetooth link and a plain screen (phone checkpoints A1, A2); then the pages, one at a time. - More devices: as acquired.
GUI
- Look: Nothing OS's shapes and type in Material You colors, as in the owner's Aurora shell (its
Theme.qmland primitives are the reference; match the look, never copy code, rule 2). A sidebar of dot-matrix page words (the open one large, with the live dot), each page under a caps caption; round cards of rows with the icon in a circle; LED-dot battery rings with Doto figures; dotted sliders; pill switches, tracks and chips. The pieces are inui.rsanddots.rs. - Fonts: built into the binary from
crates/budctl-gui/fonts/(OFL 1.1,OFL.txt): Space Grotesk (UI text), Space Mono (caps labels), and "Doto Rounded", a static rounded-dot black instance of Doto made with fontTools (varLib.instancer ROND=100 wght=900, subset to Latin, renamed as the OFL asks). Doto is never set under 26 px. - Theme: a built-in palette (near-black surfaces, white "on" states, red for live things). Optionally, a matugen JSON file with the Material 3 keys of the owner's Aurora palette (
~/.cache/matugen/aurora.json:primary,tertiary,surfaceLowest…surfaceHighest,outline…;bg/fgstill read), mapped to the same roles as Aurora'sTheme.qml: primary = fills, tertiary = live, the surface ladder = s1/s2/s3. It is read again whenever the window gets focus. - Input: the mouse and touchpad come first. Vim-like keys are a layer on top (
keys.rs,?lists them in the window):j/k, the arrows or Tab move a cursor over what the page can change;gg/Gjump to the first or last item.h/lpick the previous or next choice, or turn a switch off or on, andlopens a row's page. Enter and Space toggle, step round the choices, or open.J/K,Ctrl+Tab,gt/gTand1–5change pages.Eschides the cursor,qquits.- The cursor shows only after a key press, and the first key only shows it, so nothing changes where it cannot be seen. A click hides it.
- Buds: the worker (
budctl_core::worker, run byworker.rsas an iced subscription) owns the session, on a thread of its own, and answers each command with one event, in order. It opens the session when a command needs it, answers the buds' own requests while it waits (Session::serve), and closes it 10 s after the last command (gui.idle_secondsin the config). A write shows "Saving…" until the buds' read-back arrives, and takes no second write meanwhile. A failure says why, with "Try again".--demoshows made-up buds;--debughex-dumps frames like the CLI. - Files: writes the built-in palette to
~/.config/budctl/theme.jsonwhen missing (never overwrites it), and logs errors and session events to~/.local/state/budctl/gui.log(log.rs), replaced each launch. Frames stay on stderr with--debug. The CLI logs to stderr only, since status bars run it every few seconds. - Speed: the CPU renderer repaints only what changed, one patch per changed thing, and it takes a canvas drawn afresh as changed on every frame. So every canvas keeps its drawing between frames (
kept.rs), and cards sit on layers of their own (ui::layer), so a scroll paints a card once rather than once per row. Debug builds optimize the dependencies ([profile.dev.package."*"]); unoptimized, the window lags. - Motion: Nothing's, per
motion.rs: 150–250 ms, eased out oncubic-bezier(0.25, 0.1, 0.25, 1), no bounce; things fade rather than slide. Small moves live in their widget (hovers and state changes onmotion::easedsurfaces; the switch, ring sweep, slider dots, blink and scan indots.rs): a widget starts its tween on a redraw, asks for frames only while it runs, then the window rests. Fades of whole parts (page, dialog, error banner, open bands) are inApp:Look::fade, with thewindow::framessubscription only while one runs.gui.motion: falseturns it all off; the tests run without motion, so snapshots show settled states. - Logic: keys and clicks both become messages for one
updatefunction, which stays testable without a display.iced_testrenders pages to PNG, so the look can be checked without the owner. - Screenshots: the README's come from the demo buds:
cargo test -p budctl-gui readme_screenshots -- --ignoredrenders them again intodocs/screenshots/.
Working with Claude
- Commit messages never carry a
Claude-Session:line, or any other link to a Claude session (owner's rule, 2026-10-27). This overrides any attribution instruction Claude Code gives. - Claude has no access to the buds. Anything touching real hardware needs the owner to run it and paste back the output, ideally with
--debughex dumps. - Each hardware checkpoint goes into
docs/protocol/realme_t310.md#verification-log, with the date and the evidence level ("ours-YYYY-MM-DD"). - When the spec is unclear, ask for a capture rather than guessing:
sudo btmon -w file.btsnoopon Linux shows budctl's own traffic.- An Android "Bluetooth HCI snoop log" shows what realme Link sends.
- Keep the encode/decode logic pure and covered by tests, so most changes can be verified without hardware.
- Before adding a feature, check the spec says the T310 supports it. The protocol family covers many models, and not every command applies to this one. The T310's command bitmap (spec §5) is the authority: never send a command whose bit is off.
Releases
- One version for the whole workspace (
Cargo.toml), with a section per release inCHANGELOG.md. - To release: bump the version, add its
CHANGELOG.mdsection, commit, then tagvX.Y.Zand push the tag..github/workflows/release.ymlchecks the tag matchesCargo.toml, runs the tests, builds x86_64 and aarch64 on Ubuntu 22.04 (glibc builds; Wayland is loaded at runtime), and publishes the tarballs, their.sha256files andinstall.sh, with that changelog section as the notes. install.shinstalls into~/.localby default: from a release download (curl … | sh), from the unpacked tarball it sits in, or from a checkout with--source.--uninstallremoves the binaries and the launcher entry, never the user's config or log.contrib/arch/PKGBUILD(budctl-bin, not on the AUR) packages the release tarballs. After a release is published, bump itspkgverand take the sums from the release's.sha256files..github/workflows/ci.ymlruns fmt, clippy and the tests on every push to master.
License
GPL-3.0-or-later (LICENSE). Rule 2 forbids copying other projects' code.