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.
- esp32_stream-0.1.0/PKG-INFO +86 -0
- esp32_stream-0.1.0/README.md +71 -0
- esp32_stream-0.1.0/pyproject.toml +36 -0
- esp32_stream-0.1.0/setup.cfg +4 -0
- esp32_stream-0.1.0/src/esp32_stream/__init__.py +21 -0
- esp32_stream-0.1.0/src/esp32_stream/board_profiles.py +54 -0
- esp32_stream-0.1.0/src/esp32_stream/esp32_frame_source.py +380 -0
- esp32_stream-0.1.0/src/esp32_stream/protocol.py +169 -0
- esp32_stream-0.1.0/src/esp32_stream.egg-info/PKG-INFO +86 -0
- esp32_stream-0.1.0/src/esp32_stream.egg-info/SOURCES.txt +14 -0
- esp32_stream-0.1.0/src/esp32_stream.egg-info/dependency_links.txt +1 -0
- esp32_stream-0.1.0/src/esp32_stream.egg-info/requires.txt +9 -0
- esp32_stream-0.1.0/src/esp32_stream.egg-info/top_level.txt +1 -0
- esp32_stream-0.1.0/tests/test_board_profiles.py +36 -0
- esp32_stream-0.1.0/tests/test_frame_source.py +110 -0
- esp32_stream-0.1.0/tests/test_protocol.py +90 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|