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.
- untether_bt-0.7.0/.gitignore +8 -0
- untether_bt-0.7.0/LICENSE +21 -0
- untether_bt-0.7.0/PKG-INFO +194 -0
- untether_bt-0.7.0/README.md +163 -0
- untether_bt-0.7.0/pyproject.toml +48 -0
- untether_bt-0.7.0/src/untether_bt/__init__.py +153 -0
- untether_bt-0.7.0/src/untether_bt/advertising.py +104 -0
- untether_bt-0.7.0/src/untether_bt/android.py +159 -0
- untether_bt-0.7.0/src/untether_bt/apk.py +149 -0
- untether_bt-0.7.0/src/untether_bt/bluez.py +34 -0
- untether_bt-0.7.0/src/untether_bt/btsnoop.py +214 -0
- untether_bt-0.7.0/src/untether_bt/capture.py +142 -0
- untether_bt-0.7.0/src/untether_bt/connection.py +156 -0
- untether_bt-0.7.0/src/untether_bt/framing.py +188 -0
- untether_bt-0.7.0/src/untether_bt/frida.py +102 -0
- untether_bt-0.7.0/src/untether_bt/frida_hooks/android_bt.js +68 -0
- untether_bt-0.7.0/src/untether_bt/gatt.py +80 -0
- untether_bt-0.7.0/src/untether_bt/hci.py +148 -0
- untether_bt-0.7.0/src/untether_bt/numbers.py +118 -0
- untether_bt-0.7.0/src/untether_bt/py.typed +0 -0
- untether_bt-0.7.0/src/untether_bt/sdp.py +140 -0
- untether_bt-0.7.0/src/untether_bt/spp.py +164 -0
- untether_bt-0.7.0/src/untether_bt/uiauto.py +88 -0
- untether_bt-0.7.0/tests/test_advertising.py +67 -0
- untether_bt-0.7.0/tests/test_android.py +139 -0
- untether_bt-0.7.0/tests/test_apk.py +70 -0
- untether_bt-0.7.0/tests/test_bluez.py +21 -0
- untether_bt-0.7.0/tests/test_btsnoop.py +106 -0
- untether_bt-0.7.0/tests/test_capture.py +139 -0
- untether_bt-0.7.0/tests/test_connection.py +158 -0
- untether_bt-0.7.0/tests/test_framing.py +106 -0
- untether_bt-0.7.0/tests/test_frida.py +45 -0
- untether_bt-0.7.0/tests/test_gatt.py +76 -0
- untether_bt-0.7.0/tests/test_numbers.py +43 -0
- untether_bt-0.7.0/tests/test_sdp.py +75 -0
- untether_bt-0.7.0/tests/test_spp.py +80 -0
|
@@ -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
|
+
]
|