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