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 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)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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