A Bluetooth Swiss-army-knife for reverse engineering, troubleshooting, and engineering — with first-class Bluetooth Classic (RFCOMM/SPP) support the BLE-only ecosystem lacks.
Project description
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.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file untether_bt-0.9.0.tar.gz.
File metadata
- Download URL: untether_bt-0.9.0.tar.gz
- Upload date:
- Size: 111.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
560f2f8ccdc4861b334f399eac4fc45555f8626e2267eaf82de931917cbec415
|
|
| MD5 |
8bfae542e42a8ece85b5567850ad1f77
|
|
| BLAKE2b-256 |
50abd39618d512dc358297702baacc4d273a4192b02b903d424089942fbaf7a3
|
Provenance
The following attestation bundles were made for untether_bt-0.9.0.tar.gz:
Publisher:
publish.yml on dallanwagz/untether
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
untether_bt-0.9.0.tar.gz -
Subject digest:
560f2f8ccdc4861b334f399eac4fc45555f8626e2267eaf82de931917cbec415 - Sigstore transparency entry: 1891816374
- Sigstore integration time:
-
Permalink:
dallanwagz/untether@416cd57604a643db5399729d9b2954dadbadc2b4 -
Branch / Tag:
refs/tags/v0.9.0 - Owner: https://github.com/dallanwagz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@416cd57604a643db5399729d9b2954dadbadc2b4 -
Trigger Event:
release
-
Statement type:
File details
Details for the file untether_bt-0.9.0-py3-none-any.whl.
File metadata
- Download URL: untether_bt-0.9.0-py3-none-any.whl
- Upload date:
- Size: 105.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
854b85783069d3a43238b35786ab63ff5c2b986c2210f3ce4ecab8aa57a64b5a
|
|
| MD5 |
03638e064354996e48bc30322e52b50e
|
|
| BLAKE2b-256 |
47bc721c19fcb9d56452e4a301d4e889a90584ff5001d5007b3382c370f36ba9
|
Provenance
The following attestation bundles were made for untether_bt-0.9.0-py3-none-any.whl:
Publisher:
publish.yml on dallanwagz/untether
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
untether_bt-0.9.0-py3-none-any.whl -
Subject digest:
854b85783069d3a43238b35786ab63ff5c2b986c2210f3ce4ecab8aa57a64b5a - Sigstore transparency entry: 1891816553
- Sigstore integration time:
-
Permalink:
dallanwagz/untether@416cd57604a643db5399729d9b2954dadbadc2b4 -
Branch / Tag:
refs/tags/v0.9.0 - Owner: https://github.com/dallanwagz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@416cd57604a643db5399729d9b2954dadbadc2b4 -
Trigger Event:
release
-
Statement type: