esp32-stream 0.1.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,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,71 @@
1
+ # esp32-stream
2
+
3
+ Reusable Python package for ESP32 serial stream protocol parsing and camera frame assembly.
4
+
5
+ This package is the source of truth for host-side stream handling used by calibration and other consumer projects.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install esp32-stream
11
+ ```
12
+
13
+ With OpenCV decode support:
14
+
15
+ ```bash
16
+ pip install "esp32-stream[opencv]"
17
+ ```
18
+
19
+ ## Development Install
20
+
21
+ From this folder:
22
+
23
+ ```bash
24
+ pip install -e ".[dev,opencv]"
25
+ pytest
26
+ ```
27
+
28
+ ## Public API
29
+
30
+ - `esp32_stream.protocol`: packet constants, parser, command builders
31
+ - `esp32_stream.board_profiles`: board defaults and aliases
32
+ - `esp32_stream.esp32_frame_source`: `Esp32FrameSource`, `Esp32VideoCaptureAdapter`
33
+
34
+ ## Consumer Pinning (recommended)
35
+
36
+ Pin exact versions in downstream projects:
37
+
38
+ ```txt
39
+ esp32-stream==0.1.0
40
+ ```
41
+
42
+ For local integration testing before release:
43
+
44
+ ```bash
45
+ pip install -e /absolute/path/to/esp32/python/esp32_stream
46
+ ```
47
+
48
+ Do not use editable installs for production or CI release branches.
49
+
50
+ ## Upgrade Workflow
51
+
52
+ 1. Update package in this repo on a feature branch.
53
+ 2. Run package tests (`pytest`).
54
+ 3. Run the consumer smoke test in each downstream project.
55
+ 4. Bump version and tag release.
56
+ 5. Update pinned version in each consumer project when ready.
57
+
58
+ ## Release
59
+
60
+ ```bash
61
+ python -m pip install --upgrade build twine
62
+ python -m build
63
+ python -m twine check dist/*
64
+ ```
65
+
66
+ Tag example:
67
+
68
+ ```bash
69
+ git tag v0.1.0
70
+ git push origin v0.1.0
71
+ ```
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "esp32-stream"
7
+ version = "0.1.0"
8
+ description = "ESP32 serial stream protocol parser and frame source adapter"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [
12
+ { name = "ESP32 Host Tooling" }
13
+ ]
14
+ dependencies = [
15
+ "pyserial>=3.5",
16
+ "numpy>=1.26"
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ opencv = [
21
+ "opencv-python>=4.10"
22
+ ]
23
+ dev = [
24
+ "pytest>=8.0",
25
+ "pytest-cov>=5.0"
26
+ ]
27
+
28
+ [tool.setuptools]
29
+ package-dir = {"" = "src"}
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
@@ -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,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/esp32_stream/__init__.py
4
+ src/esp32_stream/board_profiles.py
5
+ src/esp32_stream/esp32_frame_source.py
6
+ src/esp32_stream/protocol.py
7
+ src/esp32_stream.egg-info/PKG-INFO
8
+ src/esp32_stream.egg-info/SOURCES.txt
9
+ src/esp32_stream.egg-info/dependency_links.txt
10
+ src/esp32_stream.egg-info/requires.txt
11
+ src/esp32_stream.egg-info/top_level.txt
12
+ tests/test_board_profiles.py
13
+ tests/test_frame_source.py
14
+ tests/test_protocol.py
@@ -0,0 +1,9 @@
1
+ pyserial>=3.5
2
+ numpy>=1.26
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ pytest-cov>=5.0
7
+
8
+ [opencv]
9
+ opencv-python>=4.10
@@ -0,0 +1 @@
1
+ esp32_stream
@@ -0,0 +1,36 @@
1
+ import pytest
2
+
3
+ from esp32_stream.board_profiles import CAMERA_MODE_DIMENSIONS, get_board_profile
4
+ from esp32_stream.esp32_frame_source import Esp32FrameSource
5
+
6
+
7
+ def test_alias_resolution():
8
+ assert get_board_profile("s3").name == "esp32s3"
9
+ assert get_board_profile("p4").name == "esp32p4"
10
+
11
+
12
+ def test_camera_mode_dimensions():
13
+ assert CAMERA_MODE_DIMENSIONS["dev_qvga"] == (320, 240)
14
+ assert CAMERA_MODE_DIMENSIONS["proc_lowres"] == (640, 400)
15
+
16
+
17
+ def test_frame_source_uses_board_defaults():
18
+ src_s3 = Esp32FrameSource(board="esp32s3", serial_port=object())
19
+ assert src_s3.port == "/dev/cu.usbmodem1101"
20
+ assert src_s3.baud == 115200
21
+
22
+ src_p4 = Esp32FrameSource(board="esp32p4", serial_port=object())
23
+ assert src_p4.port == "/dev/cu.usbmodem1101"
24
+ assert src_p4.baud == 6000000
25
+
26
+
27
+ def test_explicit_values_override_board_defaults():
28
+ src = Esp32FrameSource(port="/dev/tty.custom", baud=2000000, board="p4", serial_port=object())
29
+ assert src.port == "/dev/tty.custom"
30
+ assert src.baud == 2000000
31
+ assert src.board == "esp32p4"
32
+
33
+
34
+ def test_unknown_board_raises():
35
+ with pytest.raises(ValueError):
36
+ get_board_profile("esp32-unknown")
@@ -0,0 +1,110 @@
1
+ from esp32_stream.board_profiles import CAMERA_MODE_DIMENSIONS
2
+ from esp32_stream.esp32_frame_source import Esp32FrameSource, Esp32VideoCaptureAdapter
3
+ from esp32_stream.protocol import (
4
+ CAMERA_MODE_DEV_QVGA,
5
+ PACKET_TYPE_FRAME_CHUNK,
6
+ PACKET_TYPE_FRAME_END,
7
+ PACKET_TYPE_FRAME_START,
8
+ build_packet,
9
+ )
10
+
11
+
12
+ class _FakeSerial:
13
+ def __init__(self, chunks):
14
+ self._chunks = list(chunks)
15
+ self.writes = []
16
+
17
+ def read(self, n):
18
+ del n
19
+ if not self._chunks:
20
+ return b""
21
+ return self._chunks.pop(0)
22
+
23
+ def write(self, data):
24
+ self.writes.append(bytes(data))
25
+ return len(data)
26
+
27
+ def flush(self):
28
+ return None
29
+
30
+ def close(self):
31
+ return None
32
+
33
+ def reset_input_buffer(self):
34
+ return None
35
+
36
+
37
+ def _build_frame_packets(frame_id: int, payload: bytes):
38
+ capture_start_us = 1_000_000
39
+ capture_ready_us = 1_005_000
40
+ width, height = CAMERA_MODE_DIMENSIONS["dev_qvga"]
41
+
42
+ start_payload = bytearray()
43
+ start_payload.extend(frame_id.to_bytes(4, "little"))
44
+ start_payload.extend(capture_start_us.to_bytes(8, "little"))
45
+ start_payload.extend(capture_ready_us.to_bytes(8, "little"))
46
+ start_payload.extend(len(payload).to_bytes(4, "little"))
47
+ start_payload.extend(width.to_bytes(2, "little"))
48
+ start_payload.extend(height.to_bytes(2, "little"))
49
+ start_payload.append(CAMERA_MODE_DEV_QVGA)
50
+
51
+ chunk_payload = bytearray()
52
+ chunk_payload.extend(frame_id.to_bytes(4, "little"))
53
+ chunk_payload.extend((0).to_bytes(4, "little"))
54
+ chunk_payload.extend(payload)
55
+
56
+ end_payload = bytearray()
57
+ end_payload.extend(frame_id.to_bytes(4, "little"))
58
+ end_payload.extend(len(payload).to_bytes(4, "little"))
59
+ end_payload.extend((1).to_bytes(2, "little"))
60
+
61
+ return (
62
+ build_packet(PACKET_TYPE_FRAME_START, capture_ready_us, bytes(start_payload)),
63
+ build_packet(PACKET_TYPE_FRAME_CHUNK, capture_ready_us, bytes(chunk_payload)),
64
+ build_packet(PACKET_TYPE_FRAME_END, capture_ready_us, bytes(end_payload)),
65
+ )
66
+
67
+
68
+ def test_read_returns_frame_and_timestamps():
69
+ jpeg_like_payload = b"\xff\xd8\xff\xd9"
70
+ p_start, p_chunk, p_end = _build_frame_packets(7, jpeg_like_payload)
71
+ fake = _FakeSerial([p_start + p_chunk + p_end])
72
+
73
+ src = Esp32FrameSource(serial_port=fake)
74
+
75
+ src._decode_jpeg = lambda b: {"decoded": b} # type: ignore
76
+
77
+ ok, frame, meta = src.read(timeout_s=0.02)
78
+ assert ok
79
+ assert frame == {"decoded": jpeg_like_payload}
80
+ assert meta.frame_id == 7
81
+ assert meta.capture_start_us == 1_000_000
82
+ assert meta.capture_ready_us == 1_005_000
83
+ assert meta.packet_timestamp_us == 1_005_000
84
+ assert meta.frame_len_expected == len(jpeg_like_payload)
85
+ assert meta.frame_len_received == len(jpeg_like_payload)
86
+ assert meta.chunk_count == 1
87
+
88
+
89
+ def test_cv2_adapter_read_and_metadata():
90
+ jpeg_like_payload = b"\xff\xd8\xff\xd9"
91
+ p_start, p_chunk, p_end = _build_frame_packets(13, jpeg_like_payload)
92
+ fake = _FakeSerial([p_start + p_chunk + p_end])
93
+
94
+ cap = Esp32VideoCaptureAdapter(serial_port=fake, auto_open=False)
95
+ assert not cap.isOpened()
96
+ assert cap.open()
97
+ assert cap.isOpened()
98
+
99
+ cap._source._decode_jpeg = lambda b: {"decoded": b} # type: ignore
100
+
101
+ ok, frame = cap.read(timeout_s=0.02)
102
+ assert ok
103
+ assert frame == {"decoded": jpeg_like_payload}
104
+ assert cap.last_metadata is not None
105
+ assert cap.last_metadata.frame_id == 13
106
+ assert cap.get(3) == 320.0
107
+ assert cap.get(4) == 240.0
108
+
109
+ cap.release()
110
+ assert not cap.isOpened()
@@ -0,0 +1,90 @@
1
+ from esp32_stream.protocol import (
2
+ MAX_PAYLOAD_LEN,
3
+ PACKET_TYPE_CONTROL_CMD,
4
+ PACKET_TYPE_IMU_SAMPLE,
5
+ PACKET_TYPE_STREAM_STATS,
6
+ StreamParser,
7
+ build_get_stream_stats_command,
8
+ build_packet,
9
+ build_set_camera_mode_command,
10
+ build_set_stream_mode_command,
11
+ parse_stream,
12
+ )
13
+
14
+
15
+ def test_parse_valid_packet():
16
+ payload = bytes([1, 2, 3, 4])
17
+ ts = 123456789
18
+ raw = build_packet(PACKET_TYPE_IMU_SAMPLE, ts, payload)
19
+
20
+ packets = parse_stream(raw)
21
+ assert len(packets) == 1
22
+ assert packets[0].packet_type == PACKET_TYPE_IMU_SAMPLE
23
+ assert packets[0].timestamp_us == ts
24
+ assert packets[0].payload == payload
25
+
26
+
27
+ def test_crc_error_drops_packet():
28
+ raw = bytearray(build_packet(PACKET_TYPE_IMU_SAMPLE, 42, b"abc"))
29
+ raw[-1] ^= 0xFF
30
+ packets = parse_stream(bytes(raw))
31
+ assert packets == []
32
+
33
+
34
+ def test_truncated_packet_waits_for_more():
35
+ payload = b"hello"
36
+ raw = build_packet(PACKET_TYPE_IMU_SAMPLE, 1, payload)
37
+
38
+ parser = StreamParser()
39
+ partial = raw[:-2]
40
+ assert parser.feed(partial) == []
41
+
42
+ packets = parser.feed(raw[-2:])
43
+ assert len(packets) == 1
44
+ assert packets[0].payload == payload
45
+
46
+
47
+ def test_resync_after_noise():
48
+ pkt1 = build_packet(PACKET_TYPE_IMU_SAMPLE, 10, b"imu")
49
+ pkt2 = build_packet(PACKET_TYPE_CONTROL_CMD, 11, bytes([1, 0]))
50
+ raw = b"\x00\xFF\x13\x37" + pkt1 + b"\x99\x98" + pkt2
51
+
52
+ packets = parse_stream(raw)
53
+ assert len(packets) == 2
54
+ assert packets[0].payload == b"imu"
55
+ assert packets[1].packet_type == PACKET_TYPE_CONTROL_CMD
56
+
57
+
58
+ def test_oversized_payload_len_is_rejected():
59
+ parser = StreamParser()
60
+ header = bytearray()
61
+ header.append(0xAA)
62
+ header.append(PACKET_TYPE_IMU_SAMPLE)
63
+ header.extend((MAX_PAYLOAD_LEN + 1).to_bytes(2, "little"))
64
+ header.extend((0).to_bytes(8, "little"))
65
+
66
+ raw = bytes(header) + b"\x00\x00" + build_packet(PACKET_TYPE_IMU_SAMPLE, 5, b"a")
67
+ packets = parser.feed(raw)
68
+ assert len(packets) == 1
69
+ assert packets[0].timestamp_us == 5
70
+
71
+
72
+ def test_stream_stats_type_is_accepted():
73
+ raw = build_packet(PACKET_TYPE_STREAM_STATS, 77, b"abc")
74
+ packets = parse_stream(raw)
75
+ assert len(packets) == 1
76
+ assert packets[0].packet_type == PACKET_TYPE_STREAM_STATS
77
+
78
+
79
+ def test_control_command_builders_emit_control_packets():
80
+ a = build_set_camera_mode_command(0, 111)
81
+ b = build_set_stream_mode_command(0, 222)
82
+ c = build_get_stream_stats_command(333)
83
+
84
+ pa = parse_stream(a)[0]
85
+ pb = parse_stream(b)[0]
86
+ pc = parse_stream(c)[0]
87
+
88
+ assert pa.packet_type == PACKET_TYPE_CONTROL_CMD
89
+ assert pb.packet_type == PACKET_TYPE_CONTROL_CMD
90
+ assert pc.packet_type == PACKET_TYPE_CONTROL_CMD