A macOS Python library for two-way audio with BTech UV-Pro and other Benshi-family handheld radios (GA-5WB, VR-N76, VR-N7500, GMRS-Pro).
Status: full-duplex audio working. BLE control, RX (radio → Mac speaker) and TX (Mac mic → radio) all proven end-to-end against a UV-Pro.
-
macOS 12.4 or newer. Earlier 12.x releases have a known
IOBluetoothRFCOMM bug that breaks the audio path; not an issue for the BLE-only work in Phase 1 but we set the bar here to avoid surprises later. -
Radio must be paired at the OS level first:
- Power the radio on, enable Bluetooth, make it discoverable.
- On the Mac: System Settings → Bluetooth → connect.
- Confirm pairing on the radio (usually a button press).
Without an OS-level bond, BLE indications get dropped silently and you'll see writes succeed but no replies arrive.
-
Python 3.10+.
cd bendio
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
# One-time macOS fix: create a patched Python.app inside the venv with
# NSBluetoothAlwaysUsageDescription set. Without this, macOS kills the
# process with SIGABRT the instant bleak touches CoreBluetooth.
python scripts/mac_bluetooth_setup.py
# Audio extras come later; not needed for Phase 1:
# pip install -e '.[audio]'macOS requires every process that touches Bluetooth to ship an
NSBluetoothAlwaysUsageDescription key in its bundle Info.plist. Neither
Homebrew Python nor python.org Python declares one, so the first bleak
call aborts with:
This app has crashed because it attempted to access privacy-sensitive data without a usage description.
scripts/mac_bluetooth_setup.py copies the interpreter's Python.app
bundle into .venv/Python.app, patches its Info.plist (adding the usage
description and assigning a unique CFBundleIdentifier), re-signs it
ad-hoc, re-links .venv/bin/python at the patched copy, and clears any
stale TCC decision for the new bundle ID. Idempotent and local to the venv.
After that, the first bendio scan invocation should produce a macOS
permission prompt. If no prompt appears and the process still crashes,
your terminal / IDE also needs Bluetooth permission:
System Settings → Privacy & Security → Bluetooth → toggle on for Terminal / iTerm / VS Code / whatever you're launching from.
Scan for nearby radios:
bendio scanConnect, fetch device info, dump first 32 channels, and tail notifications:
bendio connect AA:BB:CC:DD:EE:FFDump the full channel table (tab-separated):
bendio channels AA:BB:CC:DD:EE:FF --count 200Sniff every inbound GAIA frame as timestamped hex (Phase 2 tool for observing BLE traffic while the radio is in FM RX):
# Register all notification classes so the radio pushes everything it has.
bendio sniff AA:BB:CC:DD:EE:FF \
--register HT_STATUS_CHANGED \
--register HT_CH_CHANGED \
--register HT_SETTINGS_CHANGED \
--register DATA_RXD \
--register RADIO_STATUS_CHANGEDimport asyncio
from bendio import Radio
from bendio import protocol as p
async def main():
async with Radio("AA:BB:CC:DD:EE:FF") as radio:
info = await radio.device_info()
print(info)
radio.on_notification(lambda ev: print("event:", ev))
await radio.register_notification(p.EventType.HT_STATUS_CHANGED)
await asyncio.sleep(60)
asyncio.run(main())bendio/
├── bendio/
│ ├── protocol/ # vendored from benlink (Apache-2.0) — see NOTICE
│ ├── link.py # BLE transport via bleak + CoreBluetooth
│ ├── radio.py # high-level async API, command/reply matching
│ ├── cli.py # scan/connect/channels/sniff/rfcomm-* subcommands
│ └── audio/ # RFCOMM SPP + SBC + HDLC framing
├── examples/ # 01_device_info, 02_sniff_fm_rx, 03_listen, 04_ptt
├── tests/ # offline pytest suite (no hardware needed)
├── docs/
│ ├── PROTOCOL_NOTES.md # empirical findings + spec corrections
│ └── ble_fm_rx_trace.md # Phase 2 capture + analysis
├── scripts/ # mac_bluetooth_setup.py, mic_check.py
├── .github/workflows/ # CI
├── pyproject.toml
├── LICENSE # Apache-2.0
├── LICENSE.benlink # vendored subtree's license (also Apache-2.0)
├── NOTICE # required attributions
├── CHANGELOG.md
└── README.md
The Classic Bluetooth RFCOMM path this library uses for audio depends on
Apple's IOBluetooth framework. Apple deprecated IOBluetooth in favour
of CoreBluetooth, but CoreBluetooth is BLE-only and provides no
replacement for RFCOMM / SPP. There is no supported alternative today.
IOBluetooth still works on every shipping macOS release (including
macOS 15 Sequoia and later), but Apple has signalled it won't receive new
development. If Apple eventually removes it, the audio path here will
need to be re-implemented — likely via a DriverKit-based RFCOMM shim,
or by giving up on Classic Bluetooth for these radios and pushing the
vendor to ship a BLE audio characteristic.
Today: fine. Long-term: a ticking risk on the Classic BT half of the
library. BLE control (bendio/link.py) is unaffected.
- Phase 1 (done): BLE control — device info, channel dump, settings, notifications. Cross-checked against the HTCommander protocol doc.
- Phase 2 (done): Put the radio in FM RX, ran
bendio sniffandbendio sniff-all, committed the trace todocs/ble_fm_rx_trace.md. Confirmed audio is not on BLE on any service, including an undocumented vendor service this library is the first to inspect. - Phase 3 (done): RFCOMM SPP audio via PyObjC +
IOBluetoothRFCOMMChannel. SBC codec (ffmpeg subprocess — homebrew dropped the standalonesbcformula, solibsbcvia ctypes wasn't feasible), 0x7E framing, sounddevice for I/O. Breakdown:- 3a: open/close RFCOMM cleanly
- 3b: dump raw bytes; confirm SBC framing on channel 2 ("BS AOC")
- 3c: HDLC deframer + SBC frame splitter
- 3d: live RX audio to default output (~200 ms latency)
- 3e: TX test tone → live mic TX, full duplex
- Phase 4 (partial): Ergonomic API + examples + packaging.
Low-level building blocks (
Radio,RfcommTxSession,SbcStream,SbcEncodeStream,Deframer,build_audio_packet) are stable and composable;examples/covers device info, sniff, listen, and PTT. TODO: a top-levelBenshiRadiofacade that unifies BLE control + audio behind one async context manager, in the shape of benlink'sRadioControllerbut extended to expose.audio.start_rx()/.audio.start_tx()(which benlink never finished).
- Cross-check against HTCommander-X on Linux/Windows. Run the
reference Dart implementation against the same physical radio,
compare: does it see the same
GET_DEV_INFObytes? Does it dump the same channel table byte-for-byte? Any protocol nuances we handle differently would surface here. Not a blocker for using the library, but a high-value correctness check once a Linux/Windows host with the radio is at hand. - Swift or Dart port of the Python library, so the
bendiocode becomes reusable from a native Mac app (Swift/SwiftUI) or from HTCommander-X Flutter. The Python version stays as the executable spec and keeps the reverse-engineering loop tight. - Spacebar PTT UX for the CLI — the plan originally called for
bendio pttas hold-to-talk. Currently implemented asbendio rfcomm-tx-mic --duration N, which works but is duration-bounded rather than interactive. - IOBluetooth fallback plan. See the caveat above — Apple has deprecated the framework with no CoreBluetooth equivalent for RFCOMM. Worth scoping a DriverKit-based shim or other alternative before Apple removes the API, not after.
- benlink — Python reference whose
protocol/subtree is vendored intobendio/protocol/(Apache-2.0). Our BLE control pattern tracksBleCommandLinkclosely. - HTCommander — C# reference implementation by Ylian Saint-Hilaire. Source of the HDLC audio framing bytes, SBC codec parameters, and end-of-TX packet format used here.
- HTCommander-X — Flutter
fork of the above (by dikei100) with the Linux/Windows RFCOMM audio
implementations we studied for the SDP discovery + channel probing
pattern. The "BS AOC" service-name detection that identifies the audio
channel on this radio family came from its
linux_audio_transport.dart.
Empirical protocol findings (including corrections to the above
references) are collected in docs/PROTOCOL_NOTES.md.
This project is licensed under the Apache License 2.0. See the LICENSE
file for the full text and the NOTICE file for attributions.
The bendio/protocol/ subtree is vendored from benlink (also Apache-2.0).
A copy of the upstream license is retained as LICENSE.benlink for
provenance.