From 1ef044894e51d7ad316d51e910621fd4cb4387ff Mon Sep 17 00:00:00 2001 From: iabdalkader Date: Sat, 13 Dec 2025 10:09:46 +0100 Subject: [PATCH] All: OpenMV Python package - PyPI package name: openmv - CLI command: openmv - Library for OpenMV Camera Protocol V2 - Dependencies: pyserial, numpy, pygame (optional) Signed-off-by: iabdalkader --- .gitignore | 137 +++++++++ LICENSE | 21 ++ README.md | 139 +++++++++ pyproject.toml | 48 +++ src/openmv/__init__.py | 35 +++ src/openmv/buffer.py | 85 +++++ src/openmv/camera.py | 649 +++++++++++++++++++++++++++++++++++++++ src/openmv/cli.py | 316 +++++++++++++++++++ src/openmv/constants.py | 101 ++++++ src/openmv/crc.py | 119 +++++++ src/openmv/exceptions.py | 41 +++ src/openmv/image.py | 129 ++++++++ src/openmv/profiler.py | 427 ++++++++++++++++++++++++++ src/openmv/transport.py | 327 ++++++++++++++++++++ 14 files changed, 2574 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/openmv/__init__.py create mode 100644 src/openmv/buffer.py create mode 100644 src/openmv/camera.py create mode 100644 src/openmv/cli.py create mode 100644 src/openmv/constants.py create mode 100644 src/openmv/crc.py create mode 100644 src/openmv/exceptions.py create mode 100644 src/openmv/image.py create mode 100644 src/openmv/profiler.py create mode 100644 src/openmv/transport.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b48fb22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,137 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific +capture.png diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c2f212b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 OpenMV, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fba6edb --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# OpenMV Python + +Python library and CLI for communicating with OpenMV cameras using Protocol V2. + +## Installation + +```bash +pip install openmv +``` + +For video streaming support (requires pygame): + +```bash +pip install openmv[gui] +``` + +## CLI Usage + +### Stream Video + +```bash +# Stream from default port (/dev/ttyACM0) +openmv + +# Stream from specific port +openmv --port /dev/ttyACM1 + +# Run a custom script +openmv --script my_script.py + +# Adjust display scale +openmv --scale 2 +``` + +### Camera Info + +```bash +openmv --info +``` + +### Reset Camera + +```bash +openmv --reset +``` + +### Enter Bootloader + +```bash +openmv --boot +``` + +### Benchmark Mode + +```bash +openmv --bench +``` + +### Controls (Stream Mode) + +- `C` - Capture screenshot to `capture.png` +- `ESC` - Exit + +## Library Usage + +```python +from openmv import OMVCamera + +# Connect to camera +with OMVCamera('/dev/ttyACM0') as camera: + # Get system info + info = camera.system_info() + print(f"Firmware: {info['firmware_version']}") + + # Execute a script + camera.exec(''' +import csi +csi0 = csi.CSI() +csi0.reset() +''') + + # Read frames + camera.streaming(True) + while True: + if frame := camera.read_frame(): + print(f"Frame: {frame['width']}x{frame['height']}") +``` + +## API Reference + +### OMVCamera + +Main class for camera communication. + +```python +OMVCamera( + port, # Serial port (e.g., '/dev/ttyACM0') + baudrate=921600, # Serial baudrate + crc=True, # Enable CRC validation + seq=True, # Enable sequence number validation + ack=True, # Enable packet acknowledgment + events=True, # Enable event notifications + timeout=1.0, # Protocol timeout in seconds + max_retry=3, # Maximum retries + max_payload=4096, # Maximum payload size +) +``` + +#### Methods + +- `connect()` / `disconnect()` - Manage connection +- `exec(script)` - Execute a MicroPython script +- `stop()` - Stop the running script +- `reset()` - Reset the camera +- `boot()` - Enter bootloader mode +- `streaming(enable, raw=False, res=None)` - Enable/disable video streaming +- `read_frame()` - Read a video frame +- `read_stdout()` - Read script output +- `read_status()` - Poll channel status +- `system_info()` - Get camera system information +- `channel_read(name)` / `channel_write(name, data)` - Custom channel I/O + +### Exceptions + +- `OMVPException` - Base protocol exception +- `OMVPTimeoutException` - Timeout during communication +- `OMVPChecksumException` - CRC validation failure +- `OMVPSequenceException` - Sequence number mismatch + +## Requirements + +- Python 3.8+ +- pyserial +- numpy +- pygame (optional, for video streaming) + +## License + +MIT License - Copyright (c) 2025 OpenMV, LLC. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c33b42b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "openmv" +version = "2.0.0" +description = "OpenMV Camera Protocol V2 - Python library and CLI" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.8" +authors = [ + {name = "OpenMV, LLC", email = "info@openmv.io"} +] +keywords = ["openmv", "camera", "machine-vision", "embedded", "micropython"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Image Processing", + "Topic :: Software Development :: Embedded Systems", +] +dependencies = [ + "pyserial>=3.5", + "numpy>=1.20.0", + "pygame>=2.0.0", + "pyelftools" +] + +[project.scripts] +openmv = "openmv.cli:main" + +[project.urls] +Homepage = "https://openmv.io" +Documentation = "https://docs.openmv.io" +Repository = "https://github.com/openmv/openmv-python" +Issues = "https://github.com/openmv/openmv-python/issues" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/openmv/__init__.py b/src/openmv/__init__.py new file mode 100644 index 0000000..62ab45c --- /dev/null +++ b/src/openmv/__init__.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (c) 2025 OpenMV, LLC. +# +# OpenMV Protocol Package +# +# This package provides a Python implementation of the OpenMV Protocol +# for communicating with OpenMV cameras. +# +# Main classes: +# Camera: High-level camera interface with channel operations +# +# Main exceptions: +# OMVException: Base exception for protocol errors +# TimeoutException: Timeout during protocol operations +# ChecksumException: CRC validation failures +# SequenceException: Sequence number validation failures + +from .camera import Camera +from .exceptions import ( + OMVException, + TimeoutException, + ChecksumException, + SequenceException +) + +__version__ = "2.0.0" + +__all__ = [ + 'Camera', + 'OMVException', + 'TimeoutException', + 'ChecksumException', + 'SequenceException' +] diff --git a/src/openmv/buffer.py b/src/openmv/buffer.py new file mode 100644 index 0000000..fac8312 --- /dev/null +++ b/src/openmv/buffer.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (c) 2025 OpenMV, LLC. +# +# OpenMV Protocol Ring Buffer +# +# This module provides an efficient ring buffer implementation for +# packet parsing with optimized memory operations using memoryview. + +import struct + + +class RingBuffer: + """Efficient ring buffer for packet parsing""" + def __init__(self, size=4096): + self.size = size + self.data = memoryview(bytearray(size)) + self.start = 0 # Read position + self.end = 0 # Write position + self.count = 0 # Number of bytes in buffer + + def __len__(self): + return self.count + + def extend(self, data): + """Add data to buffer - optimized with memoryview""" + data_len = len(data) + data_view = memoryview(data) + if data_len > self.size - self.count: + raise BufferError("Buffer overflow:") + + # Calculate contiguous space from end to buffer boundary + space_to_end = self.size - self.end + + if data_len <= space_to_end: + # All data fits without wrapping + self.data[self.end:self.end + data_len] = data_view[:data_len] + self.end = (self.end + data_len) % self.size + else: + # First part: from end to buffer boundary + self.data[self.end:self.size] = data_view[:space_to_end] + # Second part: from buffer start + remaining = data_len - space_to_end + self.data[:remaining] = data_view[space_to_end:data_len] + self.end = remaining + + self.count += data_len + + def peek(self, size): + """Peek at data without consuming - returns memoryview when possible""" + if size > self.count: + return None + + if self.start + size <= self.size: + # No wrap-around - return memoryview (zero-copy) + return self.data[self.start:self.start + size] + else: + # Wrap-around case - must create new bytes object + first_part = self.size - self.start + # Use tobytes() for memoryview concatenation + return (self.data[self.start:].tobytes() + self.data[:size - first_part].tobytes()) + + def peek16(self): + """Peek at 16-bit value at start of buffer""" + if self.count < 2: + return None + elif self.start + 2 <= self.size: + # No wrap-around - use struct directly on memoryview + return struct.unpack(' 0: + self.pending_channel_events -= 1 + self.channels_by_id = self._channel_list() + self.channels_by_name = {ch['name']: cid for cid, ch in self.channels_by_id.items()} + logging.info(f"Registered channels ({len(self.channels_by_id)}):") + for cid, ch in self.channels_by_id.items(): + logging.info(f" ID: {cid}, Flags: 0x{ch['flags']:02X}, Name: {ch['name']}") + + def get_channel(self, name=None, channel_id=None): + """Get channel ID by name or channel name by ID with lazy loading""" + if self.pending_channel_events > 0: + self.update_channels() + + if name is not None: + # Return channel ID for given name + return self.channels_by_name.get(name) + elif channel_id is not None: + # Return channel name given ID + return self.channels_by_id.get(channel_id)["name"] + else: + raise ValueError("Must specify either name or channel_id") + + def connect(self): + """Establish connection to the OpenMV camera""" + try: + # Enable hw flow control with rtscts=True + self._serial = serial.Serial(self.port, self.baudrate, timeout=self.timeout) + + # Perform resync + self._resync() + + # Cache channel list + self.update_channels() + + # Cache system info + self.sysinfo = self.system_info() + + # Print system information + self.print_system_info() + except Exception as e: + self.disconnect() + raise OMVException(f"Failed to connect: {e}") + + def disconnect(self): + """Close connection to the OpenMV camera""" + if self._serial: + self._serial.close() + self._serial = None + self.transport = None + + def is_connected(self): + """Check if connected to camera""" + return self._serial is not None and self._serial.is_open + + def host_stats(self): + """Get transport statistics""" + return self.transport.stats + + @retry_if_failed + def device_stats(self): + """Get protocol statistics""" + payload = self._send_cmd_wait_resp(Opcode.PROTO_STATS) + + if len(payload) < 32: + raise OMVException(f"Invalid PROTO_STATS payload size: {len(payload)}") + + # Unpack the structure: 8 uint32_t fields (32 bytes total) + data = struct.unpack('<8I', payload) + + return { + 'sent': data[0], + 'received': data[1], + 'checksum': data[2], + 'sequence': data[3], + 'retransmit': data[4], + 'transport': data[5], + 'sent_events': data[6], + 'max_ack_queue_depth': data[7] + } + + @retry_if_failed + def reset(self): + """Reset the camera""" + self._send_cmd_wait_resp(Opcode.SYS_RESET) + + @retry_if_failed + def boot(self): + """Jump to bootloader""" + self._send_cmd_wait_resp(Opcode.SYS_BOOT) + + @retry_if_failed + def update_capabilities(self): + """Set device capabilities""" + payload = self._send_cmd_wait_resp(Opcode.PROTO_GET_CAPS) + flags, max_payload = struct.unpack(' 0: + self.update_channels() + for name, channel_id in self.channels_by_name.items(): + result[name] = bool(flags & (1 << channel_id)) + + return result + + @retry_if_failed + def profiler_reset(self, config=None): + """Reset the profiler data""" + if profile_id := self.get_channel(name="profile"): + self._channel_ioctl(profile_id, ChannelIOCTL.PROFILE_RESET) + logging.debug("Profiler reset") + + if config is None: + # CPU cycles, and L1I, L1D, L2D cache info + events = [0x0039, 0x0023, 0x0024, 0x0001, 0x0003, 0xC102, 0x02CC, 0xC303] + for e in zip(range(len(events)), events): + self.profiler_event(*e) + + @retry_if_failed + def profiler_mode(self, exclusive=False): + """Set profiler mode (exclusive=True for exclusive, False for inclusive)""" + if profile_id := self.get_channel(name="profile"): + mode = 1 if exclusive else 0 + self._channel_ioctl(profile_id, ChannelIOCTL.PROFILE_MODE, 'I', mode) + logging.debug(f"Profile mode set to {'exclusive' if exclusive else 'inclusive'}") + + @retry_if_failed + def profiler_event(self, counter_num, event_id): + """Configure an event counter to monitor a specific event""" + if profile_id := self.get_channel(name="profile"): + self._channel_ioctl(profile_id, ChannelIOCTL.PROFILE_SET_EVENT, 'II', counter_num, event_id) + logging.debug(f"Event counter {counter_num} set to event 0x{event_id:04X}") + + @retry_if_failed + def read_profile(self): + """Read profiler data from the profile channel""" + # Check if profile channel is available (replaces profile_enabled check) + profile_id = self.get_channel(name="profile") + if not profile_id: + return None + + # TODO just subtract the known record size + # Get event count from cached system info (pmu_eventcnt field) + event_count = self.sysinfo['pmu_eventcnt'] + + # Lock the profile channel + if not self._channel_lock(profile_id): + return None + + try: + # Get profile data shape and calculate size + shape = self._channel_shape(profile_id) + if len(shape) < 2: + return None + + profile_size = reduce(mul, shape) + if profile_size == 0: + return None + + record_count, record_size = shape[0], shape[1] + + # Read raw profile data using calculated size + data = self._channel_read(profile_id, 0, profile_size) + if len(data) == 0: + return None + + # Parse profile records + records = [] + record_format = f"<5I2Q{event_count}QI" + + for i in range(record_count): + offset = i * record_size + if offset + record_size > len(data): + break + + # Unpack the record + profile = struct.unpack(record_format, data[offset:offset + record_size]) + + # Parse the profile data + records.append({ + 'address': profile[0], + 'caller': profile[1], + 'call_count': profile[2], + 'min_ticks': profile[3], + 'max_ticks': profile[4], + 'total_ticks': profile[5], + 'total_cycles': profile[6], + 'events': profile[7:7 + event_count] + }) + + return records + finally: + self._channel_unlock(profile_id) + + @retry_if_failed + def read_stdout(self): + """Read text output buffer""" + stdout_id = self.get_channel(name="stdout") + if size := self._channel_size(stdout_id): + data = self._channel_read(stdout_id, 0, size) + return bytes(data).decode('utf-8', errors='ignore') + + @retry_if_failed + def read_frame(self): + """Read stream buffer data with header at the beginning and convert to RGB888""" + stream_id = self.get_channel(name="stream") + + # Lock the stream buffer + if not self._channel_lock(stream_id): + return None + + self.frame_event = False + try: + # Get total size (16-byte header + stream data) + if (size := self._channel_size(stream_id)) <= 16: + return None + + # Read all data (header + stream data) + data = self._channel_read(stream_id, 0, size) + if len(data) < 16: + return None + + # Parse stream header: width(4), height(4), pixformat(4), depth/size(4) + width, height, pixformat, depth = struct.unpack('> 16) & 0xFFFF + usb_pid = usb_id & 0xFFFF + + # Extract capability bitfield (always 2 words) + capabilities = data[9] # First hw_caps word + + return { + 'cpu_id': data[0], + 'device_id': data[1:4], # 3 words + 'sensor_chip_id': data[4:7], # 3 words + 'usb_vid': usb_vid, + 'usb_pid': usb_pid, + 'gpu_present': bool(capabilities & (1 << 0)), + 'npu_present': bool(capabilities & (1 << 1)), + 'isp_present': bool(capabilities & (1 << 2)), + 'venc_present': bool(capabilities & (1 << 3)), + 'jpeg_present': bool(capabilities & (1 << 4)), + 'dram_present': bool(capabilities & (1 << 5)), + 'crc_present': bool(capabilities & (1 << 6)), + 'pmu_present': bool(capabilities & (1 << 7)), + 'pmu_eventcnt': (capabilities >> 8) & 0xFF, # 8 bits starting at bit 8 + 'wifi_present': bool(capabilities & (1 << 16)), + 'bt_present': bool(capabilities & (1 << 17)), + 'sd_present': bool(capabilities & (1 << 18)), + 'eth_present': bool(capabilities & (1 << 19)), + 'usb_highspeed': bool(capabilities & (1 << 20)), + 'multicore_present': bool(capabilities & (1 << 21)), + 'flash_size_kb': data[11], + 'ram_size_kb': data[12], + 'framebuffer_size_kb': data[13], + 'stream_buffer_size_kb': data[14], + 'firmware_version': data[17], + 'protocol_version': data[18], + 'bootloader_version': data[19] + } + + def print_system_info(self): + """Print formatted system information""" + logging.info("=== OpenMV System Information ===") + + # Print registered channels + logging.info(f"CPU ID: 0x{self.sysinfo['cpu_id']:08X}") + + # Device ID is now an array of 3 words + dev_id_hex = ''.join(f"{word:08X}" for word in self.sysinfo['device_id']) + logging.info(f"Device ID: {dev_id_hex}") + + # Sensor Chip IDs are now an array of 3 words + for i, chip_id in enumerate(self.sysinfo['sensor_chip_id']): + if chip_id != 0: # Only show non-zero chip IDs + logging.info(f"CSI{i}: 0x{chip_id:08X}") + + # USB VID/PID + logging.info(f"USB ID: {self.sysinfo['usb_vid']:04X}:{self.sysinfo['usb_pid']:04X}") + + # Memory info + if self.sysinfo['flash_size_kb'] > 0: + logging.info(f"Flash: {self.sysinfo['flash_size_kb']} KB") + if self.sysinfo['ram_size_kb'] > 0: + logging.info(f"RAM: {self.sysinfo['ram_size_kb']} KB") + if self.sysinfo['framebuffer_size_kb'] > 0: + logging.info(f"Framebuffer: {self.sysinfo['framebuffer_size_kb']} KB") + if self.sysinfo['stream_buffer_size_kb'] > 0: + logging.info(f"Stream Buffer: {self.sysinfo['stream_buffer_size_kb']} KB") + + # Hardware capabilities + logging.info("Hardware capabilities:") + logging.info(f" GPU: {'Yes' if self.sysinfo['gpu_present'] else 'No'}") + logging.info(f" NPU: {'Yes' if self.sysinfo['npu_present'] else 'No'}") + logging.info(f" ISP: {'Yes' if self.sysinfo['isp_present'] else 'No'}") + logging.info(f" Video Encoder: {'Yes' if self.sysinfo['venc_present'] else 'No'}") + logging.info(f" JPEG Encoder: {'Yes' if self.sysinfo['jpeg_present'] else 'No'}") + logging.info(f" DRAM: {'Yes' if self.sysinfo['dram_present'] else 'No'}") + logging.info(f" CRC Hardware: {'Yes' if self.sysinfo['crc_present'] else 'No'}") + logging.info(f" PMU: {'Yes' if self.sysinfo['pmu_present'] else 'No'} " + f"({self.sysinfo['pmu_eventcnt']} counters)") + logging.info(f" Multi-core: {'Yes' if self.sysinfo['multicore_present'] else 'No'}") + logging.info(f" WiFi: {'Yes' if self.sysinfo['wifi_present'] else 'No'}") + logging.info(f" Bluetooth: {'Yes' if self.sysinfo['bt_present'] else 'No'}") + logging.info(f" SD Card: {'Yes' if self.sysinfo['sd_present'] else 'No'}") + logging.info(f" Ethernet: {'Yes' if self.sysinfo['eth_present'] else 'No'}") + logging.info(f" USB High-Speed: {'Yes' if self.sysinfo['usb_highspeed'] else 'No'}") + + # Profiler info - check if profile channel is available + profile_available = self.get_channel(name="profile") is not None + logging.info(f"Profiler: {'Available' if profile_available else 'Not available'}") + + # Version info + fw = self.sysinfo['firmware_version'] + proto = self.sysinfo['protocol_version'] + boot = self.sysinfo['bootloader_version'] + logging.info(f"Firmware version: {fw[0]}.{fw[1]}.{fw[2]}") + logging.info(f"Protocol version: {proto[0]}.{proto[1]}.{proto[2]}") + logging.info(f"Bootloader version: {boot[0]}.{boot[1]}.{boot[2]}") + logging.info(f"Protocol capabilities: CRC={self.caps['crc']}, SEQ={self.caps['seq']}, " + f"ACK={self.caps['ack']}, EVENTS={self.caps['events']}, " + f"PAYLOAD={self.caps['max_payload']}") + logging.info("=================================") diff --git a/src/openmv/cli.py b/src/openmv/cli.py new file mode 100644 index 0000000..eb246bb --- /dev/null +++ b/src/openmv/cli.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python +# SPDX-License-Identifier: MIT +# +# Copyright (c) 2025 OpenMV, LLC. + +# OpenMV CLI Tool +# Command-line interface for OpenMV cameras. Provides live video +# streaming, script execution, and camera management capabilities. + +import sys +import os +import argparse +import time +import logging +import pygame +import signal +import atexit + +from openmv.camera import Camera +from openmv.profiler import draw_profile_overlay + + +# Benchmark script for throughput testing +bench_script = """ +import csi, image, time + +csi0 = csi.CSI() +csi0.reset() +csi0.pixformat(csi.RGB565) +csi0.framesize(csi.QVGA) +img = csi0.snapshot().compress() +while(True): + img.flush() +""" + +# Default test script for csi-based cameras +test_script = """ +import csi, image, time + +csi0 = csi.CSI() +csi0.reset() +csi0.pixformat(csi.RGB565) +csi0.framesize(csi.QVGA) +clock = time.clock() + +while(True): + clock.tick() + img = csi0.snapshot() + print(clock.fps(), " FPS") +""" + + +def cleanup_and_exit(): + """Force cleanup pygame and exit""" + try: + pygame.quit() + except Exception: + pass + os._exit(0) + + +def signal_handler(signum, frame): + cleanup_and_exit() + + +def str2bool(v): + """Convert string to boolean for argparse""" + if isinstance(v, bool): + return v + if v.lower() in ('yes', 'true', 't', 'y', '1'): + return True + elif v.lower() in ('no', 'false', 'f', 'n', '0'): + return False + else: + raise argparse.ArgumentTypeError('Boolean value expected.') + + +def main(): + parser = argparse.ArgumentParser(description='OpenMV CLI Tool') + + parser.add_argument('--port', + action='store', default='/dev/ttyACM0', + help='Serial port (default: /dev/ttyACM0)') + + parser.add_argument("--script", + action="store", default=None, + help="Script file") + + parser.add_argument('--poll', action='store', + default=4, type=int, + help='Poll rate in ms (default: 4)') + + parser.add_argument('--scale', action='store', + default=4, type=int, + help='Display scaling factor (default: 4)') + + parser.add_argument('--bench', + action='store_true', default=False, + help='Run throughput benchmark') + parser.add_argument('--timeout', + action='store', type=float, default=1.0, + help='Protocol timeout in seconds') + + parser.add_argument('--debug', + action='store_true', + help='Enable debug logging') + + parser.add_argument('--baudrate', + type=int, default=921600, + help='Serial baudrate (default: 921600)') + + parser.add_argument('--crc', + type=str2bool, nargs='?', const=True, default=True, + help='Enable CRC validation (default: true)') + + parser.add_argument('--seq', + type=str2bool, nargs='?', const=True, default=True, + help='Enable sequence number validation (default: true)') + + parser.add_argument('--ack', + type=str2bool, nargs='?', const=True, default=True, + help='Enable packet acknowledgment (default: false)') + + parser.add_argument('--events', + type=str2bool, nargs='?', const=True, default=True, + help='Enable event notifications (default: true)') + + parser.add_argument('--max-retry', + type=int, default=3, + help='Maximum number of retries (default: 3)') + + parser.add_argument('--max-payload', + type=int, default=4096, + help='Maximum payload size in bytes (default: 4096)') + + parser.add_argument('--drop-rate', + type=float, default=0.0, + help='Packet drop simulation rate (0.0-1.0, default: 0.0)') + + parser.add_argument('--firmware', + action='store', default=None, + help='Firmware ELF file for symbol resolution') + + parser.add_argument('--quiet', + action='store_true', + help='Suppress script output text') + + args = parser.parse_args() + + # Register signal handlers for clean exit + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + atexit.register(cleanup_and_exit) + + # Configure logging + if args.debug: + log_level = logging.DEBUG + elif not args.quiet: + log_level = logging.INFO + else: + log_level = logging.ERROR + + logging.basicConfig( + format="%(relativeCreated)010.3f - %(message)s", + level=log_level, + ) + + # Load script + if args.script is not None: + with open(args.script, 'r') as f: + script = f.read() + logging.info(f"Loaded script from {args.script}") + else: + script = bench_script if args.bench else test_script + logging.info("Using built-in script") + + # Load profiler symbols if firmware provided + symbols = [] + if args.firmware: + from openmv.profiler import load_symbols + symbols = load_symbols(args.firmware) + + # Initialize pygame + pygame.init() + + screen = None + clock = pygame.time.Clock() + fps_clock = pygame.time.Clock() + font = pygame.font.SysFont("monospace", 30) + + if not args.bench: + pygame.display.set_caption("OpenMV Camera") + else: + pygame.display.set_caption("OpenMV Camera (Benchmark)") + screen = pygame.display.set_mode((640, 120), pygame.DOUBLEBUF, 32) + + # Profiler state + profile_view = 0 # Off + profile_mode = False # False = inclusive, True = exclusive + profile_enabled = False # Will be set if profile channel exists + profile_update_ms = 0 + profile_data = None + + try: + with Camera(args.port, baudrate=args.baudrate, crc=args.crc, seq=args.seq, + ack=args.ack, events=args.events, + timeout=args.timeout, max_retry=args.max_retry, + max_payload=args.max_payload, drop_rate=args.drop_rate) as camera: + logging.info(f"Connected to OpenMV camera on {args.port}") + + # Configure profiler (if enabled) + if profile_enabled := camera.has_channel("profile"): + logging.info("Profiler channel detected - profiling enabled") + camera.profiler_reset(config=None) + + # Stop any running script + camera.stop() + time.sleep(0.500) + + # Execute script + camera.exec(script) + camera.streaming(True, raw=False, res=(512, 512)) + logging.info("Script executed, starting display...") + + while True: + # Handle pygame events first to keep UI responsive + for event in pygame.event.get(): + if event.type == pygame.QUIT: + raise KeyboardInterrupt + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + raise KeyboardInterrupt + elif profile_enabled and event.key == pygame.K_p: + profile_view = (profile_view + 1) % 3 # Cycle views + logging.info(f"Profile view: {profile_view}") + elif profile_enabled and event.key == pygame.K_m: + profile_mode = not profile_mode + camera.profiler_mode(exclusive=profile_mode) + logging.info(f"Profile mode: {'Exclusive' if profile_mode else 'Inclusive'}") + elif profile_enabled and event.key == pygame.K_r: + camera.profiler_reset() + logging.info("Profiler reset") + + # Read camera status + status = camera.read_status() + + # Read text output + if not args.quiet and not args.bench and status and status.get('stdout'): + if text := camera.read_stdout(): + print(text, end='') + + # Read frame data + if frame := camera.read_frame(): + fps = fps_clock.get_fps() + w, h, data = frame['width'], frame['height'], frame['data'] + + # Create image from RGB888 data (always converted by camera module) + if not args.bench: + image = pygame.image.frombuffer(data, (w, h), 'RGB') + image = pygame.transform.smoothscale(image, (w * args.scale, h * args.scale)) + + # Create/resize screen if needed + if screen is None: + screen = pygame.display.set_mode((w * args.scale, h * args.scale), pygame.DOUBLEBUF, 32) + + # Draw frame + if args.bench: + screen.fill((0, 0, 0)) + else: + screen.blit(image, (0, 0)) + + # Draw FPS info with accurate data rate + current_mbps = (fps * frame['raw_size']) / 1024**2 + if current_mbps < 1.0: + rate_text = f"{current_mbps * 1024:.2f} KB/s" + else: + rate_text = f"{current_mbps:.2f} MB/s" + fps_text = f"{fps:.2f} FPS {rate_text} {w}x{h} RGB888" + screen.blit(font.render(fps_text, True, (255, 0, 0)), (0, 0)) + + fps_clock.tick() + + # Read profiler data if enabled (max 10Hz) + if profile_enabled and profile_view and screen is not None: + current_time = time.time() + if current_time - profile_update_ms >= 0.1: # 10Hz + if profile_data := camera.read_profile(): + profile_update_ms = current_time + + # Draw profiler overlay if enabled and data available + if profile_data is not None: + screen_width, screen_height = screen.get_size() + draw_profile_overlay(screen, screen_width, screen_height, + profile_data, profile_mode, profile_view, 1, symbols) + + # Update display once at the end + if frame: + pygame.display.flip() + + # Control main loop timing + clock.tick(1000 // args.poll) + + except KeyboardInterrupt: + logging.info("Interrupted by user") + except Exception as e: + logging.error(f"Error: {e}") + if args.debug: + import traceback + logging.error(f"{traceback.format_exc()}") + sys.exit(1) + finally: + pygame.quit() + + +if __name__ == '__main__': + main() diff --git a/src/openmv/constants.py b/src/openmv/constants.py new file mode 100644 index 0000000..346105e --- /dev/null +++ b/src/openmv/constants.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (c) 2025 OpenMV, LLC. +# +# OpenMV Protocol Constants +# +# This module defines all the constants used in the OpenMV Protocol +# including opcodes, status codes, flags, and other definitions. + +from enum import IntEnum + + +class Flags(IntEnum): + """OpenMV Protocol Packet Flags""" + ACK = (1 << 0) + NAK = (1 << 1) + RTX = (1 << 2) + ACK_REQ = (1 << 3) + FRAGMENT = (1 << 4) + EVENT = (1 << 5) + + +class State(IntEnum): + """OpenMV Protocol Parser State Machine States""" + SYNC = 0 + HEADER = 1 + PAYLOAD = 2 + + +class Protocol(IntEnum): + """OpenMV Protocol Constants""" + SYNC_WORD = 0xD5AA + HEADER_SIZE = 10 + CRC_SIZE = 4 + MIN_PAYLOAD_SIZE = 64 - 10 - 2 # 64 - OMV_PROTOCOL_HEADER_SIZE - 2 = 52 + + +class Status(IntEnum): + """OpenMV Protocol Status Codes""" + SUCCESS = 0x00 + FAILED = 0x01 + INVALID = 0x02 + TIMEOUT = 0x03 + BUSY = 0x04 + CHECKSUM = 0x05 + SEQUENCE = 0x06 + OVERFLOW = 0x07 + FRAGMENT = 0x08 + UNKNOWN = 0x09 + + +class Opcode(IntEnum): + """OpenMV Protocol Operation Codes""" + # Protocol commands + PROTO_SYNC = 0x00 + PROTO_GET_CAPS = 0x01 + PROTO_SET_CAPS = 0x02 + PROTO_STATS = 0x03 + + # System commands + SYS_RESET = 0x10 + SYS_BOOT = 0x11 + SYS_INFO = 0x12 + SYS_EVENT = 0x13 + + # Channel commands + CHANNEL_LIST = 0x20 + CHANNEL_POLL = 0x21 + CHANNEL_LOCK = 0x22 + CHANNEL_UNLOCK = 0x23 + CHANNEL_SHAPE = 0x24 + CHANNEL_SIZE = 0x25 + CHANNEL_READ = 0x26 + CHANNEL_WRITE = 0x27 + CHANNEL_IOCTL = 0x28 + CHANNEL_EVENT = 0x29 + + +class EventType(IntEnum): + """OpenMV Protocol Event Types""" + CHANNEL_REGISTERED = 0x00 + CHANNEL_UNREGISTERED = 0x01 + SOFT_REBOOT = 0x02 + + +class ChannelIOCTL(IntEnum): + """OpenMV Protocol Channel IOCTL Commands""" + # Stdin channel IOCTLs + STDIN_STOP = 0x01 # Stop running script + STDIN_EXEC = 0x02 # Execute script + STDIN_RESET = 0x03 # Reseet script buffer + + # Stream channel IOCTLs + STREAM_CTRL = 0x00 # Enable/disable streaming + STREAM_RAW_CTRL = 0x01 # Enable/disable raw streaming + STREAM_RAW_CFG = 0x02 # Set raw stream resolution + + # Profile channel IOCTLs + PROFILE_MODE = 0x00 # Set profiling mode + PROFILE_SET_EVENT = 0x01 # Set event type to profile + PROFILE_RESET = 0x02 # Reset profiler data diff --git a/src/openmv/crc.py b/src/openmv/crc.py new file mode 100644 index 0000000..b588062 --- /dev/null +++ b/src/openmv/crc.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (c) 2025 OpenMV, LLC. +# +# CRC implementation for OpenMV Protocol +# +# This module provides CRC-16 and CRC-32 calculation using polynomials 0xF94F and 0xFA567D89. + +# CRC-16 lookup table for polynomial 0xF94F +_CRC16_TABLE = [ + 0x0000, 0xF94F, 0x0BD1, 0xF29E, 0x17A2, 0xEEED, 0x1C73, 0xE53C, + 0x2F44, 0xD60B, 0x2495, 0xDDDA, 0x38E6, 0xC1A9, 0x3337, 0xCA78, + 0x5E88, 0xA7C7, 0x5559, 0xAC16, 0x492A, 0xB065, 0x42FB, 0xBBB4, + 0x71CC, 0x8883, 0x7A1D, 0x8352, 0x666E, 0x9F21, 0x6DBF, 0x94F0, + 0xBD10, 0x445F, 0xB6C1, 0x4F8E, 0xAAB2, 0x53FD, 0xA163, 0x582C, + 0x9254, 0x6B1B, 0x9985, 0x60CA, 0x85F6, 0x7CB9, 0x8E27, 0x7768, + 0xE398, 0x1AD7, 0xE849, 0x1106, 0xF43A, 0x0D75, 0xFFEB, 0x06A4, + 0xCCDC, 0x3593, 0xC70D, 0x3E42, 0xDB7E, 0x2231, 0xD0AF, 0x29E0, + 0x836F, 0x7A20, 0x88BE, 0x71F1, 0x94CD, 0x6D82, 0x9F1C, 0x6653, + 0xAC2B, 0x5564, 0xA7FA, 0x5EB5, 0xBB89, 0x42C6, 0xB058, 0x4917, + 0xDDE7, 0x24A8, 0xD636, 0x2F79, 0xCA45, 0x330A, 0xC194, 0x38DB, + 0xF2A3, 0x0BEC, 0xF972, 0x003D, 0xE501, 0x1C4E, 0xEED0, 0x179F, + 0x3E7F, 0xC730, 0x35AE, 0xCCE1, 0x29DD, 0xD092, 0x220C, 0xDB43, + 0x113B, 0xE874, 0x1AEA, 0xE3A5, 0x0699, 0xFFD6, 0x0D48, 0xF407, + 0x60F7, 0x99B8, 0x6B26, 0x9269, 0x7755, 0x8E1A, 0x7C84, 0x85CB, + 0x4FB3, 0xB6FC, 0x4462, 0xBD2D, 0x5811, 0xA15E, 0x53C0, 0xAA8F, + 0xFF91, 0x06DE, 0xF440, 0x0D0F, 0xE833, 0x117C, 0xE3E2, 0x1AAD, + 0xD0D5, 0x299A, 0xDB04, 0x224B, 0xC777, 0x3E38, 0xCCA6, 0x35E9, + 0xA119, 0x5856, 0xAAC8, 0x5387, 0xB6BB, 0x4FF4, 0xBD6A, 0x4425, + 0x8E5D, 0x7712, 0x858C, 0x7CC3, 0x99FF, 0x60B0, 0x922E, 0x6B61, + 0x4281, 0xBBCE, 0x4950, 0xB01F, 0x5523, 0xAC6C, 0x5EF2, 0xA7BD, + 0x6DC5, 0x948A, 0x6614, 0x9F5B, 0x7A67, 0x8328, 0x71B6, 0x88F9, + 0x1C09, 0xE546, 0x17D8, 0xEE97, 0x0BAB, 0xF2E4, 0x007A, 0xF935, + 0x334D, 0xCA02, 0x389C, 0xC1D3, 0x24EF, 0xDDA0, 0x2F3E, 0xD671, + 0x7CFE, 0x85B1, 0x772F, 0x8E60, 0x6B5C, 0x9213, 0x608D, 0x99C2, + 0x53BA, 0xAAF5, 0x586B, 0xA124, 0x4418, 0xBD57, 0x4FC9, 0xB686, + 0x2276, 0xDB39, 0x29A7, 0xD0E8, 0x35D4, 0xCC9B, 0x3E05, 0xC74A, + 0x0D32, 0xF47D, 0x06E3, 0xFFAC, 0x1A90, 0xE3DF, 0x1141, 0xE80E, + 0xC1EE, 0x38A1, 0xCA3F, 0x3370, 0xD64C, 0x2F03, 0xDD9D, 0x24D2, + 0xEEAA, 0x17E5, 0xE57B, 0x1C34, 0xF908, 0x0047, 0xF2D9, 0x0B96, + 0x9F66, 0x6629, 0x94B7, 0x6DF8, 0x88C4, 0x718B, 0x8315, 0x7A5A, + 0xB022, 0x496D, 0xBBF3, 0x42BC, 0xA780, 0x5ECF, 0xAC51, 0x551E, +] + +# CRC32 lookup table for polynomial 0xFA567D89 +_CRC32_TABLE = [ + 0x00000000, 0xFA567D89, 0x0EFA869B, 0xF4ACFB12, 0x1DF50D36, 0xE7A370BF, 0x130F8BAD, 0xE959F624, + 0x3BEA1A6C, 0xC1BC67E5, 0x35109CF7, 0xCF46E17E, 0x261F175A, 0xDC496AD3, 0x28E591C1, 0xD2B3EC48, + 0x77D434D8, 0x8D824951, 0x792EB243, 0x8378CFCA, 0x6A2139EE, 0x90774467, 0x64DBBF75, 0x9E8DC2FC, + 0x4C3E2EB4, 0xB668533D, 0x42C4A82F, 0xB892D5A6, 0x51CB2382, 0xAB9D5E0B, 0x5F31A519, 0xA567D890, + 0xEFA869B0, 0x15FE1439, 0xE152EF2B, 0x1B0492A2, 0xF25D6486, 0x080B190F, 0xFCA7E21D, 0x06F19F94, + 0xD44273DC, 0x2E140E55, 0xDAB8F547, 0x20EE88CE, 0xC9B77EEA, 0x33E10363, 0xC74DF871, 0x3D1B85F8, + 0x987C5D68, 0x622A20E1, 0x9686DBF3, 0x6CD0A67A, 0x8589505E, 0x7FDF2DD7, 0x8B73D6C5, 0x7125AB4C, + 0xA3964704, 0x59C03A8D, 0xAD6CC19F, 0x573ABC16, 0xBE634A32, 0x443537BB, 0xB099CCA9, 0x4ACFB120, + 0x2506AEE9, 0xDF50D360, 0x2BFC2872, 0xD1AA55FB, 0x38F3A3DF, 0xC2A5DE56, 0x36092544, 0xCC5F58CD, + 0x1EECB485, 0xE4BAC90C, 0x1016321E, 0xEA404F97, 0x0319B9B3, 0xF94FC43A, 0x0DE33F28, 0xF7B542A1, + 0x52D29A31, 0xA884E7B8, 0x5C281CAA, 0xA67E6123, 0x4F279707, 0xB571EA8E, 0x41DD119C, 0xBB8B6C15, + 0x6938805D, 0x936EFDD4, 0x67C206C6, 0x9D947B4F, 0x74CD8D6B, 0x8E9BF0E2, 0x7A370BF0, 0x80617679, + 0xCAAEC759, 0x30F8BAD0, 0xC45441C2, 0x3E023C4B, 0xD75BCA6F, 0x2D0DB7E6, 0xD9A14CF4, 0x23F7317D, + 0xF144DD35, 0x0B12A0BC, 0xFFBE5BAE, 0x05E82627, 0xECB1D003, 0x16E7AD8A, 0xE24B5698, 0x181D2B11, + 0xBD7AF381, 0x472C8E08, 0xB380751A, 0x49D60893, 0xA08FFEB7, 0x5AD9833E, 0xAE75782C, 0x542305A5, + 0x8690E9ED, 0x7CC69464, 0x886A6F76, 0x723C12FF, 0x9B65E4DB, 0x61339952, 0x959F6240, 0x6FC91FC9, + 0x4A0D5DD2, 0xB05B205B, 0x44F7DB49, 0xBEA1A6C0, 0x57F850E4, 0xADAE2D6D, 0x5902D67F, 0xA354ABF6, + 0x71E747BE, 0x8BB13A37, 0x7F1DC125, 0x854BBCAC, 0x6C124A88, 0x96443701, 0x62E8CC13, 0x98BEB19A, + 0x3DD9690A, 0xC78F1483, 0x3323EF91, 0xC9759218, 0x202C643C, 0xDA7A19B5, 0x2ED6E2A7, 0xD4809F2E, + 0x06337366, 0xFC650EEF, 0x08C9F5FD, 0xF29F8874, 0x1BC67E50, 0xE19003D9, 0x153CF8CB, 0xEF6A8542, + 0xA5A53462, 0x5FF349EB, 0xAB5FB2F9, 0x5109CF70, 0xB8503954, 0x420644DD, 0xB6AABFCF, 0x4CFCC246, + 0x9E4F2E0E, 0x64195387, 0x90B5A895, 0x6AE3D51C, 0x83BA2338, 0x79EC5EB1, 0x8D40A5A3, 0x7716D82A, + 0xD27100BA, 0x28277D33, 0xDC8B8621, 0x26DDFBA8, 0xCF840D8C, 0x35D27005, 0xC17E8B17, 0x3B28F69E, + 0xE99B1AD6, 0x13CD675F, 0xE7619C4D, 0x1D37E1C4, 0xF46E17E0, 0x0E386A69, 0xFA94917B, 0x00C2ECF2, + 0x6F0BF33B, 0x955D8EB2, 0x61F175A0, 0x9BA70829, 0x72FEFE0D, 0x88A88384, 0x7C047896, 0x8652051F, + 0x54E1E957, 0xAEB794DE, 0x5A1B6FCC, 0xA04D1245, 0x4914E461, 0xB34299E8, 0x47EE62FA, 0xBDB81F73, + 0x18DFC7E3, 0xE289BA6A, 0x16254178, 0xEC733CF1, 0x052ACAD5, 0xFF7CB75C, 0x0BD04C4E, 0xF18631C7, + 0x2335DD8F, 0xD963A006, 0x2DCF5B14, 0xD799269D, 0x3EC0D0B9, 0xC496AD30, 0x303A5622, 0xCA6C2BAB, + 0x80A39A8B, 0x7AF5E702, 0x8E591C10, 0x740F6199, 0x9D5697BD, 0x6700EA34, 0x93AC1126, 0x69FA6CAF, + 0xBB4980E7, 0x411FFD6E, 0xB5B3067C, 0x4FE57BF5, 0xA6BC8DD1, 0x5CEAF058, 0xA8460B4A, 0x521076C3, + 0xF777AE53, 0x0D21D3DA, 0xF98D28C8, 0x03DB5541, 0xEA82A365, 0x10D4DEEC, 0xE47825FE, 0x1E2E5877, + 0xCC9DB43F, 0x36CBC9B6, 0xC26732A4, 0x38314F2D, 0xD168B909, 0x2B3EC480, 0xDF923F92, 0x25C4421B, +] + + +def crc16(data, init_value=0xFFFF): + """ + Calculate CRC-16 with polynomial 0xF94F using lookup table. + + Args: + data: bytes-like object to calculate CRC for + init_value: initial CRC value (default: 0xFFFF) + + Returns: + 16-bit CRC value as integer + """ + crc = init_value + for byte in data: + index = (crc >> 8) ^ byte + crc = (crc << 8) ^ _CRC16_TABLE[index] + crc &= 0xFFFF # Keep it 16-bit + + return crc + + +def crc32(data, init_value=0xFFFFFFFF): + """ + Calculate CRC-32 with polynomial 0xFA567D89 using lookup table. + + Args: + data: bytes-like object to calculate CRC for + init_value: initial CRC value (default: 0xFFFFFFFF) + + Returns: + 32-bit CRC value as integer + """ + crc = init_value + for byte in data: + index = (crc >> 24) ^ byte + crc = (crc << 8) ^ _CRC32_TABLE[index] + crc &= 0xFFFFFFFF # Keep it 32-bit + + return crc diff --git a/src/openmv/exceptions.py b/src/openmv/exceptions.py new file mode 100644 index 0000000..27a4002 --- /dev/null +++ b/src/openmv/exceptions.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (c) 2025 OpenMV, LLC. +# +# OpenMV Protocol Exceptions +# +# This module defines all the custom exceptions used in the OpenMV +# Protocol implementation for proper error handling and debugging. + +import traceback + + +class OMVException(Exception): + """Base exception for OpenMV protocol errors""" + def __init__(self, message): + super().__init__(message) + self.traceback = traceback.format_exc() + + +class TimeoutException(OMVException): + """Raised when a protocol operation times out""" + def __init__(self, message): + super().__init__(message) + + +class ChecksumException(OMVException): + """Raised when CRC validation fails""" + def __init__(self, message): + super().__init__(message) + + +class SequenceException(OMVException): + """Raised when sequence number validation fails""" + def __init__(self, message): + super().__init__(message) + + +class ResyncException(OMVException): + """Raised to indicate that a resync was performed and operation should be retried""" + def __init__(self, message="Resync performed, retry operation"): + super().__init__(message) diff --git a/src/openmv/image.py b/src/openmv/image.py new file mode 100644 index 0000000..0700a52 --- /dev/null +++ b/src/openmv/image.py @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (c) 2025 OpenMV, LLC. +# +# OpenMV Image Utilities +# +# This module provides image format conversion utilities for OpenMV camera data. +# Handles conversion from various pixel formats to RGB888 for display purposes. + +import logging +try: + import numpy as np +except ImportError: + np = None +try: + from PIL import Image +except ImportError: + Image = None + + +# Pixel format constants +PIXFORMAT_GRAYSCALE = 0x08020001 # 1 byte per pixel +PIXFORMAT_RGB565 = 0x0C030002 # 2 bytes per pixel +PIXFORMAT_JPEG = 0x06060000 # Variable size JPEG + + +def convert_to_rgb888(raw_data, width, height, pixformat): + """ + Convert various pixel formats to RGB888. + + Args: + raw_data (bytes): Raw image data + width (int): Image width + height (int): Image height + pixformat (int): Pixel format identifier + + Returns: + tuple: (rgb_data, format_string) where rgb_data is bytes or None on error + """ + + if pixformat == PIXFORMAT_GRAYSCALE: + return _convert_grayscale(raw_data, width, height) + elif pixformat == PIXFORMAT_RGB565: + return _convert_rgb565(raw_data, width, height) + elif pixformat == PIXFORMAT_JPEG: + return _convert_jpeg(raw_data, width, height) + else: + # Unknown format - return raw data and let caller handle it + fmt_str = f"0x{pixformat:08X}" + logging.warning(f"Unknown pixel format: {fmt_str}") + return raw_data, fmt_str + + +def _convert_grayscale(raw_data, width, height): + """Convert grayscale to RGB888""" + fmt_str = "GRAY" + + if np is None: + logging.error("numpy required for grayscale conversion") + return None, fmt_str + + # Convert grayscale to RGB by duplicating the gray value + gray_array = np.frombuffer(raw_data, dtype=np.uint8) + if len(gray_array) != width * height: + logging.error(f"Grayscale data size mismatch: expected {width * height}, got {len(gray_array)}") + return None, fmt_str + + rgb_array = np.column_stack((gray_array, gray_array, gray_array)) + return rgb_array.tobytes(), fmt_str + + +def _convert_rgb565(raw_data, width, height): + """Convert RGB565 to RGB888""" + fmt_str = "RGB565" + + if np is None: + logging.error("numpy required for RGB565 conversion") + return None, fmt_str + + # Convert RGB565 to RGB888 + rgb565_array = np.frombuffer(raw_data, dtype=np.uint16) + if len(rgb565_array) != width * height: + logging.error(f"RGB565 data size mismatch: expected {width * height}, got {len(rgb565_array)}") + return None, fmt_str + + # Extract RGB components from 16-bit RGB565 + r = (((rgb565_array & 0xF800) >> 11) * 255.0 / 31.0).astype(np.uint8) + g = (((rgb565_array & 0x07E0) >> 5) * 255.0 / 63.0).astype(np.uint8) + b = (((rgb565_array & 0x001F) >> 0) * 255.0 / 31.0).astype(np.uint8) + + rgb_array = np.column_stack((r, g, b)) + return rgb_array.tobytes(), fmt_str + + +def _convert_jpeg(raw_data, width, height): + """Convert JPEG to RGB888""" + fmt_str = "JPEG" + + if Image is None: + logging.error("PIL/Pillow required for JPEG conversion") + return None, fmt_str + + try: + # Decode JPEG to RGB + image = Image.frombuffer("RGB", (width, height), raw_data, "jpeg", "RGB", "") + rgb_array = np.asarray(image) if np else None + + if rgb_array is not None: + if rgb_array.size != (width * height * 3): + logging.error(f"JPEG decode size mismatch: expected {width * height * 3}, got {rgb_array.size}") + return None, fmt_str + return rgb_array.tobytes(), fmt_str + else: + # Fallback without numpy + return image.tobytes(), fmt_str + + except Exception as e: + logging.error(f"JPEG decode error: {e}") + return None, fmt_str + + +def get_format_string(pixformat): + """Get a human-readable format string from pixel format code""" + format_map = { + PIXFORMAT_GRAYSCALE: "GRAY", + PIXFORMAT_RGB565: "RGB565", + PIXFORMAT_JPEG: "JPEG" + } + return format_map.get(pixformat, f"0x{pixformat:08X}") diff --git a/src/openmv/profiler.py b/src/openmv/profiler.py new file mode 100644 index 0000000..ccb3f19 --- /dev/null +++ b/src/openmv/profiler.py @@ -0,0 +1,427 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (c) 2025 OpenMV, LLC. +# +# OpenMV Profiler Visualization +# +# This module provides profiler visualization functions for OpenMV cameras. +# It handles symbol loading, colorization, and rendering of profiler data. + +import logging +import pygame + + +def load_symbols(firmware_path): + """Load symbols from an ELF firmware file. + + Args: + firmware_path: Path to ELF file + + Returns: + List of tuples (start_addr, end_addr, name) sorted by address, + or empty list if loading fails + """ + try: + from elftools.elf.elffile import ELFFile + except ImportError: + logging.error("elftools package not installed. Install with: pip install pyelftools") + return [] + + symbols = [] + try: + with open(firmware_path, 'rb') as f: + elf = ELFFile(f) + symtab = elf.get_section_by_name('.symtab') + if not symtab: + logging.warning("No symbol table found in ELF file") + else: + for sym in symtab.iter_symbols(): + addr = sym['st_value'] + size = sym['st_size'] + name = sym.name + if name and size > 0: # ignore empty symbols + symbols.append((addr, addr + size, name)) + symbols.sort() + logging.info(f"Loaded {len(symbols)} symbols from {firmware_path}") + except Exception as e: + logging.error(f"Failed to load symbols from {firmware_path}: {e}") + + return symbols + + +def addr_to_symbol(symbols, address): + """Binary search for symbol name by address. + + Args: + symbols: List of (start, end, name) tuples sorted by start address + address: Address to look up + + Returns: + Symbol name or None if not found + """ + lo, hi = 0, len(symbols) - 1 + while lo <= hi: + mid = (lo + hi) // 2 + start, end, name = symbols[mid] + if start <= address < end: + return name + elif address < start: + hi = mid - 1 + else: + lo = mid + 1 + return None + + +def get_color_by_percentage(percentage, base_color=(220, 220, 220)): + """Return a color based on percentage with fine-grained intensity levels. + + Args: + percentage: Value from 0-100 + base_color: RGB tuple for 0% (default grey) + + Returns: + RGB tuple with gradient from green (low) to red (high) + """ + def clamp(value): + return max(0, min(255, int(value))) + + if percentage >= 50: + # Very high - bright red + intensity = min(1.0, (percentage - 50) / 50) + return (255, clamp(120 - 120 * intensity), clamp(120 - 120 * intensity)) + elif percentage >= 30: + # High - red-orange + intensity = (percentage - 30) / 20 + return (255, clamp(160 + 40 * intensity), clamp(160 - 40 * intensity)) + elif percentage >= 20: + # Medium-high - orange + intensity = (percentage - 20) / 10 + return (255, clamp(200 + 55 * intensity), clamp(180 - 20 * intensity)) + elif percentage >= 15: + # Medium - yellow-orange + intensity = (percentage - 15) / 5 + return (255, clamp(220 + 35 * intensity), clamp(180 + 20 * intensity)) + elif percentage >= 10: + # Medium-low - yellow + intensity = (percentage - 10) / 5 + return (clamp(255 - 75 * intensity), 255, clamp(180 + 75 * intensity)) + elif percentage >= 5: + # Low - light green + intensity = (percentage - 5) / 5 + return (clamp(180 + 75 * intensity), 255, clamp(180 + 75 * intensity)) + elif percentage >= 2: + # Very low - green + intensity = (percentage - 2) / 3 + return (clamp(160 + 95 * intensity), clamp(255 - 55 * intensity), clamp(160 + 95 * intensity)) + elif percentage >= 1: + # Minimal - light blue-green + intensity = (percentage - 1) / 1 + return (clamp(140 + 120 * intensity), clamp(200 + 55 * intensity), clamp(255 - 95 * intensity)) + else: + # Zero or negligible - base color + return base_color + + +def draw_rounded_rect(surface, color, rect, radius=5): + """Draw a rounded rectangle on a pygame surface.""" + x, y, w, h = rect + if w <= 0 or h <= 0: + return + + pygame.draw.rect(surface, color, (x + radius, y, w - 2 * radius, h)) + pygame.draw.rect(surface, color, (x, y + radius, w, h - 2 * radius)) + pygame.draw.circle(surface, color, (x + radius, y + radius), radius) + pygame.draw.circle(surface, color, (x + w - radius, y + radius), radius) + pygame.draw.circle(surface, color, (x + radius, y + h - radius), radius) + pygame.draw.circle(surface, color, (x + w - radius, y + h - radius), radius) + + +def draw_table(overlay_surface, config, title, headers, col_widths): + """Draw the common table background, title, and header.""" + # Draw main table background + table_rect = (0, 0, config['width'], config['height']) + draw_rounded_rect(overlay_surface, config['colors']['bg'], table_rect, int(8 * config['scale_factor'])) + pygame.draw.rect(overlay_surface, config['colors']['border'], table_rect, max(1, int(2 * config['scale_factor']))) + + # Table title + title_text = config['fonts']['title'].render(title, True, config['colors']['header_text']) + title_rect = title_text.get_rect() + title_x = (config['width'] - title_rect.width) // 2 + overlay_surface.blit(title_text, (title_x, int(12 * config['scale_factor']))) + + # Header + header_y = int(50 * config['scale_factor']) + header_height = int(40 * config['scale_factor']) + + # Draw header background + header_rect = (int(5 * config['scale_factor']), header_y, + config['width'] - int(10 * config['scale_factor']), header_height) + draw_rounded_rect(overlay_surface, config['colors']['header_bg'], header_rect, int(4 * config['scale_factor'])) + + # Draw header text and separators + current_x = int(10 * config['scale_factor']) + for i, (header, width) in enumerate(zip(headers, col_widths)): + header_surface = config['fonts']['header'].render(header, True, config['colors']['header_text']) + overlay_surface.blit(header_surface, (current_x, header_y + int(6 * config['scale_factor']))) + + if i < len(headers) - 1: + sep_x = current_x + width - int(5 * config['scale_factor']) + pygame.draw.line(overlay_surface, config['colors']['border'], + (sep_x, header_y + 2), (sep_x, header_y + header_height - 2), 1) + current_x += width + + +def draw_event_table(overlay_surface, config, profile_data, profile_mode, symbols): + """Draw the event counter mode table.""" + # Prepare data + num_events = len(profile_data[0]['events']) if profile_data else 0 + if not num_events: + sorted_data = sorted(profile_data, key=lambda x: x['address']) + else: + sort_func = lambda x: x['events'][0] // max(1, x['call_count']) # noqa + sorted_data = sorted(profile_data, key=sort_func, reverse=True) + + headers = ["Function"] + [f"E{i}" for i in range(num_events)] + proportions = [0.30] + [0.70 / num_events] * num_events + col_widths = [config['width'] * prop for prop in proportions] + profile_mode_str = "Exclusive" if profile_mode else "Inclusive" + + # Calculate event totals for percentage calculation + event_totals = [0] * num_events + for record in sorted_data: + for i, event_count in enumerate(record['events']): + event_totals[i] += event_count // max(1, record['call_count']) + + # Draw table structure + draw_table(overlay_surface, config, f"Event Counters ({profile_mode_str})", headers, col_widths) + + # Draw data rows + row_height = int(30 * config['scale_factor']) + data_start_y = int(50 * config['scale_factor'] + 40 * config['scale_factor'] + 8 * config['scale_factor']) + available_height = config['height'] - data_start_y - int(60 * config['scale_factor']) + visible_rows = min(len(sorted_data), available_height // row_height) + + for i in range(visible_rows): + record = sorted_data[i] + row_y = data_start_y + i * row_height + + # Draw row background + row_color = config['colors']['row_alt'] if i % 2 == 0 else config['colors']['row_normal'] + row_rect = (int(5 * config['scale_factor']), row_y, + config['width'] - int(10 * config['scale_factor']), row_height) + pygame.draw.rect(overlay_surface, row_color, row_rect) + + # Function name + name = addr_to_symbol(symbols, record['address']) if symbols else "" + max_name_chars = int(col_widths[0] // (11 * config['scale_factor'])) + display_name = name if len(name) <= max_name_chars else name[:max_name_chars - 3] + "..." + + row_data = [display_name] + + # Event data + for j, event_count in enumerate(record['events']): + event_scale = "" + event_count //= max(1, record['call_count']) + if event_count > 1_000_000_000: + event_count //= 1_000_000_000 + event_scale = "B" + elif event_count > 1_000_000: + event_count //= 1_000_000 + event_scale = "M" + row_data.append(f"{event_count:,}{event_scale}") + + # Determine row color based on sorting key (event 0) + if len(record['events']) > 0 and event_totals[0] > 0: + sort_key_value = record['events'][0] // max(1, record['call_count']) + percentage = (sort_key_value / event_totals[0] * 100) + row_text_color = get_color_by_percentage(percentage, config['colors']['content_text']) + else: + row_text_color = config['colors']['content_text'] + + # Draw row data with uniform color + current_x = 10 + for j, (data, width) in enumerate(zip(row_data, col_widths)): + text_surface = config['fonts']['content'].render(str(data), True, row_text_color) + overlay_surface.blit(text_surface, (current_x, row_y + int(8 * config['scale_factor']))) + + if j < len(row_data) - 1: + sep_x = current_x + width - 8 + pygame.draw.line(overlay_surface, (60, 70, 85), + (sep_x, row_y), (sep_x, row_y + row_height), 1) + current_x += width + + # Draw summary + summary_y = config['height'] - int(50 * config['scale_factor']) + total_functions = len(profile_data) + grand_total = sum(event_totals) + summary_text = ( + f"Profiles: {total_functions} | " + f"Events: {num_events} | " + f"Total Events: {grand_total:,}" + ) + + summary_surface = config['fonts']['summary'].render(summary_text, True, config['colors']['content_text']) + summary_rect = summary_surface.get_rect() + summary_x = (config['width'] - summary_rect.width) // 2 + overlay_surface.blit(summary_surface, (summary_x, summary_y)) + + # Instructions + instruction_str = "Press 'P' to toggle profiler overlay" + instruction_text = config['fonts']['instruction'].render(instruction_str, True, (180, 180, 180)) + overlay_surface.blit(instruction_text, (0, summary_y + int(20 * config['scale_factor']))) + + +def draw_profile_table(overlay_surface, config, profile_data, profile_mode, symbols): + """Draw the profile mode table.""" + # Prepare data + sort_func = lambda x: x['total_ticks'] # noqa + sorted_data = sorted(profile_data, key=sort_func, reverse=True) + total_ticks_all = sum(record['total_ticks'] for record in profile_data) + profile_mode_str = "Exclusive" if profile_mode else "Inclusive" + + headers = ["Function", "Calls", "Min", "Max", "Total", "Avg", "Cycles", "%"] + proportions = [0.30, 0.08, 0.10, 0.10, 0.13, 0.10, 0.13, 0.05] + col_widths = [config['width'] * prop for prop in proportions] + + # Draw table structure + draw_table(overlay_surface, config, f"Performance Profile ({profile_mode_str})", headers, col_widths) + + # Draw data rows + row_height = int(30 * config['scale_factor']) + data_start_y = int(50 * config['scale_factor'] + 40 * config['scale_factor'] + 8 * config['scale_factor']) + available_height = config['height'] - data_start_y - int(60 * config['scale_factor']) + visible_rows = min(len(sorted_data), available_height // row_height) + + for i in range(visible_rows): + record = sorted_data[i] + row_y = data_start_y + i * row_height + + # Draw row background + row_color = config['colors']['row_alt'] if i % 2 == 0 else config['colors']['row_normal'] + row_rect = (int(5 * config['scale_factor']), row_y, + config['width'] - int(10 * config['scale_factor']), row_height) + pygame.draw.rect(overlay_surface, row_color, row_rect) + + # Function name + name = addr_to_symbol(symbols, record['address']) if symbols else "" + max_name_chars = int(col_widths[0] // (11 * config['scale_factor'])) + display_name = name if len(name) <= max_name_chars else name[:max_name_chars - 3] + "..." + + # Calculate values + call_count = record['call_count'] + min_ticks = record['min_ticks'] if call_count else 0 + max_ticks = record['max_ticks'] if call_count else 0 + total_ticks = record['total_ticks'] + avg_cycles = record['total_cycles'] // max(1, call_count) + avg_ticks = total_ticks // max(1, call_count) + percentage = (total_ticks / total_ticks_all * 100) if total_ticks_all else 0 + + ticks_scale = "" + if total_ticks > 1_000_000_000: + total_ticks //= 1_000_000 + ticks_scale = "M" + + row_data = [ + display_name, + f"{call_count:,}", + f"{min_ticks:,}", + f"{max_ticks:,}", + f"{total_ticks:,}{ticks_scale}", + f"{avg_ticks:,}", + f"{avg_cycles:,}", + f"{percentage:.1f}%" + ] + + # Determine row color based on percentage + text_color = get_color_by_percentage(percentage, config['colors']['content_text']) + + # Draw row data + current_x = int(10 * config['scale_factor']) + for j, (data, width) in enumerate(zip(row_data, col_widths)): + text_surface = config['fonts']['content'].render(str(data), True, text_color) + overlay_surface.blit(text_surface, (current_x, row_y + int(8 * config['scale_factor']))) + + if j < len(row_data) - 1: + sep_x = current_x + width - int(8 * config['scale_factor']) + pygame.draw.line(overlay_surface, (60, 70, 85), + (sep_x, row_y), (sep_x, row_y + row_height), 1) + current_x += width + + # Draw summary + summary_y = config['height'] - int(50 * config['scale_factor']) + total_calls = sum(record['call_count'] for record in profile_data) + total_cycles = sum(record['total_cycles'] for record in profile_data) + total_ticks_summary = sum(record['total_ticks'] for record in profile_data) + + summary_text = ( + f"Profiles: {len(profile_data)} | " + f"Total Calls: {total_calls:,} | " + f"Total Ticks: {total_ticks_summary:,} | " + f"Total Cycles: {total_cycles:,}" + ) + + summary_surface = config['fonts']['summary'].render(summary_text, True, config['colors']['content_text']) + summary_rect = summary_surface.get_rect() + summary_x = (config['width'] - summary_rect.width) // 2 + overlay_surface.blit(summary_surface, (summary_x, summary_y)) + + # Instructions + instruction_str = "Press 'P' to toggle profiler overlay" + instruction_text = config['fonts']['instruction'].render(instruction_str, True, (180, 180, 180)) + overlay_surface.blit(instruction_text, (0, summary_y + int(20 * config['scale_factor']))) + + +def draw_profile_overlay(screen, screen_width, screen_height, profile_data, + profile_mode, profile_view, scale, symbols, alpha=250): + """Main entry point for drawing the profile overlay. + + Args: + screen: pygame surface to draw on + screen_width: Screen width in pixels + screen_height: Screen height in pixels + profile_data: List of profile records from camera + profile_mode: Boolean, True=exclusive, False=inclusive + profile_view: 1=performance, 2=events + scale: Display scale factor + symbols: List of (start, end, name) symbol tuples or empty list + alpha: Transparency (0-255) + """ + # Calculate dimensions and create surface + base_width, base_height = 800, 800 + screen_width *= scale + screen_height *= scale + scale_factor = min(screen_width / base_width, screen_height / base_height) + + overlay_surface = pygame.Surface((screen_width, screen_height), pygame.SRCALPHA) + overlay_surface.set_alpha(alpha) + + # Setup common configuration + config = { + 'width': screen_width, + 'height': screen_height, + 'scale_factor': scale_factor, + 'colors': { + 'bg': (40, 50, 65), + 'border': (70, 80, 100), + 'header_bg': (60, 80, 120), + 'header_text': (255, 255, 255), + 'content_text': (220, 220, 220), + 'row_alt': (35, 45, 60), + 'row_normal': (45, 55, 70) + }, + 'fonts': { + 'title': pygame.font.SysFont("arial", int(28 * scale_factor), bold=True), + 'header': pygame.font.SysFont("monospace", int(20 * scale_factor), bold=True), + 'content': pygame.font.SysFont("monospace", int(18 * scale_factor)), + 'summary': pygame.font.SysFont("arial", int(20 * scale_factor)), + 'instruction': pygame.font.SysFont("arial", int(22 * scale_factor)) + } + } + + # Draw based on mode + if profile_view == 1: + draw_profile_table(overlay_surface, config, profile_data, profile_mode, symbols) + elif profile_view == 2: + draw_event_table(overlay_surface, config, profile_data, profile_mode, symbols) + + screen.blit(overlay_surface, (0, 0)) diff --git a/src/openmv/transport.py b/src/openmv/transport.py new file mode 100644 index 0000000..91e5a7e --- /dev/null +++ b/src/openmv/transport.py @@ -0,0 +1,327 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (c) 2025 OpenMV, LLC. +# +# OpenMV Protocol Transport Layer +# +# This module provides the low-level transport layer with the protocol +# state machine for packet parsing and communication management. + +import time +import logging +import struct +import random +from .constants import * +from .exceptions import * +from .crc import crc16, crc32 +from .buffer import RingBuffer + +# Precompiled struct objects for efficiency +_crc16_struct = struct.Struct(' self.max_payload: + raise OMVException(f"Payload too large: {length} > {self.max_payload}") + + # Pack header without CRC first (10 bytes) + struct.pack_into(' 0: + self.pbuf[Protocol.HEADER_SIZE:Protocol.HEADER_SIZE + length] = data + struct.pack_into(' 0: + data = self.serial.read(self.serial.in_waiting) + self.buf.extend(data) + + # Process state machine + if not (packet := self._process()): + if poll_events: + return + time.sleep(0.001) + continue + + # Simulate packet drops by randomly dropping parsed packets + if self.drop_rate > 0.0 and random.random() < self.drop_rate: + self.log(packet=packet, direction="Drop") + continue + + self.stats['received'] += 1 + self.log(packet=packet, direction="Recv") + + # Handle retransmission + if (packet['flags'] & Flags.RTX) and (self.sequence != packet['sequence']): + if packet['flags'] & Flags.ACK_REQ: + self.send_packet(packet['opcode'], packet['channel'], + Flags.ACK, sequence=packet['sequence']) + continue # Skip further processing of duplicate packet + + # ACK the received packet + if packet['flags'] & Flags.ACK_REQ: + # Simulate packet ACK drops + if self.drop_rate > 0.0 and random.random() < self.drop_rate: + status = struct.pack(' 2: + if self.state == State.SYNC: + # Find sync pattern + while len(self.buf) > 2: + sync = self.buf.peek16() + if sync == Protocol.SYNC_WORD: + self.state = State.HEADER + break + self.buf.consume(1) + + elif self.state == State.HEADER: + # Wait for complete header + if len(self.buf) < Protocol.HEADER_SIZE: + return None + + # Parse header + header = self.buf.peek(Protocol.HEADER_SIZE) + sync, seq, chan, flags, opcode, length, crc = _hdr_struct.unpack(header[:Protocol.HEADER_SIZE]) + + self.state = State.SYNC + if length > self.max_payload: + self.log(seq, chan, opcode, flags, length, "Rjct") + self.buf.consume(1) + elif not self._check_seq(seq, self.sequence, opcode, flags): + self.log(seq, chan, opcode, flags, length, "Rjct") + self.buf.consume(1) + elif not self._check_crc(crc, header[:Protocol.HEADER_SIZE - 2], 16): + self.log(seq, chan, opcode, flags, length, "Rjct") + self.buf.consume(1) + else: + self.state = State.PAYLOAD + self.plength = Protocol.HEADER_SIZE + length + self.plength += Protocol.CRC_SIZE if length else 0 + + elif self.state == State.PAYLOAD: + # Wait for a complete packet + if len(self.buf) < self.plength: + return None + + payload = None + self.state = State.SYNC + + # Parse packet + packet = self.buf.peek(self.plength) + sync, seq, chan, flags, opcode, length, crc = _hdr_struct.unpack(packet[:Protocol.HEADER_SIZE]) + + # Parse payload + if length > 0: + payload = packet[Protocol.HEADER_SIZE:-Protocol.CRC_SIZE] + payload_crc = _crc32_struct.unpack(packet[-Protocol.CRC_SIZE:])[0] + + if not self._check_crc(payload_crc, payload, 32): + self.stats['checksum'] += 1 + self.log(seq, chan, opcode, flags, length, "Rjct") + self.buf.consume(1) # Try next byte + continue + + self.buf.consume(self.plength) + + return { + 'sync': sync, + 'sequence': seq, + 'channel': chan, + 'flags': flags, + 'opcode': opcode, + 'length': length, + 'header_crc': crc, + 'payload': payload + }