Skip to main content

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 designbleak 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_spp ESP32 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); 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, 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

untether_bt-0.8.0.tar.gz (106.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

untether_bt-0.8.0-py3-none-any.whl (100.7 kB view details)

Uploaded Python 3

File details

Details for the file untether_bt-0.8.0.tar.gz.

File metadata

  • Download URL: untether_bt-0.8.0.tar.gz
  • Upload date:
  • Size: 106.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for untether_bt-0.8.0.tar.gz
Algorithm Hash digest
SHA256 42b56fc6d4f3d9c3936de5be4976e60ac8101a9ad98700c46153d4464cccc0bb
MD5 ba474b1f6d13e396af79d648cfb47182
BLAKE2b-256 962056c2316b911ade54402fac331e86441034b04992239015b4276ffc951f96

See more details on using hashes here.

Provenance

The following attestation bundles were made for untether_bt-0.8.0.tar.gz:

Publisher: publish.yml on dallanwagz/untether

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file untether_bt-0.8.0-py3-none-any.whl.

File metadata

  • Download URL: untether_bt-0.8.0-py3-none-any.whl
  • Upload date:
  • Size: 100.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for untether_bt-0.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2cddeea0b063b3ec0426425ee7009144b5234902c450fbd64649119ace8bdcb2
MD5 99982e922d6deb107052911d454d7f00
BLAKE2b-256 728a3bcf69b15ceb62e1559769ee3558d6c08c21c4c493e7bbf70c6bada7aed2

See more details on using hashes here.

Provenance

The following attestation bundles were made for untether_bt-0.8.0-py3-none-any.whl:

Publisher: publish.yml on dallanwagz/untether

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page