esp32-stream 0.1.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.
- esp32_stream/__init__.py +21 -0
- esp32_stream/board_profiles.py +54 -0
- esp32_stream/esp32_frame_source.py +380 -0
- esp32_stream/protocol.py +169 -0
- esp32_stream-0.1.0.dist-info/METADATA +86 -0
- esp32_stream-0.1.0.dist-info/RECORD +8 -0
- esp32_stream-0.1.0.dist-info/WHEEL +5 -0
- esp32_stream-0.1.0.dist-info/top_level.txt +1 -0
esp32_stream/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .board_profiles import (
|
|
2
|
+
BOARD_ALIASES,
|
|
3
|
+
BOARD_PROFILES,
|
|
4
|
+
CAMERA_MODE_DIMENSIONS,
|
|
5
|
+
BoardProfile,
|
|
6
|
+
get_board_profile,
|
|
7
|
+
)
|
|
8
|
+
from .esp32_frame_source import Esp32FrameSource, Esp32VideoCaptureAdapter, FrameMetadata
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BOARD_ALIASES",
|
|
12
|
+
"BOARD_PROFILES",
|
|
13
|
+
"CAMERA_MODE_DIMENSIONS",
|
|
14
|
+
"BoardProfile",
|
|
15
|
+
"get_board_profile",
|
|
16
|
+
"Esp32FrameSource",
|
|
17
|
+
"Esp32VideoCaptureAdapter",
|
|
18
|
+
"FrameMetadata",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Board profiles for ESP32 host tooling.
|
|
2
|
+
|
|
3
|
+
Keeps serial defaults and mode metadata centralized so tooling can run
|
|
4
|
+
unchanged across supported boards.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class BoardProfile:
|
|
14
|
+
name: str
|
|
15
|
+
default_port: str
|
|
16
|
+
default_baud: int
|
|
17
|
+
camera_mode_dimensions: dict[str, tuple[int, int]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
CAMERA_MODE_DIMENSIONS: dict[str, tuple[int, int]] = {
|
|
21
|
+
"dev_qvga": (320, 240),
|
|
22
|
+
"proc_lowres": (640, 400),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
BOARD_PROFILES: dict[str, BoardProfile] = {
|
|
27
|
+
"esp32s3": BoardProfile(
|
|
28
|
+
name="esp32s3",
|
|
29
|
+
default_port="/dev/cu.usbmodem1101",
|
|
30
|
+
default_baud=115200,
|
|
31
|
+
camera_mode_dimensions=dict(CAMERA_MODE_DIMENSIONS),
|
|
32
|
+
),
|
|
33
|
+
"esp32p4": BoardProfile(
|
|
34
|
+
name="esp32p4",
|
|
35
|
+
default_port="/dev/cu.usbmodem1101",
|
|
36
|
+
default_baud=6000000,
|
|
37
|
+
camera_mode_dimensions=dict(CAMERA_MODE_DIMENSIONS),
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
BOARD_ALIASES = {
|
|
43
|
+
"s3": "esp32s3",
|
|
44
|
+
"p4": "esp32p4",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_board_profile(name: str) -> BoardProfile:
|
|
49
|
+
key = name.strip().lower()
|
|
50
|
+
key = BOARD_ALIASES.get(key, key)
|
|
51
|
+
if key not in BOARD_PROFILES:
|
|
52
|
+
supported = ", ".join(sorted(BOARD_PROFILES.keys()))
|
|
53
|
+
raise ValueError(f"Unsupported board '{name}'. Supported: {supported}")
|
|
54
|
+
return BOARD_PROFILES[key]
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""ESP32 camera frame source for direct integration into OpenCV pipelines.
|
|
2
|
+
|
|
3
|
+
This module reads the USB serial stream protocol and returns decoded
|
|
4
|
+
camera frames plus timing metadata suitable for calibration workflows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .board_profiles import get_board_profile
|
|
14
|
+
from .protocol import (
|
|
15
|
+
CAMERA_MODE_ID_TO_NAME,
|
|
16
|
+
CAMERA_MODE_NAME_TO_ID,
|
|
17
|
+
PACKET_TYPE_CONTROL_ACK,
|
|
18
|
+
PACKET_TYPE_FRAME_CHUNK,
|
|
19
|
+
PACKET_TYPE_FRAME_END,
|
|
20
|
+
PACKET_TYPE_FRAME_START,
|
|
21
|
+
PACKET_TYPE_STREAM_STATS,
|
|
22
|
+
STREAM_MODE_NAME_TO_ID,
|
|
23
|
+
StreamParser,
|
|
24
|
+
build_set_camera_mode_command,
|
|
25
|
+
build_set_stream_mode_command,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class FrameMetadata:
|
|
31
|
+
frame_id: int
|
|
32
|
+
width: int
|
|
33
|
+
height: int
|
|
34
|
+
camera_mode: str
|
|
35
|
+
capture_start_us: int
|
|
36
|
+
capture_ready_us: int
|
|
37
|
+
packet_timestamp_us: int
|
|
38
|
+
host_recv_us: int
|
|
39
|
+
chunk_count: int
|
|
40
|
+
frame_len_expected: int
|
|
41
|
+
frame_len_received: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class _FrameAssembly:
|
|
46
|
+
frame_id: int
|
|
47
|
+
capture_start_us: int
|
|
48
|
+
capture_ready_us: int
|
|
49
|
+
frame_len_bytes: int
|
|
50
|
+
width: int
|
|
51
|
+
height: int
|
|
52
|
+
camera_mode: int
|
|
53
|
+
received_bytes: int = 0
|
|
54
|
+
chunk_count: int = 0
|
|
55
|
+
next_offset: int = 0
|
|
56
|
+
broken: bool = False
|
|
57
|
+
frame_bytes: bytearray = field(default_factory=bytearray)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Esp32FrameSource:
|
|
61
|
+
"""Read ESP32 stream frames using a VideoCapture-like read() call.
|
|
62
|
+
|
|
63
|
+
read() returns (ok, frame_bgr, metadata).
|
|
64
|
+
If no frame arrives before timeout, returns (False, None, None).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
port: str | None = None,
|
|
70
|
+
baud: int | None = None,
|
|
71
|
+
board: str = "esp32s3",
|
|
72
|
+
read_chunk_size: int = 4096,
|
|
73
|
+
read_timeout_s: float = 0.1,
|
|
74
|
+
set_camera_mode: str = "",
|
|
75
|
+
set_stream_mode: str = "",
|
|
76
|
+
serial_port: Any | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
profile = get_board_profile(board)
|
|
79
|
+
self.port = port if port is not None else profile.default_port
|
|
80
|
+
self.baud = int(baud) if baud is not None else int(profile.default_baud)
|
|
81
|
+
self.board = profile.name
|
|
82
|
+
self.read_chunk_size = max(int(read_chunk_size), 256)
|
|
83
|
+
self.read_timeout_s = max(float(read_timeout_s), 0.001)
|
|
84
|
+
self.set_camera_mode = set_camera_mode
|
|
85
|
+
self.set_stream_mode = set_stream_mode
|
|
86
|
+
|
|
87
|
+
self._parser = StreamParser()
|
|
88
|
+
self._ser = serial_port
|
|
89
|
+
self._owns_serial = serial_port is None
|
|
90
|
+
self._active_frame: _FrameAssembly | None = None
|
|
91
|
+
|
|
92
|
+
def open(self) -> None:
|
|
93
|
+
if self._ser is None:
|
|
94
|
+
try:
|
|
95
|
+
import serial # type: ignore
|
|
96
|
+
except ModuleNotFoundError as exc:
|
|
97
|
+
raise RuntimeError("Missing dependency: pyserial") from exc
|
|
98
|
+
self._ser = serial.Serial(self.port, self.baud, timeout=self.read_timeout_s)
|
|
99
|
+
|
|
100
|
+
# Clear stale bytes from prior sessions before issuing mode commands.
|
|
101
|
+
try:
|
|
102
|
+
self._ser.reset_input_buffer()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
if self.set_stream_mode:
|
|
107
|
+
stream_mode_name = self.set_stream_mode
|
|
108
|
+
# Compatibility aliases used by host CLI wrappers.
|
|
109
|
+
if stream_mode_name == "camera":
|
|
110
|
+
stream_mode_name = "imu_camera"
|
|
111
|
+
elif stream_mode_name == "imu":
|
|
112
|
+
stream_mode_name = "imu_only"
|
|
113
|
+
|
|
114
|
+
mode_id = STREAM_MODE_NAME_TO_ID[stream_mode_name]
|
|
115
|
+
cmd = build_set_stream_mode_command(mode_id, int(time.time() * 1_000_000))
|
|
116
|
+
self._ser.write(cmd)
|
|
117
|
+
self._ser.flush()
|
|
118
|
+
|
|
119
|
+
if self.set_camera_mode:
|
|
120
|
+
mode_id = CAMERA_MODE_NAME_TO_ID[self.set_camera_mode]
|
|
121
|
+
cmd = build_set_camera_mode_command(mode_id, int(time.time() * 1_000_000))
|
|
122
|
+
self._ser.write(cmd)
|
|
123
|
+
self._ser.flush()
|
|
124
|
+
|
|
125
|
+
# Start reads at a fresh packet boundary after control writes.
|
|
126
|
+
self._active_frame = None
|
|
127
|
+
self._parser = StreamParser()
|
|
128
|
+
try:
|
|
129
|
+
self._ser.reset_input_buffer()
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
if self._ser is not None and self._owns_serial:
|
|
135
|
+
self._ser.close()
|
|
136
|
+
self._ser = None
|
|
137
|
+
|
|
138
|
+
def __enter__(self) -> "Esp32FrameSource":
|
|
139
|
+
self.open()
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
143
|
+
del exc_type, exc, tb
|
|
144
|
+
self.close()
|
|
145
|
+
|
|
146
|
+
def _decode_jpeg(self, jpg_bytes: bytes):
|
|
147
|
+
try:
|
|
148
|
+
import cv2 # type: ignore
|
|
149
|
+
import numpy as np
|
|
150
|
+
except ModuleNotFoundError as exc:
|
|
151
|
+
raise RuntimeError("Missing dependencies: opencv-python and numpy") from exc
|
|
152
|
+
|
|
153
|
+
jpg = np.frombuffer(jpg_bytes, dtype=np.uint8)
|
|
154
|
+
return cv2.imdecode(jpg, cv2.IMREAD_COLOR)
|
|
155
|
+
|
|
156
|
+
def _decode_raw_fallback(self, frame_bytes: bytes, width: int, height: int):
|
|
157
|
+
try:
|
|
158
|
+
import cv2 # type: ignore
|
|
159
|
+
import numpy as np
|
|
160
|
+
except ModuleNotFoundError as exc:
|
|
161
|
+
raise RuntimeError("Missing dependencies: opencv-python and numpy") from exc
|
|
162
|
+
|
|
163
|
+
if width <= 0 or height <= 0:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
px = width * height
|
|
167
|
+
if len(frame_bytes) == px:
|
|
168
|
+
gray = np.frombuffer(frame_bytes, dtype=np.uint8).reshape((height, width))
|
|
169
|
+
return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
|
|
170
|
+
|
|
171
|
+
if len(frame_bytes) == (px * 2):
|
|
172
|
+
rgb565 = np.frombuffer(frame_bytes, dtype=np.uint16).reshape((height, width))
|
|
173
|
+
r = ((rgb565 >> 11) & 0x1F).astype(np.uint8)
|
|
174
|
+
g = ((rgb565 >> 5) & 0x3F).astype(np.uint8)
|
|
175
|
+
b = (rgb565 & 0x1F).astype(np.uint8)
|
|
176
|
+
r8 = ((r * 255) // 31).astype(np.uint8)
|
|
177
|
+
g8 = ((g * 255) // 63).astype(np.uint8)
|
|
178
|
+
b8 = ((b * 255) // 31).astype(np.uint8)
|
|
179
|
+
return np.dstack((b8, g8, r8))
|
|
180
|
+
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def _handle_packet(self, pkt, host_recv_us: int):
|
|
184
|
+
if pkt.packet_type == PACKET_TYPE_CONTROL_ACK:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
if pkt.packet_type == PACKET_TYPE_STREAM_STATS:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
if pkt.packet_type == PACKET_TYPE_FRAME_START:
|
|
191
|
+
if len(pkt.payload) < 29:
|
|
192
|
+
self._active_frame = None
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
frame_id = int.from_bytes(pkt.payload[0:4], "little")
|
|
196
|
+
capture_start_us = int.from_bytes(pkt.payload[4:12], "little")
|
|
197
|
+
capture_ready_us = int.from_bytes(pkt.payload[12:20], "little")
|
|
198
|
+
frame_len = int.from_bytes(pkt.payload[20:24], "little")
|
|
199
|
+
width = int.from_bytes(pkt.payload[24:26], "little")
|
|
200
|
+
height = int.from_bytes(pkt.payload[26:28], "little")
|
|
201
|
+
camera_mode = pkt.payload[28]
|
|
202
|
+
|
|
203
|
+
self._active_frame = _FrameAssembly(
|
|
204
|
+
frame_id=frame_id,
|
|
205
|
+
capture_start_us=capture_start_us,
|
|
206
|
+
capture_ready_us=capture_ready_us,
|
|
207
|
+
frame_len_bytes=frame_len,
|
|
208
|
+
width=width,
|
|
209
|
+
height=height,
|
|
210
|
+
camera_mode=camera_mode,
|
|
211
|
+
)
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
if pkt.packet_type == PACKET_TYPE_FRAME_CHUNK:
|
|
215
|
+
if len(pkt.payload) < 8 or self._active_frame is None:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
frame_id = int.from_bytes(pkt.payload[0:4], "little")
|
|
219
|
+
offset = int.from_bytes(pkt.payload[4:8], "little")
|
|
220
|
+
chunk_data = pkt.payload[8:]
|
|
221
|
+
|
|
222
|
+
if frame_id != self._active_frame.frame_id:
|
|
223
|
+
self._active_frame.broken = True
|
|
224
|
+
return None
|
|
225
|
+
if offset != self._active_frame.next_offset:
|
|
226
|
+
self._active_frame.broken = True
|
|
227
|
+
return None
|
|
228
|
+
if (self._active_frame.received_bytes + len(chunk_data)) > self._active_frame.frame_len_bytes:
|
|
229
|
+
self._active_frame.broken = True
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
self._active_frame.frame_bytes.extend(chunk_data)
|
|
233
|
+
self._active_frame.received_bytes += len(chunk_data)
|
|
234
|
+
self._active_frame.chunk_count += 1
|
|
235
|
+
self._active_frame.next_offset = offset + len(chunk_data)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
if pkt.packet_type == PACKET_TYPE_FRAME_END:
|
|
239
|
+
if len(pkt.payload) < 10 or self._active_frame is None:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
frame_id = int.from_bytes(pkt.payload[0:4], "little")
|
|
243
|
+
fw_frame_len = int.from_bytes(pkt.payload[4:8], "little")
|
|
244
|
+
fw_chunk_count = int.from_bytes(pkt.payload[8:10], "little")
|
|
245
|
+
|
|
246
|
+
if frame_id != self._active_frame.frame_id:
|
|
247
|
+
self._active_frame = None
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
done = self._active_frame
|
|
251
|
+
self._active_frame = None
|
|
252
|
+
|
|
253
|
+
assembly_ok = (
|
|
254
|
+
(not done.broken)
|
|
255
|
+
and (done.received_bytes == done.frame_len_bytes)
|
|
256
|
+
and (fw_frame_len == done.frame_len_bytes)
|
|
257
|
+
and (fw_chunk_count == done.chunk_count)
|
|
258
|
+
)
|
|
259
|
+
if not assembly_ok:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
frame_bytes = bytes(done.frame_bytes)
|
|
263
|
+
frame = self._decode_jpeg(frame_bytes)
|
|
264
|
+
if frame is None:
|
|
265
|
+
frame = self._decode_raw_fallback(frame_bytes, done.width, done.height)
|
|
266
|
+
if frame is None:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
meta = FrameMetadata(
|
|
270
|
+
frame_id=done.frame_id,
|
|
271
|
+
width=done.width,
|
|
272
|
+
height=done.height,
|
|
273
|
+
camera_mode=CAMERA_MODE_ID_TO_NAME.get(done.camera_mode, f"unknown({done.camera_mode})"),
|
|
274
|
+
capture_start_us=done.capture_start_us,
|
|
275
|
+
capture_ready_us=done.capture_ready_us,
|
|
276
|
+
packet_timestamp_us=int(pkt.timestamp_us),
|
|
277
|
+
host_recv_us=host_recv_us,
|
|
278
|
+
chunk_count=done.chunk_count,
|
|
279
|
+
frame_len_expected=done.frame_len_bytes,
|
|
280
|
+
frame_len_received=done.received_bytes,
|
|
281
|
+
)
|
|
282
|
+
return frame, meta
|
|
283
|
+
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
def read(self, timeout_s: float = 1.0):
|
|
287
|
+
"""Return (ok, frame_bgr, metadata) similar to cv2.VideoCapture.read()."""
|
|
288
|
+
if self._ser is None:
|
|
289
|
+
self.open()
|
|
290
|
+
|
|
291
|
+
deadline = time.monotonic() + max(timeout_s, 0.001)
|
|
292
|
+
while time.monotonic() < deadline:
|
|
293
|
+
chunk = self._ser.read(self.read_chunk_size)
|
|
294
|
+
if not chunk:
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
host_recv_us = int(time.time_ns() // 1000)
|
|
298
|
+
for pkt in self._parser.feed(chunk):
|
|
299
|
+
out = self._handle_packet(pkt, host_recv_us)
|
|
300
|
+
if out is not None:
|
|
301
|
+
frame, meta = out
|
|
302
|
+
return True, frame, meta
|
|
303
|
+
|
|
304
|
+
return False, None, None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class Esp32VideoCaptureAdapter:
|
|
308
|
+
"""cv2.VideoCapture-like wrapper around Esp32FrameSource.
|
|
309
|
+
|
|
310
|
+
read() follows OpenCV convention and returns (ok, frame).
|
|
311
|
+
Timestamp and transport metadata for the last successful read is available
|
|
312
|
+
via last_metadata.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
# Common OpenCV property IDs used by calling code.
|
|
316
|
+
_CAP_PROP_FRAME_WIDTH = 3
|
|
317
|
+
_CAP_PROP_FRAME_HEIGHT = 4
|
|
318
|
+
|
|
319
|
+
def __init__(
|
|
320
|
+
self,
|
|
321
|
+
port: str | None = None,
|
|
322
|
+
baud: int | None = None,
|
|
323
|
+
board: str = "esp32s3",
|
|
324
|
+
read_chunk_size: int = 4096,
|
|
325
|
+
read_timeout_s: float = 0.1,
|
|
326
|
+
set_camera_mode: str = "",
|
|
327
|
+
set_stream_mode: str = "",
|
|
328
|
+
serial_port: Any | None = None,
|
|
329
|
+
auto_open: bool = True,
|
|
330
|
+
) -> None:
|
|
331
|
+
self._source = Esp32FrameSource(
|
|
332
|
+
port=port,
|
|
333
|
+
baud=baud,
|
|
334
|
+
board=board,
|
|
335
|
+
read_chunk_size=read_chunk_size,
|
|
336
|
+
read_timeout_s=read_timeout_s,
|
|
337
|
+
set_camera_mode=set_camera_mode,
|
|
338
|
+
set_stream_mode=set_stream_mode,
|
|
339
|
+
serial_port=serial_port,
|
|
340
|
+
)
|
|
341
|
+
self.last_metadata: FrameMetadata | None = None
|
|
342
|
+
self._opened = False
|
|
343
|
+
if auto_open:
|
|
344
|
+
self.open()
|
|
345
|
+
|
|
346
|
+
def open(self) -> bool:
|
|
347
|
+
if self._opened:
|
|
348
|
+
return True
|
|
349
|
+
self._source.open()
|
|
350
|
+
self._opened = True
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
def isOpened(self) -> bool:
|
|
354
|
+
return self._opened
|
|
355
|
+
|
|
356
|
+
def read(self, timeout_s: float = 1.0):
|
|
357
|
+
ok, frame, meta = self._source.read(timeout_s=timeout_s)
|
|
358
|
+
if ok:
|
|
359
|
+
self.last_metadata = meta
|
|
360
|
+
return True, frame
|
|
361
|
+
return False, None
|
|
362
|
+
|
|
363
|
+
def read_with_metadata(self, timeout_s: float = 1.0):
|
|
364
|
+
"""Convenience method if the caller wants frame and metadata together."""
|
|
365
|
+
ok, frame = self.read(timeout_s=timeout_s)
|
|
366
|
+
return ok, frame, self.last_metadata
|
|
367
|
+
|
|
368
|
+
def get(self, prop_id: int) -> float:
|
|
369
|
+
if self.last_metadata is None:
|
|
370
|
+
return 0.0
|
|
371
|
+
if prop_id == self._CAP_PROP_FRAME_WIDTH:
|
|
372
|
+
return float(self.last_metadata.width)
|
|
373
|
+
if prop_id == self._CAP_PROP_FRAME_HEIGHT:
|
|
374
|
+
return float(self.last_metadata.height)
|
|
375
|
+
return 0.0
|
|
376
|
+
|
|
377
|
+
def release(self) -> None:
|
|
378
|
+
if self._opened:
|
|
379
|
+
self._source.close()
|
|
380
|
+
self._opened = False
|
esp32_stream/protocol.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Protocol parser and framing utilities for the ESP32 stream.
|
|
2
|
+
|
|
3
|
+
This module should stay in sync with firmware/protocol.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SYNC_BYTE = 0xAA
|
|
12
|
+
PACKET_TYPE_IMU_SAMPLE = 0x01
|
|
13
|
+
PACKET_TYPE_FRAME_START = 0x02
|
|
14
|
+
PACKET_TYPE_FRAME_CHUNK = 0x03
|
|
15
|
+
PACKET_TYPE_FRAME_END = 0x04
|
|
16
|
+
PACKET_TYPE_CONTROL_CMD = 0x10
|
|
17
|
+
PACKET_TYPE_CONTROL_ACK = 0x11
|
|
18
|
+
PACKET_TYPE_STREAM_STATS = 0x12
|
|
19
|
+
|
|
20
|
+
CONTROL_CMD_SET_CAMERA_MODE = 0x01
|
|
21
|
+
CONTROL_CMD_SET_STREAM_MODE = 0x02
|
|
22
|
+
CONTROL_CMD_GET_STREAM_STATS = 0x03
|
|
23
|
+
|
|
24
|
+
CAMERA_MODE_DEV_QVGA = 0
|
|
25
|
+
CAMERA_MODE_PROC_LOWRES = 1
|
|
26
|
+
|
|
27
|
+
CAMERA_MODE_NAME_TO_ID = {
|
|
28
|
+
"dev_qvga": CAMERA_MODE_DEV_QVGA,
|
|
29
|
+
"proc_lowres": CAMERA_MODE_PROC_LOWRES,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
CAMERA_MODE_ID_TO_NAME = {
|
|
33
|
+
CAMERA_MODE_DEV_QVGA: "dev_qvga",
|
|
34
|
+
CAMERA_MODE_PROC_LOWRES: "proc_lowres",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
STREAM_MODE_IMU_CAMERA = 0
|
|
38
|
+
STREAM_MODE_IMU_ONLY = 1
|
|
39
|
+
|
|
40
|
+
STREAM_MODE_NAME_TO_ID = {
|
|
41
|
+
"imu_camera": STREAM_MODE_IMU_CAMERA,
|
|
42
|
+
"imu_only": STREAM_MODE_IMU_ONLY,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
STREAM_MODE_ID_TO_NAME = {
|
|
46
|
+
STREAM_MODE_IMU_CAMERA: "imu_camera",
|
|
47
|
+
STREAM_MODE_IMU_ONLY: "imu_only",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
VALID_PACKET_TYPES = {
|
|
51
|
+
PACKET_TYPE_IMU_SAMPLE,
|
|
52
|
+
PACKET_TYPE_FRAME_START,
|
|
53
|
+
PACKET_TYPE_FRAME_CHUNK,
|
|
54
|
+
PACKET_TYPE_FRAME_END,
|
|
55
|
+
PACKET_TYPE_CONTROL_CMD,
|
|
56
|
+
PACKET_TYPE_CONTROL_ACK,
|
|
57
|
+
PACKET_TYPE_STREAM_STATS,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
HEADER_LEN = 1 + 1 + 2 + 8
|
|
61
|
+
CRC_LEN = 2
|
|
62
|
+
MIN_PACKET_LEN = HEADER_LEN + CRC_LEN
|
|
63
|
+
MAX_PAYLOAD_LEN = 4096
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Packet:
|
|
68
|
+
packet_type: int
|
|
69
|
+
timestamp_us: int
|
|
70
|
+
payload: bytes
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _crc16_ccitt_false(data: bytes) -> int:
|
|
74
|
+
crc = 0xFFFF
|
|
75
|
+
for byte in data:
|
|
76
|
+
crc ^= byte << 8
|
|
77
|
+
for _ in range(8):
|
|
78
|
+
if crc & 0x8000:
|
|
79
|
+
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
|
|
80
|
+
else:
|
|
81
|
+
crc = (crc << 1) & 0xFFFF
|
|
82
|
+
return crc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class StreamParser:
|
|
86
|
+
"""Incremental parser with sync recovery and CRC validation."""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self._buffer = bytearray()
|
|
90
|
+
|
|
91
|
+
def feed(self, data: bytes) -> list[Packet]:
|
|
92
|
+
self._buffer.extend(data)
|
|
93
|
+
packets: list[Packet] = []
|
|
94
|
+
|
|
95
|
+
while True:
|
|
96
|
+
if len(self._buffer) < MIN_PACKET_LEN:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
if self._buffer[0] != SYNC_BYTE:
|
|
100
|
+
sync_idx = self._buffer.find(SYNC_BYTE)
|
|
101
|
+
if sync_idx == -1:
|
|
102
|
+
self._buffer.clear()
|
|
103
|
+
break
|
|
104
|
+
del self._buffer[:sync_idx]
|
|
105
|
+
if len(self._buffer) < MIN_PACKET_LEN:
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
packet_type = self._buffer[1]
|
|
109
|
+
if packet_type not in VALID_PACKET_TYPES:
|
|
110
|
+
del self._buffer[0]
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
payload_len = int.from_bytes(self._buffer[2:4], "little")
|
|
114
|
+
if payload_len > MAX_PAYLOAD_LEN:
|
|
115
|
+
del self._buffer[0]
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
packet_len = HEADER_LEN + payload_len + CRC_LEN
|
|
119
|
+
if len(self._buffer) < packet_len:
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
packet_bytes = bytes(self._buffer[:packet_len])
|
|
123
|
+
crc_expected = int.from_bytes(packet_bytes[-2:], "little")
|
|
124
|
+
crc_data = packet_bytes[1:-2]
|
|
125
|
+
crc_actual = _crc16_ccitt_false(crc_data)
|
|
126
|
+
|
|
127
|
+
if crc_actual != crc_expected:
|
|
128
|
+
del self._buffer[0]
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
packets.append(
|
|
132
|
+
Packet(
|
|
133
|
+
packet_type=packet_bytes[1],
|
|
134
|
+
timestamp_us=int.from_bytes(packet_bytes[4:12], "little"),
|
|
135
|
+
payload=packet_bytes[12:-2],
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
del self._buffer[:packet_len]
|
|
139
|
+
|
|
140
|
+
return packets
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_stream(data: bytes) -> list[Packet]:
|
|
144
|
+
"""Parse a raw byte stream into protocol packets in one shot."""
|
|
145
|
+
return StreamParser().feed(data)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_packet(packet_type: int, timestamp_us: int, payload: bytes) -> bytes:
|
|
149
|
+
payload_len = len(payload)
|
|
150
|
+
header = bytes([SYNC_BYTE, packet_type])
|
|
151
|
+
header += payload_len.to_bytes(2, "little")
|
|
152
|
+
header += int(timestamp_us).to_bytes(8, "little", signed=False)
|
|
153
|
+
crc = _crc16_ccitt_false(header[1:] + payload)
|
|
154
|
+
return header + payload + crc.to_bytes(2, "little")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_set_camera_mode_command(mode_id: int, timestamp_us: int) -> bytes:
|
|
158
|
+
payload = bytes([CONTROL_CMD_SET_CAMERA_MODE, mode_id & 0xFF])
|
|
159
|
+
return build_packet(PACKET_TYPE_CONTROL_CMD, timestamp_us, payload)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_set_stream_mode_command(mode_id: int, timestamp_us: int) -> bytes:
|
|
163
|
+
payload = bytes([CONTROL_CMD_SET_STREAM_MODE, mode_id & 0xFF])
|
|
164
|
+
return build_packet(PACKET_TYPE_CONTROL_CMD, timestamp_us, payload)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_get_stream_stats_command(timestamp_us: int) -> bytes:
|
|
168
|
+
payload = bytes([CONTROL_CMD_GET_STREAM_STATS])
|
|
169
|
+
return build_packet(PACKET_TYPE_CONTROL_CMD, timestamp_us, payload)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: esp32-stream
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ESP32 serial stream protocol parser and frame source adapter
|
|
5
|
+
Author: ESP32 Host Tooling
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: pyserial>=3.5
|
|
9
|
+
Requires-Dist: numpy>=1.26
|
|
10
|
+
Provides-Extra: opencv
|
|
11
|
+
Requires-Dist: opencv-python>=4.10; extra == "opencv"
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# esp32-stream
|
|
17
|
+
|
|
18
|
+
Reusable Python package for ESP32 serial stream protocol parsing and camera frame assembly.
|
|
19
|
+
|
|
20
|
+
This package is the source of truth for host-side stream handling used by calibration and other consumer projects.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install esp32-stream
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
With OpenCV decode support:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install "esp32-stream[opencv]"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Development Install
|
|
35
|
+
|
|
36
|
+
From this folder:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e ".[dev,opencv]"
|
|
40
|
+
pytest
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Public API
|
|
44
|
+
|
|
45
|
+
- `esp32_stream.protocol`: packet constants, parser, command builders
|
|
46
|
+
- `esp32_stream.board_profiles`: board defaults and aliases
|
|
47
|
+
- `esp32_stream.esp32_frame_source`: `Esp32FrameSource`, `Esp32VideoCaptureAdapter`
|
|
48
|
+
|
|
49
|
+
## Consumer Pinning (recommended)
|
|
50
|
+
|
|
51
|
+
Pin exact versions in downstream projects:
|
|
52
|
+
|
|
53
|
+
```txt
|
|
54
|
+
esp32-stream==0.1.0
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For local integration testing before release:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install -e /absolute/path/to/esp32/python/esp32_stream
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Do not use editable installs for production or CI release branches.
|
|
64
|
+
|
|
65
|
+
## Upgrade Workflow
|
|
66
|
+
|
|
67
|
+
1. Update package in this repo on a feature branch.
|
|
68
|
+
2. Run package tests (`pytest`).
|
|
69
|
+
3. Run the consumer smoke test in each downstream project.
|
|
70
|
+
4. Bump version and tag release.
|
|
71
|
+
5. Update pinned version in each consumer project when ready.
|
|
72
|
+
|
|
73
|
+
## Release
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python -m pip install --upgrade build twine
|
|
77
|
+
python -m build
|
|
78
|
+
python -m twine check dist/*
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Tag example:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
git tag v0.1.0
|
|
85
|
+
git push origin v0.1.0
|
|
86
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
esp32_stream/__init__.py,sha256=o_x6zp1XiUkbjc4U0rgdxUAXjXDLgV-IdZj5WzST20Q,463
|
|
2
|
+
esp32_stream/board_profiles.py,sha256=t6oIGdOF-npwXH701znSwBfWNQri7AsPHGuAFWtD7NI,1331
|
|
3
|
+
esp32_stream/esp32_frame_source.py,sha256=8-dSW-gLCQJHq-cudxOhYJZ0yNnLtJPJ195VjY7thFk,10817
|
|
4
|
+
esp32_stream/protocol.py,sha256=rIOqbPdGft5lGSuXt0SZLShM85WQflyWX2FG2nTFvD8,4803
|
|
5
|
+
esp32_stream-0.1.0.dist-info/METADATA,sha256=O4Zdx_3VsP6KzGZTGvtvGy1Bh_1a4SbTyw8Pyc28cXg,1875
|
|
6
|
+
esp32_stream-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
esp32_stream-0.1.0.dist-info/top_level.txt,sha256=Xg70wdZXX7GyDmk7DKuW0ABscjXMMZRqJngQTMsFfDA,13
|
|
8
|
+
esp32_stream-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
esp32_stream
|