Everything in docs/ was measured against the real headphones on 2026-08-15.
Re-measure; firmware moves things.
The app is VolumeActivity: every bonded headphone it knows how to drive, its
model once identified, and its ANC modes as chips. The probe (MainActivity,
probe.sh) stays — it is the only tool that can investigate a device the app
cannot drive, and both share :protocol, so a byte fixed in one is fixed in both.
:protocol every byte of the five wire formats. NO Android dependency, so
`./gradlew :protocol:test` runs on any JVM — no phone, no pairing,
no headphones switched on. This is where most of the code lives.
:app only what genuinely needs a device: RFCOMM sockets, GATT, LE
scanning, permissions, screen.
The line is drawn at I/O, not at "app vs library": Transport is declared in
:protocol and only implemented in :app, which is what lets DriversTest
replay recorded transcripts through the real driver code off-device.
⚠ That split flatters itself if you stop there. The byte layouts are the easy
part and were nearly right first time; every wrong conclusion in this repo came
from session behaviour — greetings answering questions never asked, writes that
need a transaction, reads that need an ack. So the fixtures are real captures, and
Confirmation exists so a caller cannot mistake a reply for a result.
⚠ Repo is PUBLIC and carries the headphones' MACs — Pippijn's call, 2026-08-15.
| Device | Address | Channel | Protocol | ANC |
|---|---|---|---|---|
| Bose QC45 | E4:58:BC:3E:9D:AA |
RFCOMM, SPP 00001101 |
Bose | ✅ r/w |
| Bose QC35 | 4C:87:5D:CC:A0:23 |
RFCOMM, SPP 00001101 |
Bose | ✅ r/w |
| JBL Tour One M2 | scan for it | GATT, 65786365-…0000 |
BES aa |
✅ r/w |
| Sony XM4 | 80:99:E7:F9:D0:61 |
RFCOMM, 96cc203e-… |
Sony framed | ✅ r/w |
| JLab JBuds Sport ANC 4 | EC:9A:0C:E0:D2:96 |
RFCOMM, SPP 00001101 |
JLab c0 ff |
✅ write |
QC45 1f 03 05 02 <slot> 01 slot 0=Quiet 1=Aware 2=Home 3=unnamed
QC35 01 06 02 01 <value> 00 / 01 / 03
JBL aa 91 07 10 01 <anc> 02 <amb> 03 <talkthru> read with aa 91 01 11
Sony 68 02 <on> 02 <nc> 01 00 <ambient> read with 66 02
JLab c0 ff 00 46 03 00 <mode> 04 04 01 00 <sum> 01=NC on, 02=Be Aware
All driven from our own socket. The Bose announced each mode aloud; the JBL and the Sony were confirmed by an independent read-back and against the vendor app's screen, and each device was left in the mode it started in.
⚠ Two channels that answer are not control channels. df21fe2c is Google
Fast Pair — battery, model, firmware, and the LE address, but no ANC or EQ — and
931c7e8a answers a fourth framing on four of the five devices. An earlier pass
took Fast Pair for JBL/JLab's own protocol and built a command map out of its
acknowledgements. docs/protocols.md has the correction.
- JBL EQ and auto-off writes. Reads are all done (
docs/protocols.md): status, gestures and the ANC capability answer. Writing isaa 40EQ preset,aa 41custom EQ,aa 33auto-off, each read back throughaa 21 01 3x. EQ needs ears — a read-back proves the field moved, not that it sounds right. - Sony EQ —
50–5bEQEBB, same session mechanism,SONY_SEQ=1. - Bose multipoint —
04 04/04 09read the paired list + active device; writes untried. - Bose EQ / auto-off / buttons — among the 15 write-capable fns in
docs/bose-read-surface.md. - JLab reads. ANC writes work, but no read command is known — its periodic broadcast carries battery, not mode. Capture the app opening its dashboard.
2026-08-12 Pippijn settled: headphones go in thoth's Angular UI, one remote.
2026-08-15, asked fresh: pure Kotlin app. The second knowingly reverses the
first; tie-break deferred to the protocol work, which favours neither (the Kotlin
is identical either way). Native = QS tile + widget. Angular = one remote for
headphones and the Mac's CoreAudio + Picades, which cannot move to the phone
(project_thoth). Pippijn's call — don't re-decide it silently.
./deploy.sh # build + install, Pixel 9 by MODEL
./probe.sh list # bonded + detected channel/protocol
./probe.sh scan # what is advertising over LE, right now
./probe.sh gattmap <name> # every GATT service, char and property
./probe.sh free # force-stop vendor apps
./probe.sh send|raw <mac> <uuid> <hex> # one packet, one socket
./probe.sh seq <mac> <uuid> <hex,hex> # one socket — THE RFCOMM WRITE TOOL
./probe.sh gatt <name|addr> <hex,hex> # one LE connection — THE GATT WRITE TOOL
./probe.sh sweep <mac> <uuid> <proto> [blocks] [fns]The app's own stack, end to end — registry → transport → driver → read-back:
adb shell am start -n org.xinutec.volume/.MainActivity --es op anc \
--es device "'JBL TOUR'" [--es mode ANC|AMBIENT|OFF]Verified on all five, 2026-08-15: Sony and JLab over RFCOMM, JBL over GATT with an
LE scan, and a renamed QC35 identified by asking it — its record carries
nothing but standard and shared UUIDs, so Registry.identifyBose reads 01 06,
which is ANC on the QC35 and unsupported on the QC45.
Output: adb logcat -s volume-probe. VOLUME_ADB_DEVICE overrides the target.
--ez reconnect true for Fast Pair sweeps.
- ⚠ One vendor app holds the channel exclusively. A connect failure while one
runs is not a protocol result. Keep them installed — captures need them.
com.harman.ble.jbllinkis the speaker app; headphones arejbl.stc.com. - ⚠
sendcannot write. Bose edits are transactional (operator-05Start, then the change). The orphaned write is accepted and the unchanged state echoes back — reads exactly like a wrong field. Useseq. - ⚠ Never test a write against the value already held. A no-op is indistinguishable from a broken command; the QC45 selection was found, dismissed as inert, then shown correct.
- ⚠ Response windows differ ~4× by vendor. JBL at 400 ms: 0/144 answered. At 1500 ms: 25/32. Too short reads as "implements nothing".
- ⚠ A device that talks on connect will answer a question you never asked.
sendreads the greeting;seqdrains it first.aa 11looked answered on the Fast Pair channel — that was the greeting, andseqshowed the truth. - ⚠ An answering socket is not the right socket. Both
df21fe2cand931c7e8areply on the JBL and neither is its control channel. - ⚠ A control channel need not be RFCOMM. The JBL's is GATT. Hours went into "why is SPP silent" before a capture showed the app opens no socket at all.
- ⚠ LE addresses rotate, so a scan is not optional and a noted address goes
stale. Connect through the scanner's device object with
autoConnect = false: a string address is assumed public, and an accept-list connect waits forever for a private address to reappear. Both failures look identical — status 135 after the full timeout, which reads like a protocol fault. - ⚠ A BLE device is several sightings, and only one of them carries the name. Merge them; keeping the first hid the JBL behind "(no name)".
- ⚠ "Answers nothing" and "has nothing to answer" look identical, and this has now cost three findings. The JLab genuinely has no read command; the Sony was being ignored for repeating a sequence byte. Both rendered as "reports no mode". Only comparing against a state you already know tells them apart.
- ⚠ Connected is not on-a-profile. Presence comes from the A2DP and headset proxies, which populate after the ACL link, so a pair already connected when the app starts can be invisible with no ACL event to follow. Listen for the profile transitions too.
- ⚠ A one-shot exchange cannot hold a protocol with state, and this has now
cost three separate wrong conclusions. Sony's
66 02returns a bare ACK one-per-socket and real data inside a session that acks the device's frames — ten inquired types were written off before that was the difference.
Probe with reads; never prove a round-trip with a volume command; restore any
level touched. ANC mode is not volume — it cannot raise a level, which is why it
is the right thing to write first. Sweep.kt hard-wires operator/length to
Get/zero (tested) rather than taking them as parameters.
docs/protocols.md — the wire formats, capture method, channel traps.
docs/bose-read-surface.md — Bose surface, error taxonomy, how ANC was found.
nix develop ~/Code/recall#android --command ./gradlew :app:testDebugUnitTest
nix run ../dev-lint#gate -- . gate.jsonNo flake of its own; SDK from recall's devshell, like xinutec-infra/govee-android.