espbridge 1.0.0__py3-none-any.whl
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.
- espbridge/__init__.py +96 -0
- espbridge/protocol.py +292 -0
- espbridge/receiver.py +129 -0
- espbridge/sender.py +47 -0
- espbridge/usb_device.py +636 -0
- espbridge-1.0.0.dist-info/METADATA +109 -0
- espbridge-1.0.0.dist-info/RECORD +10 -0
- espbridge-1.0.0.dist-info/WHEEL +5 -0
- espbridge-1.0.0.dist-info/licenses/LICENSE +21 -0
- espbridge-1.0.0.dist-info/top_level.txt +1 -0
espbridge/__init__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
espbridge - Framed USB bridge protocol for ESP32 <-> Termux/Linux, no root required.
|
|
3
|
+
|
|
4
|
+
Typical usage:
|
|
5
|
+
|
|
6
|
+
import espbridge as eb
|
|
7
|
+
|
|
8
|
+
backend = eb.detect_backend()
|
|
9
|
+
if backend == "termux":
|
|
10
|
+
path = eb.auto_detect_device()
|
|
11
|
+
eb.request_permission(path)
|
|
12
|
+
# ... open_usb_device() re-execs your script with TERMUX_USB_FD set ...
|
|
13
|
+
device = eb.wrap_fd(int(os.environ["TERMUX_USB_FD"]))
|
|
14
|
+
else:
|
|
15
|
+
device = eb.wrap_direct()
|
|
16
|
+
|
|
17
|
+
ep_in, ep_out, iface = eb.get_cdc_endpoints(device)
|
|
18
|
+
eb.claim_device(device, iface, fd_wrapped=(backend == "termux"))
|
|
19
|
+
eb.reset_endpoint_toggles(device, ep_in, ep_out)
|
|
20
|
+
|
|
21
|
+
sender = eb.Sender(device, ep_out)
|
|
22
|
+
receiver = eb.ReceiverThread(device, ep_in)
|
|
23
|
+
receiver.start()
|
|
24
|
+
|
|
25
|
+
proto = eb.Protocol(sender, receiver)
|
|
26
|
+
proto.start()
|
|
27
|
+
|
|
28
|
+
resp = proto.send_cmd("PING")
|
|
29
|
+
print(resp) # -> {"ok": True, "msg": "pong"} (if your firmware implements PING)
|
|
30
|
+
|
|
31
|
+
See examples/echo_cmd.py for a complete runnable version of the above, and
|
|
32
|
+
the companion BridgeProtocol PlatformIO library for the ESP32-side firmware
|
|
33
|
+
that speaks the same frames.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from .protocol import (
|
|
37
|
+
Protocol,
|
|
38
|
+
FrameParser,
|
|
39
|
+
build_frame,
|
|
40
|
+
build_cmd,
|
|
41
|
+
build_ack,
|
|
42
|
+
build_html_frame,
|
|
43
|
+
MAGIC,
|
|
44
|
+
TYPE_CMD,
|
|
45
|
+
TYPE_RESP,
|
|
46
|
+
TYPE_EVENT,
|
|
47
|
+
TYPE_PCAP,
|
|
48
|
+
TYPE_ACK,
|
|
49
|
+
TYPE_HTML,
|
|
50
|
+
HEADER_SIZE,
|
|
51
|
+
MAX_PAYLOAD,
|
|
52
|
+
)
|
|
53
|
+
from .sender import Sender
|
|
54
|
+
from .receiver import ReceiverThread
|
|
55
|
+
from .usb_device import (
|
|
56
|
+
detect_backend,
|
|
57
|
+
is_root,
|
|
58
|
+
has_termux_api,
|
|
59
|
+
list_usb_devices,
|
|
60
|
+
request_permission,
|
|
61
|
+
open_usb_device,
|
|
62
|
+
auto_detect_device,
|
|
63
|
+
launch_with_fd,
|
|
64
|
+
relaunch_with_fd,
|
|
65
|
+
wrap_fd,
|
|
66
|
+
wrap_direct,
|
|
67
|
+
find_usb_device_direct,
|
|
68
|
+
claim_device,
|
|
69
|
+
get_cdc_endpoints,
|
|
70
|
+
reset_endpoint_toggles,
|
|
71
|
+
describe_device,
|
|
72
|
+
init_uart_bridge,
|
|
73
|
+
set_uart_bridge_baud,
|
|
74
|
+
is_uart_bridge,
|
|
75
|
+
set_dtr_rts,
|
|
76
|
+
UART_BRIDGE_VIDPIDS,
|
|
77
|
+
ESP32_KNOWN,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
# protocol
|
|
82
|
+
"Protocol", "FrameParser", "build_frame", "build_cmd", "build_ack", "build_html_frame",
|
|
83
|
+
"MAGIC", "TYPE_CMD", "TYPE_RESP", "TYPE_EVENT", "TYPE_PCAP", "TYPE_ACK", "TYPE_HTML",
|
|
84
|
+
"HEADER_SIZE", "MAX_PAYLOAD",
|
|
85
|
+
# transport
|
|
86
|
+
"Sender", "ReceiverThread",
|
|
87
|
+
# device / backend
|
|
88
|
+
"detect_backend", "is_root", "has_termux_api",
|
|
89
|
+
"list_usb_devices", "request_permission", "open_usb_device", "auto_detect_device",
|
|
90
|
+
"launch_with_fd", "relaunch_with_fd", "wrap_fd", "wrap_direct", "find_usb_device_direct",
|
|
91
|
+
"claim_device", "get_cdc_endpoints", "reset_endpoint_toggles",
|
|
92
|
+
"describe_device", "init_uart_bridge", "set_uart_bridge_baud", "is_uart_bridge",
|
|
93
|
+
"set_dtr_rts", "UART_BRIDGE_VIDPIDS", "ESP32_KNOWN",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
__version__ = "1.0.0"
|
espbridge/protocol.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
protocol.py - Framed binary bridge protocol for ESP32 <-> host communication
|
|
3
|
+
over USB CDC. Part of the `espbridge` library — use this to build your own
|
|
4
|
+
ESP32-to-Termux (or ESP32-to-Linux) tools without root, without a serial
|
|
5
|
+
line-protocol, and without writing USB framing code yourself.
|
|
6
|
+
|
|
7
|
+
Pairs with the matching BridgeProtocol.h/.cpp PlatformIO library on the
|
|
8
|
+
ESP32 side. Both speak the exact same frame layout, so any command/response/
|
|
9
|
+
event you invent will round-trip correctly as long as the two are kept in
|
|
10
|
+
sync.
|
|
11
|
+
|
|
12
|
+
Frame layout:
|
|
13
|
+
[MAGIC 2B][TYPE 1B][ID 1B][LENGTH 4B LE][PAYLOAD NB]
|
|
14
|
+
|
|
15
|
+
Types:
|
|
16
|
+
0x01 CMD Host -> ESP32 JSON command
|
|
17
|
+
0x02 RESP ESP32 -> Host JSON response (mirrors CMD id)
|
|
18
|
+
0x03 EVENT ESP32 -> Host JSON async event (id=0)
|
|
19
|
+
0x04 PCAP ESP32 -> Host binary chunk (id = chunk index) — use
|
|
20
|
+
for any binary stream, not just pcap
|
|
21
|
+
0x05 ACK Host -> ESP32 JSON {"chunk": N}
|
|
22
|
+
0x06 HTML Host -> ESP32 chunked raw payload (e.g. web UI upload)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import struct
|
|
27
|
+
import threading
|
|
28
|
+
import queue
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
MAGIC = b'\xAD\xDE'
|
|
32
|
+
TYPE_CMD = 0x01
|
|
33
|
+
TYPE_RESP = 0x02
|
|
34
|
+
TYPE_EVENT = 0x03
|
|
35
|
+
TYPE_PCAP = 0x04
|
|
36
|
+
TYPE_ACK = 0x05
|
|
37
|
+
TYPE_HTML = 0x06
|
|
38
|
+
HEADER_SIZE = 8 # 2 magic + 1 type + 1 id + 4 length (LE)
|
|
39
|
+
MAX_PAYLOAD = 65536 # sanity cap; real chunks are ≤512 B
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_html_frame(seq: int, chunk: bytes, is_last: bool) -> bytes:
|
|
43
|
+
# 2-byte seq (LE) + 1-byte is_last flag + raw chunk data
|
|
44
|
+
inner = struct.pack('<HB', seq, 1 if is_last else 0) + chunk
|
|
45
|
+
return build_frame(TYPE_HTML, 0, inner)
|
|
46
|
+
|
|
47
|
+
# Frame building
|
|
48
|
+
|
|
49
|
+
def build_frame(ptype: int, pid: int, payload: bytes) -> bytes:
|
|
50
|
+
header = MAGIC + struct.pack('<BBL', ptype, pid, len(payload))
|
|
51
|
+
return header + payload
|
|
52
|
+
|
|
53
|
+
def build_cmd(cmd: str, args: dict = None, pid: int = 0) -> bytes:
|
|
54
|
+
payload = json.dumps({"cmd": cmd, "args": args or {}}).encode()
|
|
55
|
+
return build_frame(TYPE_CMD, pid, payload)
|
|
56
|
+
|
|
57
|
+
def build_ack(chunk_index: int) -> bytes:
|
|
58
|
+
payload = json.dumps({"chunk": chunk_index}).encode()
|
|
59
|
+
return build_frame(TYPE_ACK, 0, payload)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Frame parsing ─
|
|
63
|
+
|
|
64
|
+
class FrameParser:
|
|
65
|
+
"""
|
|
66
|
+
Stateful streaming parser.
|
|
67
|
+
Feed raw bytes in any chunk size, get complete frame dicts out.
|
|
68
|
+
Uses bytearray internally to avoid O(n²) bytes concatenation.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self):
|
|
72
|
+
self._buf = bytearray()
|
|
73
|
+
|
|
74
|
+
def reset(self):
|
|
75
|
+
self._buf = bytearray()
|
|
76
|
+
|
|
77
|
+
def feed(self, data: bytes) -> list:
|
|
78
|
+
"""Feed raw bytes. Returns list of parsed frame dicts (may be empty)."""
|
|
79
|
+
self._buf += data
|
|
80
|
+
frames = []
|
|
81
|
+
while True:
|
|
82
|
+
frame = self._try_parse()
|
|
83
|
+
if frame is None:
|
|
84
|
+
break
|
|
85
|
+
frames.append(frame)
|
|
86
|
+
return frames
|
|
87
|
+
|
|
88
|
+
def _try_parse(self):
|
|
89
|
+
buf = self._buf
|
|
90
|
+
|
|
91
|
+
if len(buf) < HEADER_SIZE:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# Resync on bad magic
|
|
95
|
+
if buf[0] != 0xAD or buf[1] != 0xDE:
|
|
96
|
+
idx = buf.find(b'\xAD\xDE', 1)
|
|
97
|
+
if idx == -1:
|
|
98
|
+
self._buf = bytearray(buf[-1:]) if buf else bytearray()
|
|
99
|
+
return None
|
|
100
|
+
del self._buf[:idx]
|
|
101
|
+
buf = self._buf
|
|
102
|
+
if len(buf) < HEADER_SIZE:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
ptype, pid, length = struct.unpack_from('<BBL', buf, 2)
|
|
106
|
+
|
|
107
|
+
if length > MAX_PAYLOAD:
|
|
108
|
+
del self._buf[:2]
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
total = HEADER_SIZE + length
|
|
112
|
+
if len(buf) < total:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
payload = bytes(buf[HEADER_SIZE:total])
|
|
116
|
+
del self._buf[:total]
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"type": ptype,
|
|
120
|
+
"id": pid,
|
|
121
|
+
"length": length,
|
|
122
|
+
"payload": payload,
|
|
123
|
+
"json": _safe_json(payload) if ptype != TYPE_PCAP else None,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _safe_json(data: bytes):
|
|
128
|
+
try:
|
|
129
|
+
return json.loads(data)
|
|
130
|
+
except Exception:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Protocol:
|
|
136
|
+
"""
|
|
137
|
+
Wraps a Sender + ReceiverThread with framed protocol logic.
|
|
138
|
+
|
|
139
|
+
Usage:
|
|
140
|
+
proto = Protocol(sender, receiver)
|
|
141
|
+
proto.start()
|
|
142
|
+
|
|
143
|
+
resp = proto.send_cmd("SCAN_WIFI", timeout=30)
|
|
144
|
+
|
|
145
|
+
proto.on_event = lambda ev: ... # called in dispatch thread
|
|
146
|
+
proto.on_pcap = lambda data, idx: ...
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self, sender, receiver_thread):
|
|
150
|
+
self._tx = sender
|
|
151
|
+
self._rx = receiver_thread
|
|
152
|
+
self._parser = FrameParser()
|
|
153
|
+
self._pending = {} # pid -> {"event": Event, "result": any}
|
|
154
|
+
self._lock = threading.Lock()
|
|
155
|
+
self._id_ctr = 1
|
|
156
|
+
self._running = False
|
|
157
|
+
|
|
158
|
+
self._dispatch_q = queue.Queue(maxsize=2048)
|
|
159
|
+
self._html_resp_q = queue.Queue(maxsize=16)
|
|
160
|
+
|
|
161
|
+
self.on_event = None # fn(frame_dict)
|
|
162
|
+
self.on_pcap = None # fn(data: bytes, chunk_idx: int)
|
|
163
|
+
self.log = print
|
|
164
|
+
|
|
165
|
+
def start(self):
|
|
166
|
+
self._running = True
|
|
167
|
+
self._parser.reset()
|
|
168
|
+
|
|
169
|
+
t_rx = threading.Thread(target=self._read_loop,
|
|
170
|
+
daemon=True, name="proto-rx")
|
|
171
|
+
t_rx.start()
|
|
172
|
+
|
|
173
|
+
t_cb = threading.Thread(target=self._callback_loop,
|
|
174
|
+
daemon=True, name="proto-cb")
|
|
175
|
+
t_cb.start()
|
|
176
|
+
|
|
177
|
+
def stop(self):
|
|
178
|
+
self._running = False
|
|
179
|
+
with self._lock:
|
|
180
|
+
for entry in self._pending.values():
|
|
181
|
+
entry["event"].set()
|
|
182
|
+
|
|
183
|
+
def reset(self):
|
|
184
|
+
"""Clear parser state and pending commands. Call between retries."""
|
|
185
|
+
self._parser.reset()
|
|
186
|
+
with self._lock:
|
|
187
|
+
for entry in self._pending.values():
|
|
188
|
+
entry["event"].set()
|
|
189
|
+
self._pending.clear()
|
|
190
|
+
|
|
191
|
+
def send_cmd(self, cmd: str, args: dict = None, timeout: float = 5.0):
|
|
192
|
+
"""
|
|
193
|
+
Send a CMD frame, block until the matching RESP arrives.
|
|
194
|
+
Returns the response JSON dict, or None on timeout.
|
|
195
|
+
"""
|
|
196
|
+
with self._lock:
|
|
197
|
+
# Pick an ID not currently in use
|
|
198
|
+
pid = self._id_ctr & 0xFF
|
|
199
|
+
while pid in self._pending or pid == 0:
|
|
200
|
+
self._id_ctr = (self._id_ctr % 254) + 1
|
|
201
|
+
pid = self._id_ctr & 0xFF
|
|
202
|
+
self._id_ctr = (self._id_ctr % 254) + 1
|
|
203
|
+
|
|
204
|
+
evt = threading.Event()
|
|
205
|
+
self._pending[pid] = {"event": evt, "result": None}
|
|
206
|
+
|
|
207
|
+
frame = build_cmd(cmd, args or {}, pid)
|
|
208
|
+
self._tx.send_bytes(frame)
|
|
209
|
+
self.log(f"[proto] >> CMD id={pid} cmd={cmd}")
|
|
210
|
+
|
|
211
|
+
evt.wait(timeout=timeout)
|
|
212
|
+
|
|
213
|
+
with self._lock:
|
|
214
|
+
entry = self._pending.pop(pid, None)
|
|
215
|
+
|
|
216
|
+
if entry and entry["result"] is not None:
|
|
217
|
+
return entry["result"]
|
|
218
|
+
|
|
219
|
+
self.log(f"[proto] CMD id={pid} '{cmd}' timed out after {timeout}s")
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def send_ack(self, chunk_index: int):
|
|
223
|
+
self._tx.send_bytes(build_ack(chunk_index))
|
|
224
|
+
|
|
225
|
+
def _read_loop(self):
|
|
226
|
+
while self._running:
|
|
227
|
+
raw = self._rx.readline_bytes(timeout=0.01) # 10 ms poll
|
|
228
|
+
if not raw:
|
|
229
|
+
continue
|
|
230
|
+
for frame in self._parser.feed(raw):
|
|
231
|
+
self._dispatch(frame)
|
|
232
|
+
|
|
233
|
+
def _dispatch(self, frame: dict):
|
|
234
|
+
t = frame["type"]
|
|
235
|
+
|
|
236
|
+
if t == TYPE_RESP:
|
|
237
|
+
pid = frame["id"]
|
|
238
|
+
if pid == 0:
|
|
239
|
+
# Raw HTML chunk ACK — goes to its own queue, not the shared one
|
|
240
|
+
try:
|
|
241
|
+
self._html_resp_q.put_nowait(frame["json"])
|
|
242
|
+
except queue.Full:
|
|
243
|
+
pass
|
|
244
|
+
else:
|
|
245
|
+
with self._lock:
|
|
246
|
+
entry = self._pending.get(pid)
|
|
247
|
+
if entry:
|
|
248
|
+
entry["result"] = frame["json"]
|
|
249
|
+
entry["event"].set()
|
|
250
|
+
|
|
251
|
+
elif t == TYPE_EVENT:
|
|
252
|
+
try:
|
|
253
|
+
self._dispatch_q.put_nowait(("event", frame["json"]))
|
|
254
|
+
except queue.Full:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
elif t == TYPE_PCAP:
|
|
258
|
+
idx = frame["id"]
|
|
259
|
+
self.send_ack(idx)
|
|
260
|
+
try:
|
|
261
|
+
self._dispatch_q.put_nowait(("pcap", frame["payload"], idx))
|
|
262
|
+
except queue.Full:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
def _wait_html_resp(self, expected_seq, timeout=3.0):
|
|
266
|
+
deadline = time.monotonic() + timeout
|
|
267
|
+
while True:
|
|
268
|
+
remaining = deadline - time.monotonic()
|
|
269
|
+
if remaining <= 0:
|
|
270
|
+
return None
|
|
271
|
+
try:
|
|
272
|
+
resp = self._html_resp_q.get(timeout=remaining)
|
|
273
|
+
except queue.Empty:
|
|
274
|
+
return None
|
|
275
|
+
if resp and resp.get("seq") == expected_seq:
|
|
276
|
+
return resp
|
|
277
|
+
|
|
278
|
+
def _callback_loop(self):
|
|
279
|
+
while self._running:
|
|
280
|
+
try:
|
|
281
|
+
item = self._dispatch_q.get(timeout=0.05)
|
|
282
|
+
except queue.Empty:
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
kind = item[0]
|
|
286
|
+
try:
|
|
287
|
+
if kind == "event" and self.on_event:
|
|
288
|
+
self.on_event(item[1])
|
|
289
|
+
elif kind == "pcap" and self.on_pcap:
|
|
290
|
+
self.on_pcap(item[1], item[2])
|
|
291
|
+
except Exception as e:
|
|
292
|
+
self.log(f"[proto] callback error: {e}")
|
espbridge/receiver.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
receiver.py - Background thread reading raw bytes from the ESP32's USB bulk
|
|
3
|
+
IN endpoint. Part of the `espbridge` library.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import threading
|
|
7
|
+
import queue
|
|
8
|
+
import time
|
|
9
|
+
import usb.core
|
|
10
|
+
|
|
11
|
+
# How many raw-byte chunks to buffer before dropping oldest.
|
|
12
|
+
# 512 × 16 KB = 8 MB max memory - comfortable on any modern phone.
|
|
13
|
+
_BYTE_QUEUE_DEPTH = 512
|
|
14
|
+
_LINE_QUEUE_DEPTH = 256
|
|
15
|
+
_READ_SIZE = 16 * 1024 # 16 KB per bulk read
|
|
16
|
+
_USB_TIMEOUT_MS = 100 # tighter poll → lower latency
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ReceiverThread(threading.Thread):
|
|
20
|
+
def __init__(self, device, ep_in_addr: int, log_func=None):
|
|
21
|
+
super().__init__(daemon=True, name="ESP32-Receiver")
|
|
22
|
+
self.device = device
|
|
23
|
+
self.ep_in_addr = ep_in_addr
|
|
24
|
+
self.log = log_func or print
|
|
25
|
+
self._line_queue = queue.Queue(maxsize=_LINE_QUEUE_DEPTH)
|
|
26
|
+
self._byte_queue = queue.Queue(maxsize=_BYTE_QUEUE_DEPTH)
|
|
27
|
+
self._stop_event = threading.Event()
|
|
28
|
+
self._buf = b"" # only used for line splitting
|
|
29
|
+
self._error_count = 0
|
|
30
|
+
|
|
31
|
+
def stop(self):
|
|
32
|
+
self._stop_event.set()
|
|
33
|
+
|
|
34
|
+
def readline(self, timeout: float = None) -> "str | None":
|
|
35
|
+
"""Get next text line (for READY handshake / debug). Non-blocking with timeout."""
|
|
36
|
+
try:
|
|
37
|
+
return self._line_queue.get(timeout=timeout)
|
|
38
|
+
except queue.Empty:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def readline_bytes(self, timeout: float = None) -> "bytes | None":
|
|
42
|
+
"""Get next raw USB chunk for the framed protocol parser."""
|
|
43
|
+
try:
|
|
44
|
+
return self._byte_queue.get(timeout=timeout)
|
|
45
|
+
except queue.Empty:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def drain_stale(self, duration: float = 0.5):
|
|
49
|
+
"""
|
|
50
|
+
Discard any bytes sitting in our internal queues that were filled by
|
|
51
|
+
the receiver thread from a previous session.
|
|
52
|
+
|
|
53
|
+
IMPORTANT: Do NOT call device.read() here - the receiver thread owns
|
|
54
|
+
the USB endpoint exclusively. Calling read() from a second thread
|
|
55
|
+
causes libusb on Android to return corrupt data or lock up the
|
|
56
|
+
endpoint, which is what causes the 30-second hang on the second run.
|
|
57
|
+
|
|
58
|
+
Instead we just drain the software queues. The receiver thread is
|
|
59
|
+
already running and will have consumed any hardware FIFO leftovers
|
|
60
|
+
within a few hundred ms via its normal poll loop.
|
|
61
|
+
"""
|
|
62
|
+
deadline = time.monotonic() + duration
|
|
63
|
+
drained = 0
|
|
64
|
+
while time.monotonic() < deadline:
|
|
65
|
+
drained_this_round = 0
|
|
66
|
+
try:
|
|
67
|
+
self._byte_queue.get_nowait()
|
|
68
|
+
drained += 1
|
|
69
|
+
drained_this_round += 1
|
|
70
|
+
except queue.Empty:
|
|
71
|
+
pass
|
|
72
|
+
try:
|
|
73
|
+
self._line_queue.get_nowait()
|
|
74
|
+
drained_this_round += 1
|
|
75
|
+
except queue.Empty:
|
|
76
|
+
pass
|
|
77
|
+
if drained_this_round == 0:
|
|
78
|
+
time.sleep(0.02)
|
|
79
|
+
self._buf = b""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run(self):
|
|
83
|
+
while not self._stop_event.is_set():
|
|
84
|
+
try:
|
|
85
|
+
chunk = bytes(self.device.read(self.ep_in_addr, _READ_SIZE,
|
|
86
|
+
timeout=_USB_TIMEOUT_MS))
|
|
87
|
+
if not chunk:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
self._error_count = 0
|
|
91
|
+
try:
|
|
92
|
+
self._byte_queue.put_nowait(chunk)
|
|
93
|
+
except queue.Full:
|
|
94
|
+
try:
|
|
95
|
+
self._byte_queue.get_nowait()
|
|
96
|
+
except queue.Empty:
|
|
97
|
+
pass
|
|
98
|
+
self._byte_queue.put_nowait(chunk)
|
|
99
|
+
|
|
100
|
+
self._buf += chunk
|
|
101
|
+
self._flush_lines()
|
|
102
|
+
|
|
103
|
+
except usb.core.USBTimeoutError:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
except usb.core.USBError as e:
|
|
107
|
+
code = getattr(e, 'errno', None)
|
|
108
|
+
|
|
109
|
+
if code in (None, 32, 75):
|
|
110
|
+
self._error_count += 1
|
|
111
|
+
if self._error_count <= 5:
|
|
112
|
+
self.log(f"USB error (recoverable, #{self._error_count}): {e}")
|
|
113
|
+
time.sleep(min(0.05 * self._error_count, 0.3))
|
|
114
|
+
continue
|
|
115
|
+
else:
|
|
116
|
+
self.log(f"fatal USB error: {e}")
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _flush_lines(self):
|
|
121
|
+
"""Split accumulated buffer into newline-delimited text lines."""
|
|
122
|
+
while b"\n" in self._buf:
|
|
123
|
+
line, self._buf = self._buf.split(b"\n", 1)
|
|
124
|
+
text = line.rstrip(b"\r").decode("utf-8", errors="replace").strip()
|
|
125
|
+
if text:
|
|
126
|
+
try:
|
|
127
|
+
self._line_queue.put_nowait(text)
|
|
128
|
+
except queue.Full:
|
|
129
|
+
pass # don't block on debug lines
|
espbridge/sender.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sender.py - Thread-safe sender for the ESP32's USB bulk OUT endpoint.
|
|
3
|
+
Part of the `espbridge` library.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import usb.core
|
|
9
|
+
|
|
10
|
+
_DEFAULT_TIMEOUT_MS = 3000 # USB write timeout
|
|
11
|
+
_MAX_RETRIES = 2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Sender:
|
|
15
|
+
def __init__(self, device, ep_out_addr: int, log_func=None,
|
|
16
|
+
timeout_ms: int = _DEFAULT_TIMEOUT_MS):
|
|
17
|
+
self.device = device
|
|
18
|
+
self.ep_out_addr = ep_out_addr
|
|
19
|
+
self.log = log_func or print
|
|
20
|
+
self._timeout_ms = timeout_ms
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def send_bytes(self, data: bytes) -> bool:
|
|
25
|
+
"""
|
|
26
|
+
Send raw bytes with retry on recoverable USB errors.
|
|
27
|
+
Thread-safe. Returns True on success.
|
|
28
|
+
"""
|
|
29
|
+
with self._lock:
|
|
30
|
+
for attempt in range(_MAX_RETRIES):
|
|
31
|
+
try:
|
|
32
|
+
self.device.write(self.ep_out_addr, data,
|
|
33
|
+
timeout=self._timeout_ms)
|
|
34
|
+
return True
|
|
35
|
+
except usb.core.USBError as e:
|
|
36
|
+
code = getattr(e, 'errno', None)
|
|
37
|
+
if code in (32, 75) and attempt < _MAX_RETRIES - 1:
|
|
38
|
+
time.sleep(0.02 * (attempt + 1))
|
|
39
|
+
continue
|
|
40
|
+
self.log(f"[sender] write error (attempt {attempt+1}): {e}")
|
|
41
|
+
return False
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def send(self, message: str, newline: bool = True) -> bool:
|
|
45
|
+
"""Send a UTF-8 string (legacy text mode, still used for debug)."""
|
|
46
|
+
data = (message + "\n" if newline else message).encode("utf-8")
|
|
47
|
+
return self.send_bytes(data)
|
espbridge/usb_device.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""
|
|
2
|
+
usb_device.py - Auto-detect an ESP32 over USB and wrap it for libusb, with or
|
|
3
|
+
without root. Part of the `espbridge` library.
|
|
4
|
+
|
|
5
|
+
Supports two backends selected automatically at runtime:
|
|
6
|
+
|
|
7
|
+
termux - No-root Termux: all USB access goes through the Android USB host
|
|
8
|
+
stack via termux-api Usb + TERMUX_USB_FD. The two-process
|
|
9
|
+
bootstrap dance (parent requests permission -> child inherits fd)
|
|
10
|
+
is required. Works on stock Android with Termux + Termux:API.
|
|
11
|
+
|
|
12
|
+
root - Running as uid 0 (Termux:Root, Nethunter terminal, plain Linux,
|
|
13
|
+
etc.): libusb opens /dev/bus/usb directly - no termux-api
|
|
14
|
+
binary, no Android permission dialog, no child process needed.
|
|
15
|
+
|
|
16
|
+
Call detect_backend() early in main() to pick the right path.
|
|
17
|
+
All other public helpers work the same regardless of backend.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import json
|
|
22
|
+
import ctypes
|
|
23
|
+
import usb.core
|
|
24
|
+
import usb.backend.libusb1 as libusb1
|
|
25
|
+
|
|
26
|
+
_TERMUX_API = "/data/data/com.termux/files/usr/libexec/termux-api"
|
|
27
|
+
|
|
28
|
+
# Known ESP32 native USB VID:PIDs
|
|
29
|
+
ESP32_KNOWN = {
|
|
30
|
+
(0x303A, 0x1001): "ESP32 native USB CDC",
|
|
31
|
+
(0x303A, 0x0002): "ESP32 native USB CDC",
|
|
32
|
+
(0x10C4, 0xEA60): "CP2102 (ESP32 devboard)",
|
|
33
|
+
(0x1A86, 0x7523): "CH340 (ESP32 devboard)",
|
|
34
|
+
(0x1A86, 0x55D4): "CH9102 (ESP32 devboard)",
|
|
35
|
+
(0x0403, 0x6001): "FTDI FT232 (ESP32 devboard)",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Runtime environment detection
|
|
40
|
+
|
|
41
|
+
def is_root() -> bool:
|
|
42
|
+
"""Returns True if the current process is running as uid 0."""
|
|
43
|
+
return os.getuid() == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def has_termux_api() -> bool:
|
|
47
|
+
"""Returns True if the termux-api binary is present (Termux environment)."""
|
|
48
|
+
return os.path.isfile(_TERMUX_API)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_backend() -> str:
|
|
52
|
+
"""
|
|
53
|
+
Determine which USB access backend to use and return its name.
|
|
54
|
+
|
|
55
|
+
Decision order:
|
|
56
|
+
1. uid == 0 -> 'root' (Nethunter, Termux:Root, sudo)
|
|
57
|
+
2. termux-api present -> 'termux' (stock Termux, no-root)
|
|
58
|
+
3. neither -> RuntimeError with install hint
|
|
59
|
+
|
|
60
|
+
Returns: 'root' | 'termux'
|
|
61
|
+
"""
|
|
62
|
+
if is_root():
|
|
63
|
+
return "root"
|
|
64
|
+
if has_termux_api():
|
|
65
|
+
return "termux"
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
"No USB backend available.\n"
|
|
68
|
+
" • Run as root (Nethunter / Termux:Root / sudo), OR\n"
|
|
69
|
+
" • Install Termux:API add-on and: pkg install termux-api"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Low-level: call termux-api Usb without subprocess
|
|
74
|
+
|
|
75
|
+
def _run_termux_api(*args, timeout: int = 15) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Fork + exec termux-api with the given args, collect stdout, return it.
|
|
78
|
+
|
|
79
|
+
Equivalent to:
|
|
80
|
+
/data/.../termux-api Usb -a <action> [params…]
|
|
81
|
+
but using only os.fork / os.execve / os.pipe - no subprocess module.
|
|
82
|
+
|
|
83
|
+
Args are everything *after* the binary path, e.g.:
|
|
84
|
+
_run_termux_api("Usb", "-a", "list")
|
|
85
|
+
"""
|
|
86
|
+
r_fd, w_fd = os.pipe()
|
|
87
|
+
|
|
88
|
+
pid = os.fork()
|
|
89
|
+
if pid == 0:
|
|
90
|
+
try:
|
|
91
|
+
os.close(r_fd)
|
|
92
|
+
os.dup2(w_fd, 1)
|
|
93
|
+
os.close(w_fd)
|
|
94
|
+
devnull = os.open("/dev/null", os.O_WRONLY)
|
|
95
|
+
os.dup2(devnull, 2)
|
|
96
|
+
os.close(devnull)
|
|
97
|
+
argv = [_TERMUX_API] + list(args)
|
|
98
|
+
os.execve(_TERMUX_API, argv, os.environ.copy())
|
|
99
|
+
except Exception:
|
|
100
|
+
os._exit(1)
|
|
101
|
+
os._exit(0) # unreachable, but safe
|
|
102
|
+
else:
|
|
103
|
+
os.close(w_fd)
|
|
104
|
+
chunks = []
|
|
105
|
+
while True:
|
|
106
|
+
chunk = os.read(r_fd, 4096)
|
|
107
|
+
if not chunk:
|
|
108
|
+
break
|
|
109
|
+
chunks.append(chunk)
|
|
110
|
+
os.close(r_fd)
|
|
111
|
+
_, status = os.waitpid(pid, 0)
|
|
112
|
+
exit_code = os.waitstatus_to_exitcode(status)
|
|
113
|
+
output = b"".join(chunks).decode("utf-8", errors="replace").strip()
|
|
114
|
+
if exit_code != 0 and not output:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"termux-api exited {exit_code} with no output (args={args})"
|
|
117
|
+
)
|
|
118
|
+
return output
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Termux backend: public API
|
|
122
|
+
|
|
123
|
+
def list_usb_devices() -> list[str]:
|
|
124
|
+
"""
|
|
125
|
+
Return a list of available USB device paths.
|
|
126
|
+
Replaces: subprocess.check_output(["termux-usb", "-l"])
|
|
127
|
+
Termux backend only.
|
|
128
|
+
"""
|
|
129
|
+
out = _run_termux_api("Usb", "-a", "list")
|
|
130
|
+
try:
|
|
131
|
+
devices = json.loads(out)
|
|
132
|
+
return devices if isinstance(devices, list) else []
|
|
133
|
+
except json.JSONDecodeError as e:
|
|
134
|
+
raise RuntimeError(f"termux-api Usb list returned bad JSON: {e!r}\n{out!r}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def request_permission(device_path: str) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Show the Android USB-permission dialog for device_path.
|
|
140
|
+
Returns True if the user granted permission.
|
|
141
|
+
Termux backend only.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
out = _run_termux_api(
|
|
145
|
+
"Usb", "-a", "permission",
|
|
146
|
+
"--ez", "request", "true",
|
|
147
|
+
"--es", "device", device_path,
|
|
148
|
+
)
|
|
149
|
+
return "yes" in out.lower() or "granted" in out.lower()
|
|
150
|
+
except Exception:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def open_usb_device(device_path: str, callback_cmd: str,
|
|
155
|
+
export_as_env: bool = True) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Ask termux-api to open device_path and call callback_cmd with the fd.
|
|
158
|
+
Termux backend only.
|
|
159
|
+
"""
|
|
160
|
+
params = [
|
|
161
|
+
"Usb", "-a", "open",
|
|
162
|
+
"--ez", "request", "true",
|
|
163
|
+
"--es", "device", device_path,
|
|
164
|
+
]
|
|
165
|
+
out = _run_termux_api(*params)
|
|
166
|
+
_check_api_output(out)
|
|
167
|
+
|
|
168
|
+
fd_str = out.strip()
|
|
169
|
+
env = os.environ.copy()
|
|
170
|
+
if export_as_env:
|
|
171
|
+
env["TERMUX_USB_FD"] = fd_str
|
|
172
|
+
env["TERMUX_USB_DEVICE"] = device_path
|
|
173
|
+
argv = callback_cmd.split()
|
|
174
|
+
os.execvpe(argv[0], argv, env)
|
|
175
|
+
else:
|
|
176
|
+
argv = callback_cmd.split() + [fd_str]
|
|
177
|
+
os.execvpe(argv[0], argv, env)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _check_api_output(out: str):
|
|
181
|
+
"""Raise RuntimeError on known termux-api error strings."""
|
|
182
|
+
lower = out.lower()
|
|
183
|
+
if "no such device" in lower:
|
|
184
|
+
raise RuntimeError("No such device.")
|
|
185
|
+
if "no permission" in lower or "permission denied" in lower:
|
|
186
|
+
raise RuntimeError("Permission denied.")
|
|
187
|
+
if "permission request timeout" in lower:
|
|
188
|
+
raise RuntimeError("Permission request timeout.")
|
|
189
|
+
if "failed to open" in lower or "open device failed" in lower:
|
|
190
|
+
raise RuntimeError("Open device failed.")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def auto_detect_device() -> str:
|
|
194
|
+
"""
|
|
195
|
+
Return the first available USB device path (termux backend).
|
|
196
|
+
Raises RuntimeError if none found.
|
|
197
|
+
"""
|
|
198
|
+
devices = list_usb_devices()
|
|
199
|
+
if not devices:
|
|
200
|
+
raise RuntimeError("No USB devices found. Is the ESP32 plugged in via OTG?")
|
|
201
|
+
return devices[0]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def launch_with_fd(cmd: str, device_path: str, log_file: str,
|
|
205
|
+
tail_fn=None) -> tuple:
|
|
206
|
+
"""
|
|
207
|
+
Fork-exec termux-api Usb open, log stdout+stderr to log_file (append),
|
|
208
|
+
and tail the log in a daemon thread. Termux backend only.
|
|
209
|
+
|
|
210
|
+
Returns (child_pid, child_done_event, tail_thread).
|
|
211
|
+
|
|
212
|
+
The CALLER owns os.waitpid(child_pid, 0) and must call
|
|
213
|
+
child_done.set() afterward so the tail thread can drain final output
|
|
214
|
+
without racing on waitpid itself (which caused ChildProcessError).
|
|
215
|
+
"""
|
|
216
|
+
import threading
|
|
217
|
+
import time
|
|
218
|
+
|
|
219
|
+
env = os.environ.copy()
|
|
220
|
+
env["TERMUX_CALLBACK"] = cmd
|
|
221
|
+
env["TERMUX_EXPORT_FD"] = "true"
|
|
222
|
+
argv = [_TERMUX_API, "Usb", "-a", "open",
|
|
223
|
+
"--ez", "request", "true", "--es", "device", device_path]
|
|
224
|
+
|
|
225
|
+
pid = os.fork()
|
|
226
|
+
if pid == 0:
|
|
227
|
+
try:
|
|
228
|
+
fd = os.open(log_file, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
229
|
+
os.dup2(fd, 1)
|
|
230
|
+
os.dup2(fd, 2)
|
|
231
|
+
os.close(fd)
|
|
232
|
+
os.execve(_TERMUX_API, argv, env)
|
|
233
|
+
except Exception:
|
|
234
|
+
os._exit(1)
|
|
235
|
+
|
|
236
|
+
# The tail thread checks this Event instead of calling waitpid itself,
|
|
237
|
+
# avoiding ChildProcessError when the parent reaps the child first.
|
|
238
|
+
child_done = threading.Event()
|
|
239
|
+
|
|
240
|
+
def _tail():
|
|
241
|
+
fd = os.open(log_file, os.O_RDONLY | os.O_NONBLOCK)
|
|
242
|
+
buf = b""
|
|
243
|
+
try:
|
|
244
|
+
while True:
|
|
245
|
+
done = child_done.is_set()
|
|
246
|
+
try:
|
|
247
|
+
chunk = os.read(fd, 4096)
|
|
248
|
+
buf += chunk
|
|
249
|
+
except BlockingIOError:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
# Flush on EITHER \n or \r as a line boundary. Anything the
|
|
253
|
+
# wrapped process prints with \r (e.g. a progress indicator)
|
|
254
|
+
# has no \n until it finishes, so splitting only on \n means
|
|
255
|
+
# such output sits buffered here indefinitely and then dumps
|
|
256
|
+
# all at once when a real \n finally shows up - looking like
|
|
257
|
+
# the process hung when it was actually working the whole
|
|
258
|
+
# time. Treating \r as a boundary too streams it live.
|
|
259
|
+
while True:
|
|
260
|
+
nl = buf.find(b"\n")
|
|
261
|
+
cr = buf.find(b"\r")
|
|
262
|
+
if nl == -1 and cr == -1:
|
|
263
|
+
break
|
|
264
|
+
if nl == -1:
|
|
265
|
+
idx, sep = cr, b"\r"
|
|
266
|
+
elif cr == -1:
|
|
267
|
+
idx, sep = nl, b"\n"
|
|
268
|
+
else:
|
|
269
|
+
idx, sep = (cr, b"\r") if cr < nl else (nl, b"\n")
|
|
270
|
+
line, buf = buf[:idx], buf[idx + 1:]
|
|
271
|
+
if tail_fn:
|
|
272
|
+
tail_fn(line.decode("utf-8", errors="replace") + sep.decode())
|
|
273
|
+
|
|
274
|
+
if done:
|
|
275
|
+
try:
|
|
276
|
+
buf += os.read(fd, 65536)
|
|
277
|
+
except BlockingIOError:
|
|
278
|
+
pass
|
|
279
|
+
while True:
|
|
280
|
+
nl = buf.find(b"\n")
|
|
281
|
+
cr = buf.find(b"\r")
|
|
282
|
+
if nl == -1 and cr == -1:
|
|
283
|
+
break
|
|
284
|
+
if nl == -1:
|
|
285
|
+
idx, sep = cr, b"\r"
|
|
286
|
+
elif cr == -1:
|
|
287
|
+
idx, sep = nl, b"\n"
|
|
288
|
+
else:
|
|
289
|
+
idx, sep = (cr, b"\r") if cr < nl else (nl, b"\n")
|
|
290
|
+
line, buf = buf[:idx], buf[idx + 1:]
|
|
291
|
+
if tail_fn:
|
|
292
|
+
tail_fn(line.decode("utf-8", errors="replace") + sep.decode())
|
|
293
|
+
if buf and tail_fn:
|
|
294
|
+
tail_fn(buf.decode("utf-8", errors="replace") + "\n")
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
time.sleep(0.05)
|
|
298
|
+
finally:
|
|
299
|
+
os.close(fd)
|
|
300
|
+
|
|
301
|
+
tail_thread = threading.Thread(target=_tail, daemon=True)
|
|
302
|
+
tail_thread.start()
|
|
303
|
+
|
|
304
|
+
return pid, child_done, tail_thread
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def relaunch_with_fd(device_path: str, script_path: str):
|
|
308
|
+
"""
|
|
309
|
+
Re-invoke this script so TERMUX_USB_FD gets set by termux-api Usb open.
|
|
310
|
+
This replaces the current process - it does not return.
|
|
311
|
+
Termux backend only.
|
|
312
|
+
"""
|
|
313
|
+
argv = [
|
|
314
|
+
_TERMUX_API, "Usb",
|
|
315
|
+
"-a", "open",
|
|
316
|
+
"--ez", "request", "true",
|
|
317
|
+
"--es", "device", device_path,
|
|
318
|
+
]
|
|
319
|
+
env = os.environ.copy()
|
|
320
|
+
env["TERMUX_CALLBACK"] = f"python {script_path}"
|
|
321
|
+
env["TERMUX_EXPORT_FD"] = "true"
|
|
322
|
+
os.execve(_TERMUX_API, argv, env)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def find_usb_device_direct(vid_pid_filter: list[tuple] | None = None):
|
|
327
|
+
"""
|
|
328
|
+
Root path: scan the USB bus via libusb and return the first matching device.
|
|
329
|
+
|
|
330
|
+
vid_pid_filter: list of (vid, pid) tuples to try in order.
|
|
331
|
+
Defaults to all entries in ESP32_KNOWN if None.
|
|
332
|
+
|
|
333
|
+
Falls back to the first non-hub device found if no known VID:PID matches.
|
|
334
|
+
Raises RuntimeError if nothing is found at all.
|
|
335
|
+
"""
|
|
336
|
+
candidates = list(vid_pid_filter or ESP32_KNOWN.keys())
|
|
337
|
+
|
|
338
|
+
for vid, pid in candidates:
|
|
339
|
+
dev = usb.core.find(idVendor=vid, idProduct=pid)
|
|
340
|
+
if dev is not None:
|
|
341
|
+
return dev
|
|
342
|
+
|
|
343
|
+
all_devs = list(usb.core.find(find_all=True))
|
|
344
|
+
non_hubs = [d for d in all_devs if d.bDeviceClass != 9]
|
|
345
|
+
if non_hubs:
|
|
346
|
+
return non_hubs[0]
|
|
347
|
+
|
|
348
|
+
raise RuntimeError(
|
|
349
|
+
"No USB device found on the bus. Is the ESP32 plugged in via OTG?"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def init_uart_bridge(device):
|
|
353
|
+
"""
|
|
354
|
+
Sends raw vendor control requests to wake up and configure
|
|
355
|
+
external hardware serial bridges to 115200 baud, 8N1.
|
|
356
|
+
Includes an explicit hardware reset sequence to pull the ESP32
|
|
357
|
+
out of bootloader loops/reset traps caused by Android connection probes.
|
|
358
|
+
"""
|
|
359
|
+
import time
|
|
360
|
+
vid, pid = device.idVendor, device.idProduct
|
|
361
|
+
|
|
362
|
+
# ── CASE 1: Silicon Labs CP2102 ───────────────────────────────────────────
|
|
363
|
+
if (vid, pid) == (0x10C4, 0xEA60):
|
|
364
|
+
print("[*] Initializing CP2102 line control registers...", flush=True)
|
|
365
|
+
try:
|
|
366
|
+
device.ctrl_transfer(0x41, 0x00, 0x0001, 0, None) # Enable UART port
|
|
367
|
+
device.ctrl_transfer(0x40, 0x1E, 0xC200, 0x0001, None) # Set baud rate to 115200
|
|
368
|
+
device.ctrl_transfer(0x40, 0x03, 0x0800, 0, None) # 8N1 line control
|
|
369
|
+
|
|
370
|
+
# Hardware reset toggle for CP2102 auto-reset circuit
|
|
371
|
+
device.ctrl_transfer(0x41, 0x01, 0x0300, 0, None) # De-assert DTR+RTS
|
|
372
|
+
time.sleep(0.05)
|
|
373
|
+
device.ctrl_transfer(0x41, 0x01, 0x0303, 0, None) # Assert DTR+RTS (normal boot)
|
|
374
|
+
time.sleep(0.1)
|
|
375
|
+
|
|
376
|
+
print("[+] CP2102 successfully configured and reset!", flush=True)
|
|
377
|
+
except Exception as e:
|
|
378
|
+
print(f"[-] Warning: CP2102 initialization encountered an error: {e}", flush=True)
|
|
379
|
+
|
|
380
|
+
# ── CASE 2: WCH CH340 / CH341 ────────────────────────────────────────────
|
|
381
|
+
elif (vid, pid) in [(0x1A86, 0x7523), (0x1A86, 0x55D4)]:
|
|
382
|
+
print("[*] Initializing CH340 register state machine...", flush=True)
|
|
383
|
+
try:
|
|
384
|
+
# CH340 line initialization handshake
|
|
385
|
+
device.ctrl_transfer(0x40, 0xA1, 0, 0, None)
|
|
386
|
+
|
|
387
|
+
# Set baud rate to 115200 using the real CH340/CH341 divisor
|
|
388
|
+
# algorithm (from the Linux kernel's ch341.c driver - this is
|
|
389
|
+
# what actually runs whenever esptool.py works over a real
|
|
390
|
+
# /dev/ttyUSB* on Linux, so it's a known-correct reference).
|
|
391
|
+
#
|
|
392
|
+
# An earlier version of this code sent
|
|
393
|
+
# ctrl_transfer(0x40, 0x9A, 0xC380, 0xEB00, None)
|
|
394
|
+
# which has wValue and wIndex reversed relative to the real
|
|
395
|
+
# protocol (wValue must be the constant 0x1312, wIndex the
|
|
396
|
+
# computed divisor) - it was writing garbage to the baud rate
|
|
397
|
+
# generator. Combined with a bogus 8N1 byte (0x0050 instead of
|
|
398
|
+
# the real LCR encoding 0xC3), the bridge was very likely never
|
|
399
|
+
# actually running at 115200 8N1 - explaining flaky syncs/
|
|
400
|
+
# frame drops that had nothing to do with the reset-pin timing.
|
|
401
|
+
baud_rate = 115200
|
|
402
|
+
factor = 1532620800 // baud_rate # CH341_BAUDBASE_FACTOR
|
|
403
|
+
divisor = 3 # CH341_BAUDBASE_DIVMAX
|
|
404
|
+
while factor > 0xFFF0 and divisor:
|
|
405
|
+
factor >>= 3
|
|
406
|
+
divisor -= 1
|
|
407
|
+
factor = 0x10000 - factor
|
|
408
|
+
a = ((factor & 0xFF00) | divisor) | 0x80 # BIT(7): CH341A
|
|
409
|
+
# packet-buffering flag - harmless on plain CH340/CH340G, and
|
|
410
|
+
# is what the modern kernel driver sets unconditionally.
|
|
411
|
+
lcr_8n1 = 0x80 | 0x40 | 0x03 # ENABLE_RX | ENABLE_TX | CS8
|
|
412
|
+
device.ctrl_transfer(0x40, 0x9A, 0x1312, a, None)
|
|
413
|
+
device.ctrl_transfer(0x40, 0x9A, 0x2518, lcr_8n1, None)
|
|
414
|
+
|
|
415
|
+
# ── HARDWARE RESET TOGGLE FOR ESP32 AUTO-RESET CIRCUIT ────────────
|
|
416
|
+
# 0xA4 modem control bits are ACTIVE-LOW. Bit weights per the
|
|
417
|
+
# Linux kernel ch341.c driver: bit5 (0x20) = DTR, bit6 (0x40) =
|
|
418
|
+
# RTS. (Earlier revision of this code used bit4 (0x10) for RTS,
|
|
419
|
+
# which isn't wired to anything - RTS never actually toggled,
|
|
420
|
+
# so this reset pulse silently did nothing on real hardware.)
|
|
421
|
+
# 0xFF = DTR+RTS de-asserted (idle / lines released)
|
|
422
|
+
# 0x9F = DTR+RTS asserted (bits 5+6 pulled low → EN+BOOT driven)
|
|
423
|
+
#
|
|
424
|
+
# To get a clean normal-sketch boot (not bootloader):
|
|
425
|
+
# 1. Assert both → ESP32 goes into reset
|
|
426
|
+
# 2. Release both → EN rises, BOOT pin=1 → runs sketch
|
|
427
|
+
device.ctrl_transfer(0x40, 0xA4, 0x9F, 0, None) # Assert DTR+RTS → reset
|
|
428
|
+
time.sleep(0.1) # Hold reset
|
|
429
|
+
device.ctrl_transfer(0x40, 0xA4, 0xFF, 0, None) # Release → normal boot
|
|
430
|
+
time.sleep(0.5)
|
|
431
|
+
|
|
432
|
+
# Assert DTR so CH340 forwards RX data to host
|
|
433
|
+
device.ctrl_transfer(0x40, 0xA4, 0xDF, 0, None) # DTR asserted, RTS released
|
|
434
|
+
time.sleep(0.05)
|
|
435
|
+
|
|
436
|
+
print("[+] CH340 successfully configured and released from reset!", flush=True)
|
|
437
|
+
except Exception as e:
|
|
438
|
+
print(f"[-] Warning: CH340 initialization encountered an error: {e}", flush=True)
|
|
439
|
+
|
|
440
|
+
# ── CASE 3: FTDI FT232 ───────────────────────────────────────────────────
|
|
441
|
+
elif (vid, pid) == (0x0403, 0x6001):
|
|
442
|
+
print("[*] Initializing FTDI FT232 line settings...", flush=True)
|
|
443
|
+
try:
|
|
444
|
+
device.ctrl_transfer(0x40, 0x00, 0x0000, 0, None) # Reset device
|
|
445
|
+
time.sleep(0.05)
|
|
446
|
+
device.ctrl_transfer(0x40, 0x03, 0x001A, 0, None) # Set baud rate to 115200
|
|
447
|
+
device.ctrl_transfer(0x40, 0x04, 0x0008, 0, None) # 8N1 line control
|
|
448
|
+
device.ctrl_transfer(0x40, 0x01, 0x0202, 0, None) # Assert DTR+RTS
|
|
449
|
+
time.sleep(0.1)
|
|
450
|
+
|
|
451
|
+
print("[+] FTDI FT232 successfully configured!", flush=True)
|
|
452
|
+
except Exception as e:
|
|
453
|
+
print(f"[-] Warning: FTDI initialization encountered an error: {e}", flush=True)
|
|
454
|
+
|
|
455
|
+
# ── CASE 4: Native USB ESP32-S3 / C3 — no bridge init needed ─────────────
|
|
456
|
+
else:
|
|
457
|
+
pass
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# Backward-compatibility alias — callers using the old name keep working
|
|
461
|
+
init_cp2102_bridge = init_uart_bridge
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def wrap_direct(vid_pid_filter: list[tuple] | None = None):
|
|
465
|
+
"""
|
|
466
|
+
Root backend equivalent of wrap_fd().
|
|
467
|
+
|
|
468
|
+
Opens the ESP32 directly via libusb without termux-api or an Android
|
|
469
|
+
file descriptor. Returns a usb.core.Device ready for claim_device()
|
|
470
|
+
and get_cdc_endpoints() - same contract as wrap_fd().
|
|
471
|
+
|
|
472
|
+
Requires root (uid 0) so libusb can open /dev/bus/usb directly.
|
|
473
|
+
On Nethunter, ensure libusb-1.0 is installed:
|
|
474
|
+
apt install libusb-1.0-0
|
|
475
|
+
"""
|
|
476
|
+
device = find_usb_device_direct(vid_pid_filter)
|
|
477
|
+
return device
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def wrap_fd(fd: int):
|
|
482
|
+
"""
|
|
483
|
+
Wrap Android's USB file descriptor into a PyUSB Device object.
|
|
484
|
+
Must call LIBUSB_OPTION_NO_DEVICE_DISCOVERY before libusb_init.
|
|
485
|
+
Returns a usb.core.Device ready for use.
|
|
486
|
+
Termux backend only.
|
|
487
|
+
"""
|
|
488
|
+
backend = libusb1.get_backend()
|
|
489
|
+
lib = backend.lib
|
|
490
|
+
ctx = backend.ctx
|
|
491
|
+
|
|
492
|
+
lib.libusb_set_option.argtypes = [libusb1.c_void_p, ctypes.c_int]
|
|
493
|
+
lib.libusb_set_option.restype = ctypes.c_int
|
|
494
|
+
lib.libusb_set_option(None, 5) # LIBUSB_OPTION_NO_DEVICE_DISCOVERY
|
|
495
|
+
|
|
496
|
+
lib.libusb_wrap_sys_device.argtypes = [
|
|
497
|
+
libusb1.c_void_p,
|
|
498
|
+
ctypes.c_int,
|
|
499
|
+
ctypes.POINTER(libusb1._libusb_device_handle),
|
|
500
|
+
]
|
|
501
|
+
lib.libusb_get_device.argtypes = [libusb1._libusb_device_handle]
|
|
502
|
+
lib.libusb_get_device.restype = ctypes.c_void_p
|
|
503
|
+
|
|
504
|
+
handle = libusb1._libusb_device_handle()
|
|
505
|
+
ret = lib.libusb_wrap_sys_device(ctx, int(fd), ctypes.byref(handle))
|
|
506
|
+
if ret != 0:
|
|
507
|
+
raise RuntimeError(f"libusb_wrap_sys_device failed: {ret}")
|
|
508
|
+
|
|
509
|
+
devid = lib.libusb_get_device(handle)
|
|
510
|
+
|
|
511
|
+
class _Dummy:
|
|
512
|
+
def __init__(self, devid, handle):
|
|
513
|
+
self.devid = devid
|
|
514
|
+
self.handle = handle
|
|
515
|
+
|
|
516
|
+
dummy = _Dummy(devid, handle)
|
|
517
|
+
device = usb.core.Device(dummy, backend)
|
|
518
|
+
device._ctx.handle = dummy
|
|
519
|
+
return device
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def describe_device(device) -> str:
|
|
524
|
+
vid, pid = device.idVendor, device.idProduct
|
|
525
|
+
label = ESP32_KNOWN.get((vid, pid), "Unknown device")
|
|
526
|
+
return f"{vid:04X}:{pid:04X} {label}"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def get_cdc_endpoints(device):
|
|
530
|
+
"""
|
|
531
|
+
Find bulk IN and OUT endpoints.
|
|
532
|
+
Completely compatible drop-in replacement that works for BOTH:
|
|
533
|
+
1. Native USB CDC devices (ESP32-S3, ESP32-C3)
|
|
534
|
+
2. Serial Bridge chips (CP2102, CH340, CH9102, FTDI)
|
|
535
|
+
|
|
536
|
+
Returns (ep_in_addr, ep_out_addr, interface_number).
|
|
537
|
+
"""
|
|
538
|
+
import usb.util
|
|
539
|
+
vid, pid = device.idVendor, device.idProduct
|
|
540
|
+
cfg = device.get_active_configuration()
|
|
541
|
+
|
|
542
|
+
if (vid, pid) in [(0x303A, 0x1001), (0x303A, 0x0002)]:
|
|
543
|
+
# Native ESP32 S3/C3 USB CDC always maps bulk data to Interface 1
|
|
544
|
+
try:
|
|
545
|
+
intf = cfg[(1, 0)]
|
|
546
|
+
eps = list(intf)
|
|
547
|
+
ep_in = next((e.bEndpointAddress for e in eps if usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN and usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK), None)
|
|
548
|
+
ep_out = next((e.bEndpointAddress for e in eps if usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT and usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK), None)
|
|
549
|
+
if ep_in and ep_out:
|
|
550
|
+
return ep_in, ep_out, 1
|
|
551
|
+
except Exception:
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
elif (vid, pid) in [(0x10C4, 0xEA60), (0x1A86, 0x7523), (0x1A86, 0x55D4), (0x0403, 0x6001)]:
|
|
555
|
+
# Hardware UART Bridges — enumerate from descriptor instead of hardcoding.
|
|
556
|
+
# Hardcoded 0x81/0x01 fails on fd-wrapped devices because set_configuration()
|
|
557
|
+
# is skipped (Termux no-root) so _ep_info is never populated.
|
|
558
|
+
try:
|
|
559
|
+
intf = cfg[(0, 0)]
|
|
560
|
+
eps = list(intf)
|
|
561
|
+
ep_in = next((e.bEndpointAddress for e in eps
|
|
562
|
+
if usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
|
|
563
|
+
and usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK), None)
|
|
564
|
+
ep_out = next((e.bEndpointAddress for e in eps
|
|
565
|
+
if usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
|
|
566
|
+
and usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK), None)
|
|
567
|
+
if ep_in and ep_out:
|
|
568
|
+
return ep_in, ep_out, 0
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
# Absolute fallback if descriptor walk failed
|
|
572
|
+
return 0x81, 0x01, 0
|
|
573
|
+
|
|
574
|
+
# If a new or unknown device is plugged in, look for CDC Data class (0x0A) first
|
|
575
|
+
for intf in cfg:
|
|
576
|
+
if intf.bInterfaceClass == 0x0A:
|
|
577
|
+
eps = list(intf)
|
|
578
|
+
ep_in = next((e.bEndpointAddress for e in eps if usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN and usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK), None)
|
|
579
|
+
ep_out = next((e.bEndpointAddress for e in eps if usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT and usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK), None)
|
|
580
|
+
if ep_in and ep_out:
|
|
581
|
+
return ep_in, ep_out, intf.bInterfaceNumber
|
|
582
|
+
|
|
583
|
+
# Final Fallback: Grab the first valid bulk pair found anywhere on the device
|
|
584
|
+
for intf in cfg:
|
|
585
|
+
eps = list(intf)
|
|
586
|
+
ep_in = next((e.bEndpointAddress for e in eps if usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN and usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK), None)
|
|
587
|
+
ep_out = next((e.bEndpointAddress for e in eps if usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT and usb.util.endpoint_type(e.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK), None)
|
|
588
|
+
if ep_in and ep_out:
|
|
589
|
+
return ep_in, ep_out, intf.bInterfaceNumber
|
|
590
|
+
|
|
591
|
+
raise RuntimeError("No bulk endpoint pair found on device")
|
|
592
|
+
|
|
593
|
+
def claim_device(device, interface_num: int, fd_wrapped: bool = False):
|
|
594
|
+
"""
|
|
595
|
+
Detach kernel driver if needed and claim the interface.
|
|
596
|
+
|
|
597
|
+
fd_wrapped: set True when the device came from wrap_fd() (Termux no-root).
|
|
598
|
+
In that case we must NOT call set_configuration() — Android has already
|
|
599
|
+
configured the device and libusb's internal refcount doesn't survive a
|
|
600
|
+
second configuration attempt on a fd-wrapped handle. Calling it on the
|
|
601
|
+
ESP32-S3 (which uses TinyUSB with two CDC interfaces) triggers:
|
|
602
|
+
assertion "refcnt >= 2" failed
|
|
603
|
+
and crashes the process. The root (wrap_direct) path is fine.
|
|
604
|
+
"""
|
|
605
|
+
import usb.util
|
|
606
|
+
try:
|
|
607
|
+
if device.is_kernel_driver_active(interface_num):
|
|
608
|
+
device.detach_kernel_driver(interface_num)
|
|
609
|
+
except Exception:
|
|
610
|
+
pass
|
|
611
|
+
if not fd_wrapped:
|
|
612
|
+
try:
|
|
613
|
+
device.set_configuration()
|
|
614
|
+
except Exception:
|
|
615
|
+
pass
|
|
616
|
+
try:
|
|
617
|
+
usb.util.claim_interface(device, interface_num)
|
|
618
|
+
except Exception:
|
|
619
|
+
pass
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def reset_endpoint_toggles(device, ep_in_addr: int, ep_out_addr: int):
|
|
623
|
+
"""
|
|
624
|
+
Clear the DATA0/DATA1 toggle on both bulk endpoints.
|
|
625
|
+
|
|
626
|
+
Each invocation is a fresh OS process wrapping the *same* physical USB
|
|
627
|
+
device via TERMUX_USB_FD (termux) or a direct libusb open (root).
|
|
628
|
+
The toggle bit lives in the peripheral's endpoint state, not in process
|
|
629
|
+
memory, so a clean exit does NOT reset it. Call this once right after
|
|
630
|
+
claim_device(), before any read/write.
|
|
631
|
+
"""
|
|
632
|
+
for ep in (ep_in_addr, ep_out_addr):
|
|
633
|
+
try:
|
|
634
|
+
device.clear_halt(ep)
|
|
635
|
+
except usb.core.USBError:
|
|
636
|
+
pass
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: espbridge
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Framed binary USB bridge protocol for talking to an ESP32 from Termux (or any Linux host) — no root required.
|
|
5
|
+
Author: 7wp81x
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/7wp81x/ESP-Bridge
|
|
8
|
+
Project-URL: Repository, https://github.com/7wp81x/ESP-Bridge
|
|
9
|
+
Project-URL: Issues, https://github.com/7wp81x/ESP-Bridge/issues
|
|
10
|
+
Keywords: esp32,termux,usb,no-root,android,serial,bridge,protocol
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Operating System :: Android
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
18
|
+
Classifier: Topic :: System :: Hardware
|
|
19
|
+
Classifier: Topic :: System :: Networking
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: pyusb>=1.2.1
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# ESP-Bridge
|
|
27
|
+
|
|
28
|
+
Framed binary USB bridge protocol for talking to an ESP32 from Termux — or
|
|
29
|
+
any Linux host — **no root required**.
|
|
30
|
+
|
|
31
|
+
This is the transport layer extracted from [NRSuite](https://github.com/7wp81x/NRSuite).
|
|
32
|
+
It doesn't know anything about Wi-Fi, packet capture, or any specific
|
|
33
|
+
use case — it just gets JSON commands and binary chunks reliably between
|
|
34
|
+
an ESP32 and a host process over USB CDC, with automatic root/no-root
|
|
35
|
+
backend detection. Use it to build your own ESP32-to-Termux tools.
|
|
36
|
+
|
|
37
|
+
Pairs with the [`BridgeProtocol`](https://github.com/7wp81x/BridgeProtocol) PlatformIO library on
|
|
38
|
+
the firmware side — install both and the two speak the same frames out of
|
|
39
|
+
the box.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install espbridge # once published
|
|
45
|
+
# or, for local development:
|
|
46
|
+
git clone https://github.com/7wp81x/ESP-Bridge
|
|
47
|
+
cd ESP-Bridge
|
|
48
|
+
pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
On stock Termux you'll also need:
|
|
52
|
+
```bash
|
|
53
|
+
pkg install python termux-api libusb
|
|
54
|
+
```
|
|
55
|
+
plus the **Termux:API** app from F-Droid (not the Play Store version).
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
See [`examples/echo_cmd.py`](examples/echo_cmd.py) for a full runnable
|
|
60
|
+
example. The short version:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import os, espbridge as eb
|
|
64
|
+
|
|
65
|
+
backend = eb.detect_backend() # "termux" or "root"
|
|
66
|
+
device = eb.wrap_fd(int(os.environ["TERMUX_USB_FD"])) if backend == "termux" \
|
|
67
|
+
else eb.wrap_direct()
|
|
68
|
+
|
|
69
|
+
ep_in, ep_out, iface = eb.get_cdc_endpoints(device)
|
|
70
|
+
eb.claim_device(device, iface, fd_wrapped=(backend == "termux"))
|
|
71
|
+
eb.reset_endpoint_toggles(device, ep_in, ep_out)
|
|
72
|
+
|
|
73
|
+
sender = eb.Sender(device, ep_out)
|
|
74
|
+
receiver = eb.ReceiverThread(device, ep_in); receiver.start()
|
|
75
|
+
proto = eb.Protocol(sender, receiver); proto.start()
|
|
76
|
+
|
|
77
|
+
resp = proto.send_cmd("PING") # blocks until RESP or timeout
|
|
78
|
+
print(resp)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Frame format
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
[MAGIC 2B: 0xAD 0xDE][TYPE 1B][ID 1B][LENGTH 4B LE][PAYLOAD NB]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Type | Hex | Direction | Payload |
|
|
88
|
+
|-------|------|---------------|---------------------------------------|
|
|
89
|
+
| CMD | 0x01 | Host → ESP32 | JSON `{"cmd": "...", "args": {...}}` |
|
|
90
|
+
| RESP | 0x02 | ESP32 → Host | JSON response, matched by frame ID |
|
|
91
|
+
| EVENT | 0x03 | ESP32 → Host | Async JSON, id=0 |
|
|
92
|
+
| PCAP | 0x04 | ESP32 → Host | Raw binary chunk (id = chunk index) — despite the name, use this for any binary stream |
|
|
93
|
+
| ACK | 0x05 | Host → ESP32 | JSON `{"chunk": N}` — flow control |
|
|
94
|
+
| HTML | 0x06 | Host → ESP32 | Chunked raw payload upload |
|
|
95
|
+
|
|
96
|
+
Commands (`CMD`) are entirely up to you — define whatever `cmd` strings and
|
|
97
|
+
`args` your firmware understands. This library only handles framing,
|
|
98
|
+
transport, and request/response matching.
|
|
99
|
+
|
|
100
|
+
## What's included
|
|
101
|
+
|
|
102
|
+
- `protocol.py` — frame builder/parser, `Protocol` class (send_cmd/on_event/on_pcap)
|
|
103
|
+
- `sender.py` — thread-safe bulk OUT writer with retry
|
|
104
|
+
- `receiver.py` — background bulk IN reader thread
|
|
105
|
+
- `usb_device.py` — root vs. no-root backend detection, permission flow, endpoint discovery
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
espbridge/__init__.py,sha256=3Tq3yTUwYjxWD1Iv3__BkegIhEMc-qxb1kCinvLFLjs,2710
|
|
2
|
+
espbridge/protocol.py,sha256=gjS3KxCuQRbmvI3P4yVC75skHjFj7JfeuWvXqT-ApPw,9153
|
|
3
|
+
espbridge/receiver.py,sha256=gSk29tujpXmNEqYBmmH0nigar2mX7VGMzV8riuT0kds,4697
|
|
4
|
+
espbridge/sender.py,sha256=nTbjwNQl7mz4amHrQRr7X-9bTeGUdeST1R_U5nbIxts,1652
|
|
5
|
+
espbridge/usb_device.py,sha256=g87CggCEJxRdUH_apmACFxA98Mcn4NOUYj1yMixpXWM,25516
|
|
6
|
+
espbridge-1.0.0.dist-info/licenses/LICENSE,sha256=o7Urwh9i4GpPNG1gjUG8ekKcv988l8c8688uK92mPfc,1061
|
|
7
|
+
espbridge-1.0.0.dist-info/METADATA,sha256=q25uFU_T0zk18AGDlyHn2gvjbzbv9-qX5eMfq3dSZJs,4044
|
|
8
|
+
espbridge-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
espbridge-1.0.0.dist-info/top_level.txt,sha256=AvhZ9_x5BcXLdg5tqhhd0IGDP6hatdORRePsYaUfCJY,10
|
|
10
|
+
espbridge-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 J457
|
|
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 @@
|
|
|
1
|
+
espbridge
|