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.
- espbridge-1.0.0/LICENSE +21 -0
- espbridge-1.0.0/PKG-INFO +109 -0
- espbridge-1.0.0/README.md +84 -0
- espbridge-1.0.0/pyproject.toml +38 -0
- espbridge-1.0.0/setup.cfg +4 -0
- espbridge-1.0.0/src/espbridge/__init__.py +96 -0
- espbridge-1.0.0/src/espbridge/protocol.py +292 -0
- espbridge-1.0.0/src/espbridge/receiver.py +129 -0
- espbridge-1.0.0/src/espbridge/sender.py +47 -0
- espbridge-1.0.0/src/espbridge/usb_device.py +636 -0
- espbridge-1.0.0/src/espbridge.egg-info/PKG-INFO +109 -0
- espbridge-1.0.0/src/espbridge.egg-info/SOURCES.txt +13 -0
- espbridge-1.0.0/src/espbridge.egg-info/dependency_links.txt +1 -0
- espbridge-1.0.0/src/espbridge.egg-info/requires.txt +1 -0
- espbridge-1.0.0/src/espbridge.egg-info/top_level.txt +1 -0
espbridge-1.0.0/LICENSE
ADDED
|
@@ -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.
|
espbridge-1.0.0/PKG-INFO
ADDED
|
@@ -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,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}")
|