untether-bt
A Bluetooth Swiss-army-knife for reverse engineering, troubleshooting, and engineering — with first-class Bluetooth Classic (RFCOMM/SPP) support the BLE-only ecosystem lacks.
The modern Bluetooth stack (bleak → Home Assistant → ESPHome bluetooth_proxy) is BLE-only by design — bleak closed Classic support as wontfix. So a Bluetooth Classic SPP device (countless LED panels, meters, serial gadgets, massage chairs…) has no first-class path to a modern host or to Home Assistant. untether-bt fixes that, and gives you the protocol primitives you'd otherwise hand-roll.
Part of the untether project (the methodology + the
untether_sppESP32 firmware). This is the host-side Python library.
Install
pip install untether-bt # core (no heavy deps)
pip install "untether-bt[ble]" # + bleak, for GATT/LE work
Reach a Classic-SPP device from anywhere
Host BLE stacks can't speak RFCOMM/SPP. Flash an ESP32 with the companion untether_spp firmware (it RFCOMM-connects to the device and serves the byte stream over TCP), then:
from untether_bt import SppBridge, DIVOOM_NEWMODE
with SppBridge("192.168.1.50", 8888, framing=DIVOOM_NEWMODE) as dev:
dev.send_frame(0x74, b"\x32") # set brightness 50 (framed: 01 04 00 74 32 aa 00 02)
for f in dev.request(0x46): # send a query, collect replies
print(f.type, f.args.hex())
AsyncSppBridge is the asyncio twin (request/response). The same client also works against socat/ser2net over /dev/rfcommN on a BlueZ host.
A connection that stays up (for daemons & HA coordinators)
A long-running host needs the opposite of request/response: one connection that heals itself. SppConnection is that loop — connect, run a startup handshake, read forever in the background, serialise writes, reconnect with capped backoff, and tear down when inbound bytes go quiet. Pure asyncio, no Home Assistant dependency; you bring the deframer.
from untether_bt import SppConnection, DIVOOM_NEWMODE
leftover = b""
def on_chunk(chunk: bytes) -> None:
global leftover
frames, leftover = DIVOOM_NEWMODE.iter_frames(leftover + chunk)
for f in frames:
... # decode device state, push to your entities
conn = SppConnection(
"192.168.1.50", 8888,
on_chunk=on_chunk,
on_connect=lambda: conn.send(DIVOOM_NEWMODE.build(0xAF, b"\x01")), # handshake
on_state=lambda up: print("link", "up" if up else "down"),
)
await conn.start()
await conn.send(DIVOOM_NEWMODE.build(0x74, b"\x32")) # serialised write
# ... later: await conn.stop()
This is exactly the transport the example hass-pixoo-spp coordinator runs on — the integration keeps only its device-specific logic (handshake bytes, frame parsing, the chunked animation upload) and delegates the connection lifecycle here.
The framing/codec engine
Many BT-serial protocols wrap payloads as SOI | LEN16 | body | CRC16 | EOI, sometimes byte-stuffed. Framing captures the whole family, with hardened resync on the inbound parser:
from untether_bt import Framing, Stuffing, DIVOOM_NEWMODE, DIVOOM_STUFFED
DIVOOM_NEWMODE.build(0xAF, b"\x01").hex() # '010400af01b40002'
frames, leftover = DIVOOM_STUFFED.iter_frames(raw) # byte-stuffed (TimeBox-mini), auto de-stuffed
custom = Framing(crc_bytes=1, stuffing=Stuffing(escape=0x7D)) # roll your own device's dialect
Decode passive BLE advertisements
from untether_bt import parse_ad, manufacturer_data, service_data, local_name
cid, data = manufacturer_data(adv_bytes) # company id (little-endian) + payload
temp = ((int.from_bytes(data[2:5], "big")) // 1000) / 10 # e.g. Govee H5104 packed temp
Reverse-engineer an app, end to end
Drive the vendor app over ADB, mark each UI action, then see exactly which wire bytes each action produced — the UI-action↔byte correlation every other toolchain leaves to manual work:
from untether_bt import AndroidDriver, Capture, Recorder, correlate
drv = AndroidDriver(serial="ABC123") # accessibility-label driving, not pixels
drv.enable_hci_snoop() # turn on Bluetooth HCI logging
drv.launch("com.vendor.app")
rec = Recorder()
drv.tap_and_mark("Power", rec) # tap the labelled control + timestamp the action
drv.tap_and_mark("Brightness Up", rec)
cap = Capture.from_btsnoop(drv.pull_btsnoop()) # pull the capture (via adb bugreport)
for c in correlate(cap.wire_events(), rec.marks):
print(c.mark.label, "→", [e.data.hex() for e in c.events]) # action → the frames it sent
Already have a capture? Skip the driver and decode it directly:
cap = Capture.from_btsnoop(open("btsnoop_hci.log", "rb").read())
for a in cap.att(): # GATT command/status bytes (BLE)
print("TX" if a.sent else "RX", a.opcode_name, hex(a.att_handle or 0), a.value.hex())
Capture also exposes hci_packets/l2cap_payloads (the Classic/RFCOMM hook via
include_l2cap=True); the btsnoop layer (parse_btsnoop/write_btsnoop) is a clean,
signed-year-0-epoch-correct parser you can use standalone; and AndroidDriver runs adb through an
injectable runner, so it's testable without a device.
Static & dynamic analysis (jadx / Frida)
Decompile the app and map its Bluetooth surface — is it BLE or Classic SPP?, which UUIDs, where are the write call sites:
from untether_bt import analyze_apk
a = analyze_apk("vendor.apk") # runs jadx, walks the tree
print(a.summary()) # transport: classic-spp | ble | both ; UUIDs ; call sites
Or hook the running app with Frida to dump the outgoing command bytes live (BLE and Classic,
at the API layer — works even on an encrypted link), as the same WireEvents correlate() eats:
from untether_bt import FridaSession # pip install "untether-bt[frida]"
events = []
FridaSession("com.vendor.app").run(events.append, duration=20)
Protocol primitives
from untether_bt import Capture, GattClient, describe_uuid, parse_ssa_response, spp_channel
describe_uuid(0x180F) # '0x180F (Battery Service)'
spp_channel(parse_ssa_response(sdp_bytes)) # the dynamic RFCOMM channel — browse, don't hardcode
spp_channel(Capture.from_btsnoop(cap).sdp_records()) # …or recover it straight from a capture
async with GattClient("AA:BB:CC:DD:EE:FF") as g: # wraps bleak; pip install "untether-bt[ble]"
print(g.services())
await g.subscribe(0xFFE1, print) # CCCD handled for you
await g.write(0xFFE1, b"\x01")
The Assigned-Numbers resolver carries the full SIG registries, not a handful — every company ID, GATT service/characteristic/descriptor, SDP service class, protocol identifier, AD/EIR type, GAP appearance, and Class of Device (regenerate with tools/gen_assigned_numbers.py):
from untether_bt import (company_name, protocol_name, sdp_service_name,
ad_type_name, appearance_name, parse_class_of_device)
company_name(0x004C) # 'Apple, Inc.'
protocol_name(0x0003) # 'RFCOMM' (SDP protocol identifier namespace)
sdp_service_name(0x110A) # 'Audio Source'
ad_type_name(0xFF) # 'Manufacturer Specific Data'
appearance_name(0x0341) # 'Heart Rate Sensor: Heart Rate Belt'
parse_class_of_device(0x20020C) # {'major_device_class': 'Phone …', 'minor_device_class': 'Smartphone', …}
What's here and what's next
Now: the framing/codec engine; the SPP bridge client (sync + async) plus the self-healing
SppConnection (dogfooded by the Pixoo HA integration); the advertisement decoder;
the full RE capture pipeline (live ADB/UIAutomator driver → btsnoop + btsnooz → HCI/L2CAP/ATT
extraction → UI-action↔wire-byte correlation); a standalone RFCOMM/TS 07.10 frame decoder
(DLCI/server-channel, frame types, credit flow, MUX commands, kernel-exact FCS); static + dynamic
analysis (jadx mapping + Frida write hooks); the protocol primitives (SDP record parser — incl.
recovering the RFCOMM channel from a capture or live via BlueZ — GATT client over bleak, full
Assigned-Numbers resolver). Proven on real hardware and uniquely ours (first-class Classic throughout).
Roadmap: growing the bundled Assigned-Numbers tables; publishing the spec map as a Classic-BT RE handbook; contributing parsers upstream.
License
MIT.
Metadata
Release files for untether-bt 0.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| untether_bt-0.9.0.tar.gz | 111.7 kB | With provenance | Details for untether_bt-0.9.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| untether_bt-0.9.0-py3-none-any.whl | Python 3 | none | any | Details for untether_bt-0.9.0-py3-none-any.whl |
Total release size: 216.9 kB
Release files / untether_bt-0.9.0.tar.gz
| Download URL | untether_bt-0.9.0.tar.gz |
|---|---|
| Size | 111.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
560f2f8ccdc4861b334f399eac4fc45555f8626e2267eaf82de931917cbec415
|
|
BLAKE2b-256 checksum How to use checksums |
50abd39618d512dc358297702baacc4d273a4192b02b903d424089942fbaf7a3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 21, 2026.
Transparency logRelease files / untether_bt-0.9.0-py3-none-any.whl
| Download URL | untether_bt-0.9.0-py3-none-any.whl |
|---|---|
| Size | 105.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
854b85783069d3a43238b35786ab63ff5c2b986c2210f3ce4ecab8aa57a64b5a
|
|
BLAKE2b-256 checksum How to use checksums |
47bc721c19fcb9d56452e4a301d4e889a90584ff5001d5007b3382c370f36ba9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 21, 2026.
Transparency logSecurity for untether-bt 0.9.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for untether_bt-0.9.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 21, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.