You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1376 lines
43 KiB

#!/usr/bin/env python3
"""
UART Core - vzug-e-hinge
=========================
Clean UART port management with packet detection and timestamping.
Features:
- Single port per instance (no multi-port management)
- Reader thread writes to circular buffer
- Start/stop condition detection with timestamps
- Packet counting per connection
- Configurable stop conditions (timeout or terminator byte)
- Polling mode with grace period
- **NEW: Packet detection in listening mode with real-time timestamps**
- Buffer overflow tracking
- Clean connect/disconnect (no auto-reconnect)
Author: Kynsight
Version: 2.1.0 - Added packet detection for listening mode
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Callable, Tuple
try:
import serial
except ImportError:
serial = None
from buffer_kit.circular_buffer import (
Status as BufferStatus,
CircularBufferHandle,
cb_init,
cb_write,
cb_copy_span,
cb_w_abs,
cb_capacity,
cb_fill_bytes,
cb_fill_pct,
cb_overflows,
)
__all__ = [
# Status & Configuration
'Status',
'StopConditionMode',
'UARTConfig',
'PacketConfig',
'PacketInfo',
'PortStatus',
'UARTPort',
# Lifecycle
'uart_create',
'uart_open',
'uart_close',
# Reader thread
'uart_start_reader',
'uart_stop_reader',
# Write
'uart_write',
# Packet detection modes
'uart_send_and_receive',
'uart_send_and_read_pgkomm2',
'uart_poll_packet',
# Listening mode
'uart_start_listening',
'uart_stop_listening',
'uart_read_buffer',
# NEW: Listening with packet detection
'uart_start_listening_with_packets',
'uart_get_detected_packets',
'uart_clear_detected_packets',
# Status
'uart_get_status',
]
# =============================================================================
# Status & Configuration
# =============================================================================
class Status(Enum):
"""Operation status codes."""
OK = 0
TIMEOUT = 1
TIMEOUT_NO_DATA = 2
BUFFER_OVERFLOW = 3
IO_ERROR = 4
BAD_CONFIG = 5
PORT_CLOSED = 6
ALREADY_OPEN = 7
READER_NOT_RUNNING = 8
class StopConditionMode(Enum):
"""How to detect packet end."""
TIMEOUT = 0 # No data for N ms
TERMINATOR = 1 # Specific byte received
@dataclass(frozen=True)
class UARTConfig:
"""
UART port configuration.
device: Serial device path (e.g., "/dev/ttyUSB0")
baudrate: Baud rate (e.g., 115200)
data_bits: Data bits (7 or 8)
stop_bits: Stop bits (1 or 2)
parity: Parity ('N', 'E', 'O', 'M', 'S')
buffer_size: RX circular buffer capacity in bytes
read_chunk_size: Max bytes to read per loop iteration
stop_mode: TIMEOUT or TERMINATOR
stop_timeout_ms: Timeout in ms (for TIMEOUT mode or grace period)
stop_terminator: Byte to detect (for TERMINATOR mode)
polling_mode: If True, enables grace period before timeout
grace_timeout_ms: Max wait for first byte in polling mode
timestamp_source: Optional external time function (default: time.perf_counter)
"""
device: str
baudrate: int
data_bits: int = 8
stop_bits: int = 1
parity: str = 'N'
buffer_size: int = 40 * 1024 * 1024 # 40MB default buffer
read_chunk_size: int = 512
stop_mode: StopConditionMode = StopConditionMode.TIMEOUT
stop_timeout_ms: int = 150
stop_terminator: int = 0x0A # Newline
polling_mode: bool = False
grace_timeout_ms: int = 150
timestamp_source: Optional[Callable[[], float]] = None
@dataclass(frozen=True)
class PacketConfig:
"""
Packet detection configuration for listening mode.
Used to detect packet boundaries in continuous data stream.
Example for format: EF FE [14 bytes] EE
PacketConfig(
enable=True,
start_marker=b'\\xEF\\xFE',
packet_length=17,
end_marker=b'\\xEE',
on_packet_callback=my_callback_function
)
Fields:
enable: Enable/disable packet detection
start_marker: Bytes that mark packet start (e.g., b'\\xEF\\xFE')
packet_length: Total packet length in bytes (including markers)
end_marker: Bytes that mark packet end (e.g., b'\\xEE')
on_packet_callback: Optional callback function called when packet detected
Signature: callback(timestamp_ns: int) -> None
Called in reader thread with packet timestamp
"""
enable: bool = False
start_marker: Optional[bytes] = None
packet_length: Optional[int] = None
end_marker: Optional[bytes] = None
on_packet_callback: Optional[Callable[[int], None]] = None
@dataclass
class PacketInfo:
"""
Information about a detected packet.
packet_id: Sequential ID (resets per connection)
start_timestamp: When first byte arrived (nanoseconds)
stop_timestamp: When stop condition met (not used in listening mode)
data: Packet payload
stop_reason: "timeout", "terminator", "grace_timeout", "packet_complete"
"""
packet_id: int
start_timestamp: float
stop_timestamp: float
data: bytes
stop_reason: str
@dataclass
class PortStatus:
"""
Current port status.
is_open: Port is opened
is_reader_running: Reader thread is active
buffer_capacity: Buffer size in bytes
buffer_fill_bytes: Current buffer usage
buffer_fill_percent: Fill percentage (0-100)
buffer_overflows: Number of buffer overflows
total_bytes_received: Total bytes since connection
total_packets: Total packets detected
"""
is_open: bool
is_reader_running: bool
buffer_capacity: int
buffer_fill_bytes: int
buffer_fill_percent: int
buffer_overflows: int
total_bytes_received: int
total_packets: int
# =============================================================================
# UART Port Handle
# =============================================================================
@dataclass
class UARTPort:
"""
UART port instance.
Internal state - do not access directly.
Use the public API functions.
"""
config: UARTConfig
# Serial port
_serial_port: Optional[serial.Serial] = None
_is_open: bool = False
# Circular buffer
_rx_buffer: Optional[CircularBufferHandle] = None
# Reader thread
_reader_thread: Optional[threading.Thread] = None
_reader_running: bool = False
_stop_reader_event: threading.Event = field(default_factory=threading.Event)
# Statistics (protected by lock)
_lock: threading.Lock = field(default_factory=threading.Lock)
_total_bytes_received: int = 0
_total_packets: int = 0
_last_rx_timestamp: float = 0.0
_listening_start_time: float = 0.0
# Packet detection (for listening mode with packet detection)
_packet_config: Optional[PacketConfig] = None
_detected_packets: list = field(default_factory=list)
_packet_buffer: bytearray = field(default_factory=bytearray)
_packet_detection_active: bool = False
_packet_start_timestamp: float = 0.0
# Timestamp function
_get_timestamp: Callable[[], float] = field(default=time.perf_counter)
def __post_init__(self):
"""Initialize timestamp source."""
if self.config.timestamp_source:
self._get_timestamp = self.config.timestamp_source
# =============================================================================
# Port Lifecycle
# =============================================================================
def uart_create(config: UARTConfig) -> Tuple[Status, Optional[UARTPort]]:
"""
Create UART port instance.
Returns:
(Status.OK, port) on success
(Status.BAD_CONFIG, None) on invalid config
"""
if not config.device or config.baudrate <= 0:
return (Status.BAD_CONFIG, None)
if not serial:
return (Status.IO_ERROR, None)
port = UARTPort(config=config)
return (Status.OK, port)
def uart_open(port: UARTPort) -> Status:
"""
Open serial port and initialize buffer.
Returns:
Status.OK on success
Status.ALREADY_OPEN if already open
Status.IO_ERROR on failure
"""
if port._is_open:
return Status.ALREADY_OPEN
try:
# Map parity
parity_map = {
'N': serial.PARITY_NONE,
'E': serial.PARITY_EVEN,
'O': serial.PARITY_ODD,
'M': serial.PARITY_MARK,
'S': serial.PARITY_SPACE
}
# Open serial port with FULL configuration
port._serial_port = serial.Serial(
port=port.config.device,
baudrate=port.config.baudrate,
bytesize=port.config.data_bits,
parity=parity_map.get(port.config.parity, serial.PARITY_NONE),
stopbits=port.config.stop_bits,
timeout=0.01, # Non-blocking with small timeout
write_timeout=0.5,
xonxoff=False, # Disable software flow control
rtscts=False, # Disable hardware (RTS/CTS) flow control
dsrdtr=False # Disable hardware (DSR/DTR) flow control
)
# Create RX buffer
status, buffer = cb_init(port.config.buffer_size)
if status != BufferStatus.OK:
port._serial_port.close()
return Status.IO_ERROR
port._rx_buffer = buffer
port._is_open = True
# Reset statistics
with port._lock:
port._total_bytes_received = 0
port._total_packets = 0
port._last_rx_timestamp = 0.0
return Status.OK
except Exception as e:
return Status.IO_ERROR
def uart_close(port: UARTPort) -> Status:
"""
Close serial port and cleanup resources.
Stops reader thread if running.
Returns:
Status.OK on success
"""
if not port._is_open:
return Status.OK
# Stop reader first
if port._reader_running:
uart_stop_reader(port)
# Close serial port
if port._serial_port:
try:
port._serial_port.close()
except:
pass
port._serial_port = None
# Cleanup buffer
port._rx_buffer = None
port._is_open = False
return Status.OK
# =============================================================================
# Reader Thread
# =============================================================================
def uart_start_reader(port: UARTPort) -> Status:
"""
Start background reader thread.
Thread reads from serial port and writes to circular buffer.
Returns:
Status.OK on success
Status.PORT_CLOSED if port not open
"""
if not port._is_open:
return Status.PORT_CLOSED
if port._reader_running:
return Status.OK # Already running
port._stop_reader_event.clear()
port._reader_running = True
port._reader_thread = threading.Thread(
target=_reader_thread_func,
args=(port,),
daemon=True,
name=f"UART-Reader-{port.config.device}"
)
port._reader_thread.start()
return Status.OK
def uart_stop_reader(port: UARTPort) -> Status:
"""
Stop background reader thread.
Blocks until thread exits (with timeout).
Returns:
Status.OK on success
"""
if not port._reader_running:
return Status.OK
port._stop_reader_event.set()
if port._reader_thread:
port._reader_thread.join(timeout=1.0)
port._reader_thread = None
port._reader_running = False
return Status.OK
def _reader_thread_func(port: UARTPort) -> None:
"""
Background reader thread implementation.
Continuously reads from serial port and writes to circular buffer.
Updates timestamps and byte counters.
If packet detection is enabled, also detects packet boundaries
and stores detected packets with timestamps.
"""
while not port._stop_reader_event.is_set():
try:
if port._serial_port and port._serial_port.in_waiting > 0:
# Read available data
chunk = port._serial_port.read(port.config.read_chunk_size)
if chunk:
# Write to circular buffer
cb_write(port._rx_buffer, chunk)
# Update statistics
timestamp = port._get_timestamp()
with port._lock:
port._total_bytes_received += len(chunk)
port._last_rx_timestamp = timestamp
# Packet detection (if enabled)
if port._packet_detection_active and port._packet_config:
_detect_packets_in_chunk(port, chunk, timestamp)
else:
# No data available, sleep briefly
time.sleep(0.001)
except Exception:
# IO error - exit thread
break
def _detect_packets_in_chunk(port: UARTPort, chunk: bytes, timestamp: float) -> None:
"""
Detect packets in received chunk.
Uses configured packet format (start marker, length, end marker).
Stores complete packets in _detected_packets list with timestamps.
Packet format: [START_MARKER][DATA][END_MARKER]
Example: EF FE [14 bytes] EE
Args:
port: UART port instance
chunk: Received data chunk
timestamp: Timestamp when chunk received
"""
if not port._packet_config or not port._packet_config.enable:
return
cfg = port._packet_config
# Add chunk to packet buffer
port._packet_buffer.extend(chunk)
# Process buffer looking for complete packets
while len(port._packet_buffer) >= (cfg.packet_length or 0):
# Look for start marker
if cfg.start_marker:
# Find start marker position
start_idx = port._packet_buffer.find(cfg.start_marker)
if start_idx == -1:
# No start marker found - clear old data, keep last few bytes
# (in case start marker is split across chunks)
if len(port._packet_buffer) > 100:
port._packet_buffer = port._packet_buffer[-10:]
break
# Remove everything before start marker
if start_idx > 0:
port._packet_buffer = port._packet_buffer[start_idx:]
# Check if we have enough bytes for complete packet
if len(port._packet_buffer) < cfg.packet_length:
break # Wait for more data
# Extract potential packet
packet_bytes = bytes(port._packet_buffer[:cfg.packet_length])
# Verify end marker (if configured)
if cfg.end_marker:
expected_end_pos = cfg.packet_length - len(cfg.end_marker)
actual_end = packet_bytes[expected_end_pos:]
if actual_end != cfg.end_marker:
# Invalid packet - discard first byte and try again
port._packet_buffer.pop(0)
continue
# Valid packet found!
# Timestamp at packet START (when we found start marker)
if port._packet_start_timestamp == 0.0:
port._packet_start_timestamp = timestamp
# Create packet info
with port._lock:
port._total_packets += 1
packet_id = port._total_packets
packet_info = PacketInfo(
packet_id=packet_id,
start_timestamp=int(port._packet_start_timestamp * 1e9), # Convert to nanoseconds
stop_timestamp=int(timestamp * 1e9), # Not used, but filled anyway
data=packet_bytes,
stop_reason="packet_complete"
)
# Store packet
port._detected_packets.append(packet_info)
# Call callback if provided (with timestamp in nanoseconds)
if cfg.on_packet_callback:
try:
cfg.on_packet_callback(packet_info.start_timestamp)
except Exception:
# Don't crash reader thread if callback fails
pass
# Remove packet from buffer
port._packet_buffer = port._packet_buffer[cfg.packet_length:]
# Reset start timestamp for next packet
port._packet_start_timestamp = 0.0
else:
# No start marker - just use fixed length
if cfg.packet_length and len(port._packet_buffer) >= cfg.packet_length:
packet_bytes = bytes(port._packet_buffer[:cfg.packet_length])
with port._lock:
port._total_packets += 1
packet_id = port._total_packets
packet_info = PacketInfo(
packet_id=packet_id,
start_timestamp=int(timestamp * 1e9),
stop_timestamp=int(timestamp * 1e9),
data=packet_bytes,
stop_reason="packet_complete"
)
port._detected_packets.append(packet_info)
# Call callback if provided (with timestamp in nanoseconds)
if cfg.on_packet_callback:
try:
cfg.on_packet_callback(packet_info.start_timestamp)
except Exception:
# Don't crash reader thread if callback fails
pass
port._packet_buffer = port._packet_buffer[cfg.packet_length:]
else:
break
# =============================================================================
# Write Operations
# =============================================================================
def uart_write(port: UARTPort, data: bytes) -> Tuple[Status, int]:
"""
Write data to serial port.
Returns:
(Status.OK, bytes_written) on success
(Status.PORT_CLOSED, 0) if port not open
(Status.IO_ERROR, 0) on write failure
"""
if not port._is_open or not port._serial_port:
return (Status.PORT_CLOSED, 0)
try:
written = port._serial_port.write(data)
port._serial_port.flush()
return (Status.OK, written)
except Exception:
return (Status.IO_ERROR, 0)
# =============================================================================
# Packet Detection (Request-Response Mode)
# =============================================================================
def uart_send_and_receive(port: UARTPort, tx_data: bytes,
timeout_ms: Optional[int] = None) -> Tuple[Status, Optional[PacketInfo]]:
"""
Send data and wait for response packet (request-response mode).
Uses configured stop condition (timeout or terminator).
Timeout starts immediately after send.
Args:
port: UART port instance
tx_data: Data to transmit
timeout_ms: Override configured timeout (optional)
Returns:
(Status.OK, PacketInfo) on success
(Status.TIMEOUT, None) on timeout
(Status.PORT_CLOSED, None) if port not ready
"""
if not port._is_open or not port._reader_running:
return (Status.PORT_CLOSED, None)
# Use configured timeout or override
timeout = timeout_ms if timeout_ms is not None else port.config.stop_timeout_ms
# CRITICAL: Flush serial input buffer BEFORE sending to clear any background traffic
# This ensures we only capture the response to OUR command, not stale data
try:
if port._serial_port:
port._serial_port.reset_input_buffer()
except Exception:
pass # Some platforms don't support this - continue anyway
# Snapshot buffer position AFTER flush
start_w = cb_w_abs(port._rx_buffer)
# Send data
status, _ = uart_write(port, tx_data)
if status != Status.OK:
return (status, None)
# Start timestamp
start_time = port._get_timestamp()
timeout_s = timeout / 1000.0
# Wait for response
return _wait_for_packet(
port=port,
start_w=start_w,
start_time=start_time,
timeout_s=timeout_s,
grace_period=False
)
def uart_send_and_read_pgkomm2(port: UARTPort,
tx_data: bytes,
capture_max_ms: int = 30,
max_frames: int = 10) -> Tuple[Status, Optional[list]]:
"""
Send PGKomm2 command and read response frames.
Uses EXACT logic from old working code (uart_old/pgkomm.py):
- Single timeout window for entire operation
- Read immediately when bytes arrive
- Stop immediately when HP response detected
- No sleep, no delays - pure spinning
- BCC validation rejects corrupted frames
PGKomm2 protocol (V-ZUG spec A5.5093D-AB):
- Frame format: DD 22 | ADR1 ADR2 | LEN | DATA(0-255) | BCC
- Response time: < 15 ms (spec, ideal conditions)
- Multiple frames may be returned (SB status + PH echo + HP response)
- Length-delimited, no terminator bytes
- Background telemetry: Device sends unsolicited SB frames continuously
Args:
port: UART port instance
tx_data: PGKomm2 command to transmit (must start with DD 22)
capture_max_ms: Total capture window in ms (default 30ms, spec says <15ms but real-world needs margin)
max_frames: Maximum number of frames to parse (safety limit)
Returns:
(Status.OK, [frame1, frame2, ...]) on success
(Status.TIMEOUT, None) if no response within timeout
(Status.PORT_CLOSED, None) if port not ready
(Status.IO_ERROR, None) on read/write error
Example:
# Send PGKomm2 command
status, frames = uart_send_and_read_pgkomm2(
port,
bytes.fromhex("DD 22 50 48 02 43 4F 16"),
capture_max_ms=30 # Default, can be adjusted if needed
)
if status == Status.OK:
for frame in frames:
print(f"Frame: {frame.hex(' ')}")
# BCC errors are logged to console automatically
"""
if not port._is_open or not port._serial_port:
return (Status.PORT_CLOSED, None)
# Save original timeout
original_timeout = port._serial_port.timeout
# Temporarily stop reader thread to get exclusive serial access
reader_was_running = port._reader_running
if reader_was_running:
uart_stop_reader(port)
try:
# Flush serial input buffer (clear background traffic)
try:
port._serial_port.reset_input_buffer()
except Exception:
pass
# Set non-blocking timeout
port._serial_port.timeout = 0
# Send command
status, _ = uart_write(port, tx_data)
if status != Status.OK:
return (status, None)
# Calculate deadline (single timeout for entire operation)
start_time = time.time()
deadline = start_time + (capture_max_ms / 1000.0)
rx_buffer = bytearray()
collected_frames = []
echo_frame = None
reply_frame = None
# Single loop - read directly from serial until HP or timeout (like old code)
while time.time() < deadline:
# Read available data IMMEDIATELY from serial port
n = port._serial_port.in_waiting or 0
if n:
chunk = port._serial_port.read(n)
if chunk:
rx_buffer += chunk
# Try to extract complete frames as they arrive
while True:
frame = _extract_pgkomm2_frame(rx_buffer)
if frame is None:
break # Need more bytes
# Collect frame
collected_frames.append(frame)
# Check frame type
if len(frame) >= 5:
adr1, adr2 = frame[2], frame[3]
# First complete frame is typically PH echo
if echo_frame is None and adr1 == 0x50 and adr2 == 0x48: # PH
echo_frame = frame
continue
# Prefer HP and stop looking once we have it
if adr1 == 0x48 and adr2 == 0x50: # HP
reply_frame = frame
break
# If it's neither PH nor HP and we have no echo yet
if echo_frame is None:
echo_frame = frame
# Stop immediately if we have HP
if reply_frame is not None:
break
# No sleep - pure spinning (old code does this)
# Return results - ONLY success if we got HP response!
if reply_frame is not None:
# Got HP response - success!
return (Status.OK, collected_frames)
elif len(collected_frames) > 0:
# Got frames but no HP response (only SB broadcasts or PH echo without answer)
print(f"[PGKOMM2] TIMEOUT: Got {len(collected_frames)} frame(s) but no HP response")
return (Status.TIMEOUT, None)
elif len(rx_buffer) > 0:
# Unparseable data - log for debugging
print(f"[PGKOMM2] IO_ERROR: Unparsed buffer ({len(rx_buffer)} bytes): {rx_buffer.hex(' ').upper()}")
return (Status.IO_ERROR, None)
else:
# No response
print(f"[PGKOMM2] TIMEOUT: No data received within {capture_max_ms}ms")
return (Status.TIMEOUT, None)
except Exception as e:
return (Status.IO_ERROR, None)
finally:
# Restore timeout
try:
if port._serial_port:
port._serial_port.timeout = original_timeout
except Exception:
pass
# Restart reader thread if it was running
if reader_was_running:
uart_start_reader(port)
def _extract_pgkomm2_frame(buffer: bytearray) -> Optional[bytes]:
"""
Extract ONE complete PGKomm2 frame from buffer (destructive).
Uses the EXACT logic from the old working code (uart_old/pgkomm.py).
Searches for DD 22 header, reads LEN, extracts complete frame,
validates BCC checksum, and REMOVES it from the buffer.
Frame format: [DD][22][ADR1][ADR2][LEN][DATA...][BCC]
BCC = XOR of all bytes from ADR1 through last DATA byte
Args:
buffer: Bytearray to extract from (will be modified!)
Returns:
Complete frame as bytes, or None if no complete frame available
Corrupted frames (BCC mismatch) are rejected and return None
"""
MAGIC = 0xDD
INVMAGIC = 0x22
# Hunt for header DD 22
i = 0
blen = len(buffer)
while i + 1 < blen:
if buffer[i] == MAGIC and buffer[i + 1] == INVMAGIC:
# Have header; check if we have at least up to LEN
if i + 5 > blen:
# Need more bytes for ADR1/ADR2/LEN
return None
adr1 = buffer[i + 2]
adr2 = buffer[i + 3]
length = buffer[i + 4]
total = 6 + length # full frame size
if i + total <= blen:
# We have the whole frame
frame = bytes(buffer[i:i + total])
# Validate BCC (XOR from ADR1 through DATA)
# BCC = frame[-1], should equal XOR of frame[2:-1]
calculated_bcc = 0
for byte in frame[2:-1]: # ADR1, ADR2, LEN, DATA...
calculated_bcc ^= byte
received_bcc = frame[-1]
if calculated_bcc != received_bcc:
# BCC mismatch - frame corrupted!
adr_str = f"{frame[2]:02X} {frame[3]:02X}" if len(frame) >= 4 else "??"
print(f"[PGKOMM2] ✗ BCC FAIL: ADR={adr_str}, calc={calculated_bcc:02X}, recv={received_bcc:02X}")
print(f"[PGKOMM2] Frame: {frame.hex(' ').upper()}")
# Drop this frame and continue searching
del buffer[:i + total]
return None # Reject corrupted frame
# BCC valid - frame is good!
del buffer[:i + total]
return frame
else:
# Header found but incomplete body — wait for more bytes
return None
else:
i += 1
# If we advanced i without finding a header, drop garbage to i to avoid re-scanning
# BUT: Only if we either reached the end OR the buffer doesn't start with MAGIC
if i > 0 and (i >= blen or not (blen >= 2 and buffer[0] == MAGIC and buffer[1] == INVMAGIC)):
del buffer[:i]
return None
def _parse_pgkomm2_frames(buffer: bytearray, max_frames: int = 10) -> list:
"""
Parse PGKomm2 frames from buffer.
Frame format: DD 22 | ADR1 ADR2 | LEN | DATA | BCC
Total size: 6 + LEN bytes
Args:
buffer: Raw data buffer
max_frames: Maximum frames to extract (safety limit)
Returns:
List of frame bytes (each frame as bytes object)
"""
MAGIC = 0xDD
INVMAGIC = 0x22
MIN_FRAME_SIZE = 6 # DD 22 ADR1 ADR2 LEN BCC (with LEN=0)
frames = []
pos = 0
while pos < len(buffer) and len(frames) < max_frames:
# Look for frame header DD 22
if pos + 1 >= len(buffer):
break
if buffer[pos] == MAGIC and buffer[pos + 1] == INVMAGIC:
# Found potential frame start
if pos + 5 >= len(buffer): # Need at least ADR1 ADR2 LEN
break
# Extract length byte
length = buffer[pos + 4]
frame_size = 6 + length # DD 22 ADR1 ADR2 LEN DATA(length) BCC
# Check if we have complete frame
if pos + frame_size <= len(buffer):
# Extract complete frame
frame = bytes(buffer[pos:pos + frame_size])
frames.append(frame)
pos += frame_size
else:
# Incomplete frame at end of buffer
break
else:
# Not a frame start, advance
pos += 1
return frames
# =============================================================================
# Listening Mode (Continuous, No Auto-Stop)
# =============================================================================
def uart_start_listening(port: UARTPort) -> Status:
"""
Start listening mode - continuous data collection with no stop condition.
Data fills buffer continuously. Use uart_read_buffer() to get current contents.
Call uart_stop_listening() to stop.
Returns:
Status.OK on success
Status.PORT_CLOSED if port not ready
"""
if not port._is_open or not port._reader_running:
return Status.PORT_CLOSED
# Listening mode has no special state - just reader running
# Mark listening start time for potential later use
with port._lock:
port._listening_start_time = port._get_timestamp()
return Status.OK
def uart_stop_listening(port: UARTPort) -> Status:
"""
Stop listening mode.
Returns:
Status.OK
"""
# Nothing special to do - reader keeps running
# Just mark end time
with port._lock:
port._listening_start_time = 0.0
# Stop packet detection
port._packet_detection_active = False
return Status.OK
def uart_start_listening_with_packets(port: UARTPort, packet_config: PacketConfig) -> Status:
"""
Start listening mode WITH packet detection.
Reader thread will:
- Fill circular buffer (continuous logging)
- Detect packet boundaries based on packet_config
- Store each detected packet with timestamp and count
- Call optional callback when packet detected (real-time trigger)
Args:
port: UART port instance
packet_config: Packet detection configuration
Returns:
Status.OK on success
Status.PORT_CLOSED if port not ready
Example without callback:
# For packet format: EF FE [14 bytes] EE (17 bytes total)
packet_config = PacketConfig(
enable=True,
start_marker=b'\\xEF\\xFE',
packet_length=17,
end_marker=b'\\xEE'
)
uart_start_listening_with_packets(port, packet_config)
Example with callback (real-time trigger):
def on_packet_detected(timestamp_ns: int):
'''Called immediately when packet detected.'''
# Trigger I2C read or other action
i2c_read_angle(i2c_port)
packet_config = PacketConfig(
enable=True,
start_marker=b'\\xEF\\xFE',
packet_length=17,
end_marker=b'\\xEE',
on_packet_callback=on_packet_detected
)
uart_start_listening_with_packets(port, packet_config)
"""
if not port._is_open or not port._reader_running:
return Status.PORT_CLOSED
# Configure packet detection
port._packet_config = packet_config
port._packet_buffer.clear()
port._packet_start_timestamp = 0.0
# Mark listening start time
with port._lock:
port._listening_start_time = port._get_timestamp()
# Enable packet detection in reader thread
port._packet_detection_active = packet_config.enable
return Status.OK
def uart_get_detected_packets(port: UARTPort) -> list:
"""
Get all packets detected since listening started.
Returns list of PacketInfo objects, each containing:
- packet_id: Sequential packet number (1, 2, 3, ...)
- start_timestamp: When packet started (nanoseconds since epoch)
- data: Raw packet bytes
Returns:
List of PacketInfo objects
Example:
packets = uart_get_detected_packets(port)
print(f"Detected {len(packets)} packets")
for pkt in packets:
print(f"Packet {pkt.packet_id} at {pkt.start_timestamp}ns")
"""
return port._detected_packets.copy()
def uart_clear_detected_packets(port: UARTPort) -> Status:
"""
Clear detected packets list.
Call at start of each RUN to reset packet counter and list.
Returns:
Status.OK
"""
port._detected_packets.clear()
port._packet_buffer.clear()
port._packet_start_timestamp = 0.0
with port._lock:
port._total_packets = 0
return Status.OK
def uart_read_buffer(port: UARTPort, max_bytes: int = 0) -> Tuple[Status, bytes]:
"""
Read current buffer contents (for listening mode).
Args:
port: UART port
max_bytes: Max bytes to read (0 = all available, or last N bytes if buffer larger)
Returns:
(Status.OK, data) on success
(Status.PORT_CLOSED, b"") if port not ready
"""
if not port._is_open or not port._rx_buffer:
return (Status.PORT_CLOSED, b"")
current_w = cb_w_abs(port._rx_buffer)
if current_w == 0:
return (Status.OK, b"")
if max_bytes > 0:
# Read last N bytes
start_w = max(0, current_w - max_bytes)
else:
# Read all available (up to buffer capacity)
capacity = cb_capacity(port._rx_buffer)
start_w = max(0, current_w - capacity)
status, data = cb_copy_span(port._rx_buffer, start_w, current_w)
if status != BufferStatus.OK:
return (Status.IO_ERROR, b"")
return (Status.OK, data)
# =============================================================================
# Packet Detection (Polling Mode)
# =============================================================================
def uart_poll_packet(port: UARTPort) -> Tuple[Status, Optional[PacketInfo]]:
"""
Poll for next packet (polling mode).
Grace period: Waits for first byte, then timeout starts.
Flow:
1. Wait up to grace_timeout_ms for first byte
2. Once first byte arrives, start stop_timeout_ms
3. If grace expires without byte, start timeout anyway
4. Detect stop condition (timeout or terminator)
Returns:
(Status.OK, PacketInfo) on success
(Status.TIMEOUT_NO_DATA, None) if no data after grace + timeout
(Status.PORT_CLOSED, None) if port not ready
"""
if not port._is_open or not port._reader_running:
return (Status.PORT_CLOSED, None)
if not port.config.polling_mode:
# Not configured for polling - use regular timeout
start_w = cb_w_abs(port._rx_buffer)
start_time = port._get_timestamp()
timeout_s = port.config.stop_timeout_ms / 1000.0
return _wait_for_packet(
port=port,
start_w=start_w,
start_time=start_time,
timeout_s=timeout_s,
grace_period=False
)
# Polling mode with grace period
start_w = cb_w_abs(port._rx_buffer)
start_time = port._get_timestamp()
grace_s = port.config.grace_timeout_ms / 1000.0
timeout_s = port.config.stop_timeout_ms / 1000.0
# Phase 1: Grace period - wait for first byte
first_byte_seen = False
grace_start = start_time
while (port._get_timestamp() - grace_start) < grace_s:
current_w = cb_w_abs(port._rx_buffer)
if current_w > start_w:
# First byte arrived!
first_byte_seen = True
break
time.sleep(0.001)
# Phase 2: Timeout starts (whether or not byte arrived)
return _wait_for_packet(
port=port,
start_w=start_w,
start_time=port._get_timestamp(), # Reset start time
timeout_s=timeout_s,
grace_period=False,
grace_expired_no_data=(not first_byte_seen)
)
def _wait_for_packet(port: UARTPort, start_w: int, start_time: float,
timeout_s: float, grace_period: bool,
grace_expired_no_data: bool = False) -> Tuple[Status, Optional[PacketInfo]]:
"""
Internal: Wait for stop condition and collect packet.
Args:
port: UART port
start_w: Buffer write position at start
start_time: Start timestamp
timeout_s: Timeout in seconds
grace_period: If True, timeout starts after first byte
grace_expired_no_data: Grace expired without any byte
Returns:
(Status, PacketInfo or None)
"""
mode = port.config.stop_mode
first_byte_seen = False
first_byte_time = 0.0
last_rx_time = start_time
while True:
now = port._get_timestamp()
# Check if port was closed during execution
if not port._is_open or port._rx_buffer is None:
return (Status.PORT_CLOSED, None)
current_w = cb_w_abs(port._rx_buffer)
# Check for new data
if current_w > start_w:
if not first_byte_seen:
first_byte_seen = True
first_byte_time = now
# Update last RX time from reader thread
with port._lock:
last_rx_time = port._last_rx_timestamp
# Grace period: wait for first byte before starting timeout
if grace_period and not first_byte_seen:
if (now - start_time) >= timeout_s:
# Grace expired, no data
if grace_expired_no_data:
return (Status.TIMEOUT_NO_DATA, None)
# Start regular timeout now
grace_period = False
start_time = now
time.sleep(0.001)
continue
# Timeout mode: silence timeout
if mode == StopConditionMode.TIMEOUT:
if first_byte_seen:
silence = now - last_rx_time
if silence >= (timeout_s):
# Stop condition: timeout met
status, data = cb_copy_span(port._rx_buffer, start_w, current_w)
if status != BufferStatus.OK:
return (Status.IO_ERROR, None)
return _create_packet_info(
port=port,
data=data,
start_time=first_byte_time,
stop_time=now,
stop_reason="timeout"
)
else:
# No byte yet, check absolute timeout
if (now - start_time) >= timeout_s:
return (Status.TIMEOUT_NO_DATA, None)
# Terminator mode: look for specific byte
elif mode == StopConditionMode.TERMINATOR:
if current_w > start_w:
# Read current data
status, data = cb_copy_span(port._rx_buffer, start_w, current_w)
if status != BufferStatus.OK:
return (Status.IO_ERROR, None)
# Check for terminator
if port.config.stop_terminator in data:
return _create_packet_info(
port=port,
data=data,
start_time=first_byte_time if first_byte_seen else start_time,
stop_time=now,
stop_reason="terminator"
)
# Absolute timeout fallback
if (now - start_time) >= timeout_s:
return (Status.TIMEOUT, None)
time.sleep(0.001)
def _create_packet_info(port: UARTPort, data: bytes, start_time: float,
stop_time: float, stop_reason: str) -> Tuple[Status, PacketInfo]:
"""Create packet info and increment counter."""
with port._lock:
port._total_packets += 1
packet_id = port._total_packets
packet = PacketInfo(
packet_id=packet_id,
start_timestamp=start_time,
stop_timestamp=stop_time,
data=data,
stop_reason=stop_reason
)
return (Status.OK, packet)
# =============================================================================
# Status Query
# =============================================================================
def uart_get_status(port: UARTPort) -> PortStatus:
"""
Get current port status.
Returns:
PortStatus with current metrics
"""
if not port._rx_buffer:
return PortStatus(
is_open=port._is_open,
is_reader_running=port._reader_running,
buffer_capacity=0,
buffer_fill_bytes=0,
buffer_fill_percent=0,
buffer_overflows=0,
total_bytes_received=0,
total_packets=0
)
with port._lock:
total_bytes = port._total_bytes_received
total_packets = port._total_packets
return PortStatus(
is_open=port._is_open,
is_reader_running=port._reader_running,
buffer_capacity=cb_capacity(port._rx_buffer),
buffer_fill_bytes=cb_fill_bytes(port._rx_buffer),
buffer_fill_percent=cb_fill_pct(port._rx_buffer),
buffer_overflows=cb_overflows(port._rx_buffer),
total_bytes_received=total_bytes,
total_packets=total_packets
)