espbridge 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,84 @@
1
+ # ESP-Bridge
2
+
3
+ Framed binary USB bridge protocol for talking to an ESP32 from Termux — or
4
+ any Linux host — **no root required**.
5
+
6
+ This is the transport layer extracted from [NRSuite](https://github.com/7wp81x/NRSuite).
7
+ It doesn't know anything about Wi-Fi, packet capture, or any specific
8
+ use case — it just gets JSON commands and binary chunks reliably between
9
+ an ESP32 and a host process over USB CDC, with automatic root/no-root
10
+ backend detection. Use it to build your own ESP32-to-Termux tools.
11
+
12
+ Pairs with the [`BridgeProtocol`](https://github.com/7wp81x/BridgeProtocol) PlatformIO library on
13
+ the firmware side — install both and the two speak the same frames out of
14
+ the box.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install espbridge # once published
20
+ # or, for local development:
21
+ git clone https://github.com/7wp81x/ESP-Bridge
22
+ cd ESP-Bridge
23
+ pip install -e .
24
+ ```
25
+
26
+ On stock Termux you'll also need:
27
+ ```bash
28
+ pkg install python termux-api libusb
29
+ ```
30
+ plus the **Termux:API** app from F-Droid (not the Play Store version).
31
+
32
+ ## Quick start
33
+
34
+ See [`examples/echo_cmd.py`](examples/echo_cmd.py) for a full runnable
35
+ example. The short version:
36
+
37
+ ```python
38
+ import os, espbridge as eb
39
+
40
+ backend = eb.detect_backend() # "termux" or "root"
41
+ device = eb.wrap_fd(int(os.environ["TERMUX_USB_FD"])) if backend == "termux" \
42
+ else eb.wrap_direct()
43
+
44
+ ep_in, ep_out, iface = eb.get_cdc_endpoints(device)
45
+ eb.claim_device(device, iface, fd_wrapped=(backend == "termux"))
46
+ eb.reset_endpoint_toggles(device, ep_in, ep_out)
47
+
48
+ sender = eb.Sender(device, ep_out)
49
+ receiver = eb.ReceiverThread(device, ep_in); receiver.start()
50
+ proto = eb.Protocol(sender, receiver); proto.start()
51
+
52
+ resp = proto.send_cmd("PING") # blocks until RESP or timeout
53
+ print(resp)
54
+ ```
55
+
56
+ ## Frame format
57
+
58
+ ```
59
+ [MAGIC 2B: 0xAD 0xDE][TYPE 1B][ID 1B][LENGTH 4B LE][PAYLOAD NB]
60
+ ```
61
+
62
+ | Type | Hex | Direction | Payload |
63
+ |-------|------|---------------|---------------------------------------|
64
+ | CMD | 0x01 | Host → ESP32 | JSON `{"cmd": "...", "args": {...}}` |
65
+ | RESP | 0x02 | ESP32 → Host | JSON response, matched by frame ID |
66
+ | EVENT | 0x03 | ESP32 → Host | Async JSON, id=0 |
67
+ | PCAP | 0x04 | ESP32 → Host | Raw binary chunk (id = chunk index) — despite the name, use this for any binary stream |
68
+ | ACK | 0x05 | Host → ESP32 | JSON `{"chunk": N}` — flow control |
69
+ | HTML | 0x06 | Host → ESP32 | Chunked raw payload upload |
70
+
71
+ Commands (`CMD`) are entirely up to you — define whatever `cmd` strings and
72
+ `args` your firmware understands. This library only handles framing,
73
+ transport, and request/response matching.
74
+
75
+ ## What's included
76
+
77
+ - `protocol.py` — frame builder/parser, `Protocol` class (send_cmd/on_event/on_pcap)
78
+ - `sender.py` — thread-safe bulk OUT writer with retry
79
+ - `receiver.py` — background bulk IN reader thread
80
+ - `usb_device.py` — root vs. no-root backend detection, permission flow, endpoint discovery
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "espbridge"
7
+ version = "1.0.0"
8
+ description = "Framed binary USB bridge protocol for talking to an ESP32 from Termux (or any Linux host) — no root required."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "7wp81x" }
15
+ ]
16
+ keywords = ["esp32", "termux", "usb", "no-root", "android", "serial", "bridge", "protocol"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Operating System :: Android",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Topic :: Software Development :: Embedded Systems",
25
+ "Topic :: System :: Hardware",
26
+ "Topic :: System :: Networking",
27
+ ]
28
+ dependencies = [
29
+ "pyusb>=1.2.1",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/7wp81x/ESP-Bridge"
34
+ Repository = "https://github.com/7wp81x/ESP-Bridge"
35
+ Issues = "https://github.com/7wp81x/ESP-Bridge/issues"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"
@@ -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}")