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.
162 lines
5.6 KiB
162 lines
5.6 KiB
# -----------------------
|
|
# File: components/uart/packet_detector.py
|
|
#
|
|
# Purpose
|
|
# -------
|
|
# Tiny, fast packet sniffer for fixed-format frames in a byte stream.
|
|
# Detects packets *in real time* as bytes arrive, without conversions.
|
|
#
|
|
# Default packet shape
|
|
# --------------------
|
|
# - Start: 0xEF 0xFE
|
|
# - Total length: 14 bytes
|
|
# - Last byte must be: 0xEE
|
|
#
|
|
# When a packet is detected, we invoke an optional callback:
|
|
# on_packet(ts_ns: int, packet: bytes, abs_off_start: int) -> None
|
|
# where `ts_ns` is the detection/completion timestamp (now_ns at the moment
|
|
# we saw the last byte), and `abs_off_start` is the absolute offset of the
|
|
# first packet byte within the upstream ring buffer (if provided by caller).
|
|
#
|
|
# Notes
|
|
# -----
|
|
# - The detector keeps a tiny internal buffer (max 14 bytes) and a simple FSM.
|
|
# - It is transport-agnostic: you feed() bytes from any source (UART, I2C, ...).
|
|
# - "Absolute offset" is optional. If you pass a base offset for each chunk,
|
|
# the detector computes the packet's start offset precisely.
|
|
#
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional, Callable
|
|
|
|
|
|
class PacketDetector:
|
|
"""Realtime detector for fixed 14-byte packets starting with EF FE and ending with EE.
|
|
|
|
Usage
|
|
-----
|
|
det = PacketDetector(on_packet=my_handler)
|
|
det.feed(data, t_ns, abs_off_start)
|
|
|
|
Where `abs_off_start` is the absolute ring offset of `data[0]`.
|
|
"""
|
|
|
|
__slots__ = (
|
|
"_on_packet",
|
|
"_buf",
|
|
"_count",
|
|
"_searching",
|
|
"_have_ef",
|
|
"_pkt_len",
|
|
"_start0",
|
|
"_start1",
|
|
"_end_byte",
|
|
"_pending_first_abs_off",
|
|
"_pending_first_ts_ns",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
on_packet: Optional[Callable[[int, bytes, int], None]] = None,
|
|
*,
|
|
start0: int = 0xEF,
|
|
start1: int = 0xFE,
|
|
end_byte: int = 0xEE,
|
|
length: int = 14,
|
|
) -> None:
|
|
self._on_packet = on_packet
|
|
self._buf = bytearray()
|
|
self._count = 0
|
|
self._searching = True
|
|
self._have_ef = False # helper to detect EF FE efficiently
|
|
self._pkt_len = int(length)
|
|
self._start0 = int(start0) & 0xFF
|
|
self._start1 = int(start1) & 0xFF
|
|
self._end_byte = int(end_byte) & 0xFF
|
|
self._pending_first_abs_off = 0
|
|
self._pending_first_ts_ns = 0
|
|
|
|
# ---------------------------
|
|
# Configuration
|
|
# ---------------------------
|
|
def set_handler(self, fn: Optional[Callable[[int, bytes, int], None]]):
|
|
"""Register or clear the on_packet callback."""
|
|
self._on_packet = fn
|
|
|
|
# ---------------------------
|
|
# Core detection
|
|
# ---------------------------
|
|
def feed(self, data: bytes, t_ns: int, abs_off_start: int = 0) -> None:
|
|
"""Consume a chunk of bytes and emit packets via callback when found.
|
|
|
|
Parameters
|
|
----------
|
|
data : bytes
|
|
Newly received bytes.
|
|
t_ns : int
|
|
Timestamp for *this chunk* (e.g., when the first byte was read).
|
|
We use this for the first-byte time if desired; for simplicity the
|
|
emitted event uses the completion time (call site passes a fresh now).
|
|
abs_off_start : int
|
|
Absolute offset of data[0] within the upstream ring buffer. Used to
|
|
compute the absolute start offset of detected packets.
|
|
"""
|
|
if not data:
|
|
return
|
|
|
|
# Fast path: work byte-by-byte, minimal branching
|
|
for i, b in enumerate(data):
|
|
bi = b & 0xFF
|
|
|
|
if self._searching:
|
|
# Find EF FE start sequence without growing the buffer
|
|
if not self._have_ef:
|
|
self._have_ef = bi == self._start0
|
|
continue
|
|
else:
|
|
if bi == self._start1:
|
|
# Start found: reset buffer to EF FE
|
|
self._buf.clear()
|
|
self._buf.append(self._start0)
|
|
self._buf.append(self._start1)
|
|
self._count = 2
|
|
self._searching = False
|
|
self._pending_first_abs_off = (
|
|
abs_off_start + i - 1
|
|
) # index of EF
|
|
self._pending_first_ts_ns = t_ns
|
|
# Regardless, reset EF tracker (we only accept EF FE)
|
|
self._have_ef = bi == self._start0
|
|
continue
|
|
|
|
# Accumulating a candidate packet
|
|
self._buf.append(bi)
|
|
self._count += 1
|
|
|
|
if self._count < self._pkt_len:
|
|
continue
|
|
|
|
# We have exactly `length` bytes; check end marker
|
|
if self._buf[-1] == self._end_byte:
|
|
# Detected a full packet → emit
|
|
if self._on_packet is not None:
|
|
# Completion timestamp should be the call-site's fresh now.
|
|
# We re-use t_ns here; callers should pass a fresh now when feeding.
|
|
self._on_packet(t_ns, bytes(self._buf), self._pending_first_abs_off)
|
|
# Reset to search for the next packet, even if the tail byte was wrong
|
|
self._buf.clear()
|
|
self._count = 0
|
|
self._searching = True
|
|
self._have_ef = False
|
|
self._pending_first_abs_off = 0
|
|
self._pending_first_ts_ns = 0
|
|
|
|
def reset(self) -> None:
|
|
"""Clear detector state (used on reconnects, etc.)."""
|
|
self._buf.clear()
|
|
self._count = 0
|
|
self._searching = True
|
|
self._have_ef = False
|
|
self._pending_first_abs_off = 0
|
|
self._pending_first_ts_ns = 0
|