untether-bt 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. untether_bt-0.7.0/.gitignore +8 -0
  2. untether_bt-0.7.0/LICENSE +21 -0
  3. untether_bt-0.7.0/PKG-INFO +194 -0
  4. untether_bt-0.7.0/README.md +163 -0
  5. untether_bt-0.7.0/pyproject.toml +48 -0
  6. untether_bt-0.7.0/src/untether_bt/__init__.py +153 -0
  7. untether_bt-0.7.0/src/untether_bt/advertising.py +104 -0
  8. untether_bt-0.7.0/src/untether_bt/android.py +159 -0
  9. untether_bt-0.7.0/src/untether_bt/apk.py +149 -0
  10. untether_bt-0.7.0/src/untether_bt/bluez.py +34 -0
  11. untether_bt-0.7.0/src/untether_bt/btsnoop.py +214 -0
  12. untether_bt-0.7.0/src/untether_bt/capture.py +142 -0
  13. untether_bt-0.7.0/src/untether_bt/connection.py +156 -0
  14. untether_bt-0.7.0/src/untether_bt/framing.py +188 -0
  15. untether_bt-0.7.0/src/untether_bt/frida.py +102 -0
  16. untether_bt-0.7.0/src/untether_bt/frida_hooks/android_bt.js +68 -0
  17. untether_bt-0.7.0/src/untether_bt/gatt.py +80 -0
  18. untether_bt-0.7.0/src/untether_bt/hci.py +148 -0
  19. untether_bt-0.7.0/src/untether_bt/numbers.py +118 -0
  20. untether_bt-0.7.0/src/untether_bt/py.typed +0 -0
  21. untether_bt-0.7.0/src/untether_bt/sdp.py +140 -0
  22. untether_bt-0.7.0/src/untether_bt/spp.py +164 -0
  23. untether_bt-0.7.0/src/untether_bt/uiauto.py +88 -0
  24. untether_bt-0.7.0/tests/test_advertising.py +67 -0
  25. untether_bt-0.7.0/tests/test_android.py +139 -0
  26. untether_bt-0.7.0/tests/test_apk.py +70 -0
  27. untether_bt-0.7.0/tests/test_bluez.py +21 -0
  28. untether_bt-0.7.0/tests/test_btsnoop.py +106 -0
  29. untether_bt-0.7.0/tests/test_capture.py +139 -0
  30. untether_bt-0.7.0/tests/test_connection.py +158 -0
  31. untether_bt-0.7.0/tests/test_framing.py +106 -0
  32. untether_bt-0.7.0/tests/test_frida.py +45 -0
  33. untether_bt-0.7.0/tests/test_gatt.py +76 -0
  34. untether_bt-0.7.0/tests/test_numbers.py +43 -0
  35. untether_bt-0.7.0/tests/test_sdp.py +75 -0
  36. untether_bt-0.7.0/tests/test_spp.py +80 -0
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dallanwagz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: untether-bt
3
+ Version: 0.7.0
4
+ Summary: A Bluetooth Swiss-army-knife for reverse engineering, troubleshooting, and engineering — with first-class Bluetooth Classic (RFCOMM/SPP) support the BLE-only ecosystem lacks.
5
+ Project-URL: Homepage, https://github.com/dallanwagz/untether
6
+ Project-URL: Repository, https://github.com/dallanwagz/untether
7
+ Project-URL: Issues, https://github.com/dallanwagz/untether/issues
8
+ Author: dallanwagz
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: advertisement,ble,bluetooth,bluetooth-classic,esp32,home-assistant,reverse-engineering,rfcomm,spp
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Communications
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: Topic :: System :: Hardware
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: ble
21
+ Requires-Dist: bleak>=0.22; extra == 'ble'
22
+ Provides-Extra: bluez
23
+ Requires-Dist: pybluez2>=0.46; extra == 'bluez'
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
26
+ Requires-Dist: pytest>=8; extra == 'dev'
27
+ Requires-Dist: ruff>=0.5; extra == 'dev'
28
+ Provides-Extra: frida
29
+ Requires-Dist: frida>=16; extra == 'frida'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # untether-bt
33
+
34
+ **A Bluetooth Swiss-army-knife for reverse engineering, troubleshooting, and engineering — with first-class Bluetooth *Classic* (RFCOMM/SPP) support the BLE-only ecosystem lacks.**
35
+
36
+ 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.
37
+
38
+ > Part of the [untether](https://github.com/dallanwagz/untether) project (the methodology + the `untether_spp` ESP32 firmware). This is the host-side Python library.
39
+
40
+ ## Install
41
+
42
+ ```sh
43
+ pip install untether-bt # core (no heavy deps)
44
+ pip install "untether-bt[ble]" # + bleak, for GATT/LE work
45
+ ```
46
+
47
+ ## Reach a Classic-SPP device from anywhere
48
+
49
+ Host BLE stacks can't speak RFCOMM/SPP. Flash an ESP32 with the companion [`untether_spp`](https://github.com/dallanwagz/untether/tree/main/components/untether_spp) firmware (it RFCOMM-connects to the device and serves the byte stream over TCP), then:
50
+
51
+ ```python
52
+ from untether_bt import SppBridge, DIVOOM_NEWMODE
53
+
54
+ with SppBridge("192.168.1.50", 8888, framing=DIVOOM_NEWMODE) as dev:
55
+ dev.send_frame(0x74, b"\x32") # set brightness 50 (framed: 01 04 00 74 32 aa 00 02)
56
+ for f in dev.request(0x46): # send a query, collect replies
57
+ print(f.type, f.args.hex())
58
+ ```
59
+
60
+ `AsyncSppBridge` is the asyncio twin (request/response). The same client also works against `socat`/`ser2net` over `/dev/rfcommN` on a BlueZ host.
61
+
62
+ ## A connection that stays up (for daemons & HA coordinators)
63
+
64
+ 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.
65
+
66
+ ```python
67
+ from untether_bt import SppConnection, DIVOOM_NEWMODE
68
+
69
+ leftover = b""
70
+ def on_chunk(chunk: bytes) -> None:
71
+ global leftover
72
+ frames, leftover = DIVOOM_NEWMODE.iter_frames(leftover + chunk)
73
+ for f in frames:
74
+ ... # decode device state, push to your entities
75
+
76
+ conn = SppConnection(
77
+ "192.168.1.50", 8888,
78
+ on_chunk=on_chunk,
79
+ on_connect=lambda: conn.send(DIVOOM_NEWMODE.build(0xAF, b"\x01")), # handshake
80
+ on_state=lambda up: print("link", "up" if up else "down"),
81
+ )
82
+ await conn.start()
83
+ await conn.send(DIVOOM_NEWMODE.build(0x74, b"\x32")) # serialised write
84
+ # ... later: await conn.stop()
85
+ ```
86
+
87
+ This is exactly the transport the example [`hass-pixoo-spp`](https://github.com/dallanwagz/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.
88
+
89
+ ## The framing/codec engine
90
+
91
+ 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:
92
+
93
+ ```python
94
+ from untether_bt import Framing, Stuffing, DIVOOM_NEWMODE, DIVOOM_STUFFED
95
+
96
+ DIVOOM_NEWMODE.build(0xAF, b"\x01").hex() # '010400af01b40002'
97
+ frames, leftover = DIVOOM_STUFFED.iter_frames(raw) # byte-stuffed (TimeBox-mini), auto de-stuffed
98
+ custom = Framing(crc_bytes=1, stuffing=Stuffing(escape=0x7D)) # roll your own device's dialect
99
+ ```
100
+
101
+ ## Decode passive BLE advertisements
102
+
103
+ ```python
104
+ from untether_bt import parse_ad, manufacturer_data, service_data, local_name
105
+
106
+ cid, data = manufacturer_data(adv_bytes) # company id (little-endian) + payload
107
+ temp = ((int.from_bytes(data[2:5], "big")) // 1000) / 10 # e.g. Govee H5104 packed temp
108
+ ```
109
+
110
+ ## Reverse-engineer an app, end to end
111
+
112
+ Drive the vendor app over ADB, mark each UI action, then see exactly which wire bytes each action
113
+ produced — the UI-action↔byte correlation every other toolchain leaves to manual work:
114
+
115
+ ```python
116
+ from untether_bt import AndroidDriver, Capture, Recorder, correlate
117
+
118
+ drv = AndroidDriver(serial="ABC123") # accessibility-label driving, not pixels
119
+ drv.enable_hci_snoop() # turn on Bluetooth HCI logging
120
+ drv.launch("com.vendor.app")
121
+
122
+ rec = Recorder()
123
+ drv.tap_and_mark("Power", rec) # tap the labelled control + timestamp the action
124
+ drv.tap_and_mark("Brightness Up", rec)
125
+
126
+ cap = Capture.from_btsnoop(drv.pull_btsnoop()) # pull the capture (via adb bugreport)
127
+ for c in correlate(cap.wire_events(), rec.marks):
128
+ print(c.mark.label, "→", [e.data.hex() for e in c.events]) # action → the frames it sent
129
+ ```
130
+
131
+ Already have a capture? Skip the driver and decode it directly:
132
+
133
+ ```python
134
+ cap = Capture.from_btsnoop(open("btsnoop_hci.log", "rb").read())
135
+ for a in cap.att(): # GATT command/status bytes (BLE)
136
+ print("TX" if a.sent else "RX", a.opcode_name, hex(a.att_handle or 0), a.value.hex())
137
+ ```
138
+
139
+ `Capture` also exposes `hci_packets`/`l2cap_payloads` (the Classic/RFCOMM hook via
140
+ `include_l2cap=True`); the btsnoop layer (`parse_btsnoop`/`write_btsnoop`) is a clean,
141
+ signed-year-0-epoch-correct parser you can use standalone; and `AndroidDriver` runs adb through an
142
+ injectable runner, so it's testable without a device.
143
+
144
+ ## Static & dynamic analysis (jadx / Frida)
145
+
146
+ Decompile the app and map its Bluetooth surface — *is it BLE or Classic SPP?*, which UUIDs, where
147
+ are the write call sites:
148
+
149
+ ```python
150
+ from untether_bt import analyze_apk
151
+ a = analyze_apk("vendor.apk") # runs jadx, walks the tree
152
+ print(a.summary()) # transport: classic-spp | ble | both ; UUIDs ; call sites
153
+ ```
154
+
155
+ Or hook the running app with Frida to dump the **outgoing command bytes** live (BLE *and* Classic,
156
+ at the API layer — works even on an encrypted link), as the same `WireEvent`s `correlate()` eats:
157
+
158
+ ```python
159
+ from untether_bt import FridaSession # pip install "untether-bt[frida]"
160
+ events = []
161
+ FridaSession("com.vendor.app").run(events.append, duration=20)
162
+ ```
163
+
164
+ ## Protocol primitives
165
+
166
+ ```python
167
+ from untether_bt import Capture, GattClient, describe_uuid, parse_ssa_response, spp_channel
168
+
169
+ describe_uuid(0x180F) # '0x180F (Battery Service)'
170
+ spp_channel(parse_ssa_response(sdp_bytes)) # the dynamic RFCOMM channel — browse, don't hardcode
171
+ spp_channel(Capture.from_btsnoop(cap).sdp_records()) # …or recover it straight from a capture
172
+
173
+ async with GattClient("AA:BB:CC:DD:EE:FF") as g: # wraps bleak; pip install "untether-bt[ble]"
174
+ print(g.services())
175
+ await g.subscribe(0xFFE1, print) # CCCD handled for you
176
+ await g.write(0xFFE1, b"\x01")
177
+ ```
178
+
179
+ ## What's here and what's next
180
+
181
+ **Now:** the framing/codec engine; the SPP bridge client (sync + async) plus the self-healing
182
+ `SppConnection` (dogfooded by the Pixoo HA integration); the advertisement decoder;
183
+ the full RE capture pipeline (live **ADB/UIAutomator driver** → btsnoop **+ btsnooz** → HCI/L2CAP/ATT
184
+ extraction → UI-action↔wire-byte correlation); **static + dynamic analysis** (jadx mapping + Frida
185
+ write hooks); the protocol primitives (**SDP** record parser — incl. recovering the RFCOMM channel
186
+ from a capture or live via BlueZ — **GATT** client over bleak, **Assigned-Numbers** resolver). Proven
187
+ on real hardware and uniquely ours (first-class Classic throughout).
188
+
189
+ **Roadmap:** growing the bundled Assigned-Numbers tables; publishing the spec map as a Classic-BT RE
190
+ handbook; contributing parsers upstream.
191
+
192
+ ## License
193
+
194
+ MIT.
@@ -0,0 +1,163 @@
1
+ # untether-bt
2
+
3
+ **A Bluetooth Swiss-army-knife for reverse engineering, troubleshooting, and engineering — with first-class Bluetooth *Classic* (RFCOMM/SPP) support the BLE-only ecosystem lacks.**
4
+
5
+ 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.
6
+
7
+ > Part of the [untether](https://github.com/dallanwagz/untether) project (the methodology + the `untether_spp` ESP32 firmware). This is the host-side Python library.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pip install untether-bt # core (no heavy deps)
13
+ pip install "untether-bt[ble]" # + bleak, for GATT/LE work
14
+ ```
15
+
16
+ ## Reach a Classic-SPP device from anywhere
17
+
18
+ Host BLE stacks can't speak RFCOMM/SPP. Flash an ESP32 with the companion [`untether_spp`](https://github.com/dallanwagz/untether/tree/main/components/untether_spp) firmware (it RFCOMM-connects to the device and serves the byte stream over TCP), then:
19
+
20
+ ```python
21
+ from untether_bt import SppBridge, DIVOOM_NEWMODE
22
+
23
+ with SppBridge("192.168.1.50", 8888, framing=DIVOOM_NEWMODE) as dev:
24
+ dev.send_frame(0x74, b"\x32") # set brightness 50 (framed: 01 04 00 74 32 aa 00 02)
25
+ for f in dev.request(0x46): # send a query, collect replies
26
+ print(f.type, f.args.hex())
27
+ ```
28
+
29
+ `AsyncSppBridge` is the asyncio twin (request/response). The same client also works against `socat`/`ser2net` over `/dev/rfcommN` on a BlueZ host.
30
+
31
+ ## A connection that stays up (for daemons & HA coordinators)
32
+
33
+ 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.
34
+
35
+ ```python
36
+ from untether_bt import SppConnection, DIVOOM_NEWMODE
37
+
38
+ leftover = b""
39
+ def on_chunk(chunk: bytes) -> None:
40
+ global leftover
41
+ frames, leftover = DIVOOM_NEWMODE.iter_frames(leftover + chunk)
42
+ for f in frames:
43
+ ... # decode device state, push to your entities
44
+
45
+ conn = SppConnection(
46
+ "192.168.1.50", 8888,
47
+ on_chunk=on_chunk,
48
+ on_connect=lambda: conn.send(DIVOOM_NEWMODE.build(0xAF, b"\x01")), # handshake
49
+ on_state=lambda up: print("link", "up" if up else "down"),
50
+ )
51
+ await conn.start()
52
+ await conn.send(DIVOOM_NEWMODE.build(0x74, b"\x32")) # serialised write
53
+ # ... later: await conn.stop()
54
+ ```
55
+
56
+ This is exactly the transport the example [`hass-pixoo-spp`](https://github.com/dallanwagz/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.
57
+
58
+ ## The framing/codec engine
59
+
60
+ 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:
61
+
62
+ ```python
63
+ from untether_bt import Framing, Stuffing, DIVOOM_NEWMODE, DIVOOM_STUFFED
64
+
65
+ DIVOOM_NEWMODE.build(0xAF, b"\x01").hex() # '010400af01b40002'
66
+ frames, leftover = DIVOOM_STUFFED.iter_frames(raw) # byte-stuffed (TimeBox-mini), auto de-stuffed
67
+ custom = Framing(crc_bytes=1, stuffing=Stuffing(escape=0x7D)) # roll your own device's dialect
68
+ ```
69
+
70
+ ## Decode passive BLE advertisements
71
+
72
+ ```python
73
+ from untether_bt import parse_ad, manufacturer_data, service_data, local_name
74
+
75
+ cid, data = manufacturer_data(adv_bytes) # company id (little-endian) + payload
76
+ temp = ((int.from_bytes(data[2:5], "big")) // 1000) / 10 # e.g. Govee H5104 packed temp
77
+ ```
78
+
79
+ ## Reverse-engineer an app, end to end
80
+
81
+ Drive the vendor app over ADB, mark each UI action, then see exactly which wire bytes each action
82
+ produced — the UI-action↔byte correlation every other toolchain leaves to manual work:
83
+
84
+ ```python
85
+ from untether_bt import AndroidDriver, Capture, Recorder, correlate
86
+
87
+ drv = AndroidDriver(serial="ABC123") # accessibility-label driving, not pixels
88
+ drv.enable_hci_snoop() # turn on Bluetooth HCI logging
89
+ drv.launch("com.vendor.app")
90
+
91
+ rec = Recorder()
92
+ drv.tap_and_mark("Power", rec) # tap the labelled control + timestamp the action
93
+ drv.tap_and_mark("Brightness Up", rec)
94
+
95
+ cap = Capture.from_btsnoop(drv.pull_btsnoop()) # pull the capture (via adb bugreport)
96
+ for c in correlate(cap.wire_events(), rec.marks):
97
+ print(c.mark.label, "→", [e.data.hex() for e in c.events]) # action → the frames it sent
98
+ ```
99
+
100
+ Already have a capture? Skip the driver and decode it directly:
101
+
102
+ ```python
103
+ cap = Capture.from_btsnoop(open("btsnoop_hci.log", "rb").read())
104
+ for a in cap.att(): # GATT command/status bytes (BLE)
105
+ print("TX" if a.sent else "RX", a.opcode_name, hex(a.att_handle or 0), a.value.hex())
106
+ ```
107
+
108
+ `Capture` also exposes `hci_packets`/`l2cap_payloads` (the Classic/RFCOMM hook via
109
+ `include_l2cap=True`); the btsnoop layer (`parse_btsnoop`/`write_btsnoop`) is a clean,
110
+ signed-year-0-epoch-correct parser you can use standalone; and `AndroidDriver` runs adb through an
111
+ injectable runner, so it's testable without a device.
112
+
113
+ ## Static & dynamic analysis (jadx / Frida)
114
+
115
+ Decompile the app and map its Bluetooth surface — *is it BLE or Classic SPP?*, which UUIDs, where
116
+ are the write call sites:
117
+
118
+ ```python
119
+ from untether_bt import analyze_apk
120
+ a = analyze_apk("vendor.apk") # runs jadx, walks the tree
121
+ print(a.summary()) # transport: classic-spp | ble | both ; UUIDs ; call sites
122
+ ```
123
+
124
+ Or hook the running app with Frida to dump the **outgoing command bytes** live (BLE *and* Classic,
125
+ at the API layer — works even on an encrypted link), as the same `WireEvent`s `correlate()` eats:
126
+
127
+ ```python
128
+ from untether_bt import FridaSession # pip install "untether-bt[frida]"
129
+ events = []
130
+ FridaSession("com.vendor.app").run(events.append, duration=20)
131
+ ```
132
+
133
+ ## Protocol primitives
134
+
135
+ ```python
136
+ from untether_bt import Capture, GattClient, describe_uuid, parse_ssa_response, spp_channel
137
+
138
+ describe_uuid(0x180F) # '0x180F (Battery Service)'
139
+ spp_channel(parse_ssa_response(sdp_bytes)) # the dynamic RFCOMM channel — browse, don't hardcode
140
+ spp_channel(Capture.from_btsnoop(cap).sdp_records()) # …or recover it straight from a capture
141
+
142
+ async with GattClient("AA:BB:CC:DD:EE:FF") as g: # wraps bleak; pip install "untether-bt[ble]"
143
+ print(g.services())
144
+ await g.subscribe(0xFFE1, print) # CCCD handled for you
145
+ await g.write(0xFFE1, b"\x01")
146
+ ```
147
+
148
+ ## What's here and what's next
149
+
150
+ **Now:** the framing/codec engine; the SPP bridge client (sync + async) plus the self-healing
151
+ `SppConnection` (dogfooded by the Pixoo HA integration); the advertisement decoder;
152
+ the full RE capture pipeline (live **ADB/UIAutomator driver** → btsnoop **+ btsnooz** → HCI/L2CAP/ATT
153
+ extraction → UI-action↔wire-byte correlation); **static + dynamic analysis** (jadx mapping + Frida
154
+ write hooks); the protocol primitives (**SDP** record parser — incl. recovering the RFCOMM channel
155
+ from a capture or live via BlueZ — **GATT** client over bleak, **Assigned-Numbers** resolver). Proven
156
+ on real hardware and uniquely ours (first-class Classic throughout).
157
+
158
+ **Roadmap:** growing the bundled Assigned-Numbers tables; publishing the spec map as a Classic-BT RE
159
+ handbook; contributing parsers upstream.
160
+
161
+ ## License
162
+
163
+ MIT.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "untether-bt"
7
+ version = "0.7.0"
8
+ description = "A Bluetooth Swiss-army-knife for reverse engineering, troubleshooting, and engineering — with first-class Bluetooth Classic (RFCOMM/SPP) support the BLE-only ecosystem lacks."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "dallanwagz" }]
13
+ keywords = [
14
+ "bluetooth", "ble", "bluetooth-classic", "rfcomm", "spp",
15
+ "reverse-engineering", "home-assistant", "esp32", "advertisement",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Communications",
23
+ "Topic :: Software Development :: Libraries",
24
+ "Topic :: System :: Hardware",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ ble = ["bleak>=0.22"] # GATT/LE transport (we wrap bleak, never rebuild it)
30
+ frida = ["frida>=16"] # dynamic app instrumentation (live BLE/RFCOMM write hooks)
31
+ bluez = ["pybluez2>=0.46"] # live Classic SDP browsing on Linux/BlueZ
32
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.5"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/dallanwagz/untether"
36
+ Repository = "https://github.com/dallanwagz/untether"
37
+ Issues = "https://github.com/dallanwagz/untether/issues"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/untether_bt"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
44
+ testpaths = ["tests"]
45
+
46
+ [tool.ruff]
47
+ line-length = 100
48
+ src = ["src"]
@@ -0,0 +1,153 @@
1
+ """untether-bt — a Bluetooth Swiss-army-knife for reverse engineering, troubleshooting, and engineering.
2
+
3
+ First-class Bluetooth **Classic (RFCOMM/SPP)** support — reachable from any host or from Home
4
+ Assistant via the companion ``untether_spp`` ESP32 bridge — plus the protocol primitives the
5
+ BLE-only ecosystem leaves to you. Includes: the framing/codec engine, the SPP bridge client, the
6
+ advertisement decoder, and the full reverse-engineering pipeline: the live ADB/UIAutomator driver
7
+ (drive the vendor app, mark each action) → btsnoop capture → HCI/ATT extraction → UI-action↔
8
+ wire-byte correlation. jadx/Frida wrappers and SDP/GATT-over-bleak follow.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .advertising import (
14
+ ADStructure,
15
+ flags,
16
+ local_name,
17
+ manufacturer_data,
18
+ parse_ad,
19
+ service_data,
20
+ service_uuids16,
21
+ )
22
+ from .android import AdbError, AdbRunner, AndroidDriver, extract_btsnoop_from_zip
23
+ from .apk import (
24
+ ApkAnalysis,
25
+ Finding,
26
+ analyze_apk,
27
+ analyze_tree,
28
+ decompile_apk,
29
+ pull_apk,
30
+ )
31
+ from .frida import FridaSession, hook_script_path, parse_hook_message
32
+ from .gatt import GattClient, normalize_uuid
33
+ from .numbers import (
34
+ company_name,
35
+ describe_uuid,
36
+ gatt_name,
37
+ sdp_service_name,
38
+ uuid16_to_128,
39
+ uuid128_to_16,
40
+ )
41
+ from .sdp import (
42
+ find_rfcomm_channels,
43
+ parse_data_element,
44
+ parse_records,
45
+ parse_ssa_response,
46
+ rfcomm_channel,
47
+ spp_channel,
48
+ )
49
+ from .uiauto import UiNode, find_node, parse_ui_dump
50
+ from .btsnoop import (
51
+ Btsnoop,
52
+ BtsnoopRecord,
53
+ decompress_btsnooz,
54
+ is_btsnooz,
55
+ load_btsnoop,
56
+ make_record,
57
+ parse_btsnoop,
58
+ write_btsnoop,
59
+ )
60
+ from .capture import Capture, Correlation, Mark, Recorder, WireEvent, correlate
61
+ from .framing import (
62
+ DIVOOM_NEWMODE,
63
+ DIVOOM_STUFFED,
64
+ Frame,
65
+ Framing,
66
+ Stuffing,
67
+ crc_sum16,
68
+ )
69
+ from .hci import AttPdu, HciPacket, L2capPayload, att_pdus, hci_packets, l2cap_payloads
70
+ from .spp import AsyncSppBridge, SppBridge
71
+ from .connection import SppConnection
72
+
73
+ __version__ = "0.7.0"
74
+
75
+ __all__ = [
76
+ "__version__",
77
+ # framing
78
+ "Framing",
79
+ "Frame",
80
+ "Stuffing",
81
+ "crc_sum16",
82
+ "DIVOOM_NEWMODE",
83
+ "DIVOOM_STUFFED",
84
+ # spp
85
+ "SppBridge",
86
+ "AsyncSppBridge",
87
+ "SppConnection",
88
+ # advertising
89
+ "ADStructure",
90
+ "parse_ad",
91
+ "manufacturer_data",
92
+ "service_data",
93
+ "service_uuids16",
94
+ "local_name",
95
+ "flags",
96
+ # capture / reverse-engineering
97
+ "Btsnoop",
98
+ "BtsnoopRecord",
99
+ "parse_btsnoop",
100
+ "write_btsnoop",
101
+ "make_record",
102
+ "decompress_btsnooz",
103
+ "is_btsnooz",
104
+ "load_btsnoop",
105
+ "HciPacket",
106
+ "L2capPayload",
107
+ "AttPdu",
108
+ "hci_packets",
109
+ "l2cap_payloads",
110
+ "att_pdus",
111
+ "Capture",
112
+ "WireEvent",
113
+ "Mark",
114
+ "Correlation",
115
+ "Recorder",
116
+ "correlate",
117
+ # android live driver (RE pipeline)
118
+ "AndroidDriver",
119
+ "AdbRunner",
120
+ "AdbError",
121
+ "extract_btsnoop_from_zip",
122
+ "UiNode",
123
+ "parse_ui_dump",
124
+ "find_node",
125
+ # static analysis (jadx)
126
+ "ApkAnalysis",
127
+ "Finding",
128
+ "analyze_tree",
129
+ "analyze_apk",
130
+ "decompile_apk",
131
+ "pull_apk",
132
+ # dynamic instrumentation (frida)
133
+ "FridaSession",
134
+ "parse_hook_message",
135
+ "hook_script_path",
136
+ # assigned numbers
137
+ "uuid16_to_128",
138
+ "uuid128_to_16",
139
+ "company_name",
140
+ "gatt_name",
141
+ "sdp_service_name",
142
+ "describe_uuid",
143
+ # sdp
144
+ "parse_data_element",
145
+ "parse_records",
146
+ "parse_ssa_response",
147
+ "rfcomm_channel",
148
+ "find_rfcomm_channels",
149
+ "spp_channel",
150
+ # gatt (over bleak)
151
+ "GattClient",
152
+ "normalize_uuid",
153
+ ]