235 lines
7.7 KiB
Python
235 lines
7.7 KiB
Python
# serial_manager.py
|
|
import threading
|
|
import time
|
|
import csv
|
|
from collections import deque
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Deque, Dict, List, Optional, Tuple
|
|
|
|
import serial # provided by python3-serial
|
|
|
|
|
|
@dataclass
|
|
class SerialConfig:
|
|
"""
|
|
Configuration for the read-only serial intake.
|
|
"""
|
|
port: str = "/dev/ttyUSB0"
|
|
baudrate: int = 115200
|
|
bytesize: int = serial.EIGHTBITS
|
|
parity: str = serial.PARITY_NONE
|
|
stopbits: int = serial.STOPBITS_ONE
|
|
timeout: float = 0.05
|
|
rtscts: bool = False
|
|
dsrdtr: bool = False
|
|
xonxoff: bool = False
|
|
ring_capacity: int = 5000
|
|
# If set, a single "generic" CSV will be written here (append mode).
|
|
# If you want segregated CSVs per message type, leave this as None and
|
|
# supply an `on_message` callback that writes where you want.
|
|
csv_log_path: Optional[str] = None # e.g. "/home/pi/hmi/serial_log.csv"
|
|
|
|
|
|
class SerialStore:
|
|
"""
|
|
Thread-safe store for recent parsed messages and intake stats.
|
|
Stores parsed dicts as returned by the decoder.
|
|
"""
|
|
def __init__(self, capacity: int):
|
|
self._buf: Deque[Dict[str, Any]] = deque(maxlen=capacity)
|
|
self._lock = threading.Lock()
|
|
self._stats = {
|
|
"frames_in": 0,
|
|
"frames_ok": 0,
|
|
"frames_bad": 0,
|
|
"restarts": 0,
|
|
"last_err": "",
|
|
}
|
|
self._latest_by_id: Dict[str, Dict[str, Any]] = {}
|
|
|
|
def add(self, msg: Dict[str, Any], ok: bool = True):
|
|
with self._lock:
|
|
self._buf.append(msg)
|
|
self._stats["frames_in"] += 1
|
|
if ok:
|
|
self._stats["frames_ok"] += 1
|
|
else:
|
|
self._stats["frames_bad"] += 1
|
|
mid = msg.get("msg_id")
|
|
if mid:
|
|
self._latest_by_id[mid] = msg
|
|
|
|
def latest(self, n: int = 100) -> List[Dict[str, Any]]:
|
|
with self._lock:
|
|
return list(self._buf)[-n:]
|
|
|
|
def latest_by_id(self) -> Dict[str, Dict[str, Any]]:
|
|
with self._lock:
|
|
return dict(self._latest_by_id)
|
|
|
|
def stats(self) -> Dict[str, Any]:
|
|
with self._lock:
|
|
return dict(self._stats)
|
|
|
|
def set_error(self, err: str):
|
|
with self._lock:
|
|
self._stats["last_err"] = err
|
|
|
|
def inc_restart(self):
|
|
with self._lock:
|
|
self._stats["restarts"] += 1
|
|
|
|
|
|
class SerialReader:
|
|
"""
|
|
Background read-only serial reader.
|
|
|
|
Args:
|
|
cfg: SerialConfig
|
|
store: SerialStore
|
|
decoder: function(buffer: bytes) ->
|
|
(messages: List[Tuple[raw_frame: bytes, parsed: Dict]], remaining: bytes, errors: int)
|
|
on_message: optional callback called for each parsed dict (e.g., segregated CSV logger)
|
|
"""
|
|
def __init__(
|
|
self,
|
|
cfg: SerialConfig,
|
|
store: SerialStore,
|
|
decoder: Callable[[bytes], Tuple[List[Tuple[bytes, Dict[str, Any]]], bytes, int]],
|
|
on_message: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
):
|
|
self.cfg = cfg
|
|
self.store = store
|
|
self.decoder = decoder
|
|
self.on_message = on_message
|
|
|
|
self._ser: Optional[serial.Serial] = None
|
|
self._th: Optional[threading.Thread] = None
|
|
self._stop = threading.Event()
|
|
self._buffer = b""
|
|
|
|
# Optional generic CSV (single file) if cfg.csv_log_path is set
|
|
self._csv_file = None
|
|
self._csv_writer = None
|
|
|
|
# ---------- lifecycle ----------
|
|
def start(self):
|
|
self._stop.clear()
|
|
self._open_serial()
|
|
self._open_csv()
|
|
self._th = threading.Thread(target=self._run, name="SerialReader", daemon=True)
|
|
self._th.start()
|
|
|
|
def stop(self):
|
|
self._stop.set()
|
|
if self._th and self._th.is_alive():
|
|
self._th.join(timeout=2.0)
|
|
self._close_serial()
|
|
self._close_csv()
|
|
|
|
# ---------- internals ----------
|
|
def _open_serial(self):
|
|
try:
|
|
self._ser = serial.Serial(
|
|
port=self.cfg.port,
|
|
baudrate=self.cfg.baudrate,
|
|
bytesize=self.cfg.bytesize,
|
|
parity=self.cfg.parity,
|
|
stopbits=self.cfg.stopbits,
|
|
timeout=self.cfg.timeout,
|
|
rtscts=self.cfg.rtscts,
|
|
dsrdtr=self.cfg.dsrdtr,
|
|
xonxoff=self.cfg.xonxoff,
|
|
)
|
|
except Exception as e:
|
|
self.store.set_error(f"Open error: {e}")
|
|
self._ser = None
|
|
|
|
def _close_serial(self):
|
|
try:
|
|
if self._ser and self._ser.is_open:
|
|
self._ser.close()
|
|
except Exception:
|
|
pass
|
|
self._ser = None
|
|
|
|
def _open_csv(self):
|
|
if not self.cfg.csv_log_path:
|
|
return
|
|
try:
|
|
self._csv_file = open(self.cfg.csv_log_path, "a", newline="")
|
|
self._csv_writer = csv.writer(self._csv_file)
|
|
# Write header only if file is empty (avoid duplicates on restart)
|
|
if self._csv_file.tell() == 0:
|
|
self._csv_writer.writerow(["ts_ms", "msg_id", "raw_hex", "parsed"])
|
|
self._csv_file.flush()
|
|
except Exception as e:
|
|
self.store.set_error(f"CSV open error: {e}")
|
|
self._csv_file = None
|
|
self._csv_writer = None
|
|
|
|
def _close_csv(self):
|
|
try:
|
|
if self._csv_file:
|
|
self._csv_file.close()
|
|
except Exception:
|
|
pass
|
|
self._csv_file = None
|
|
self._csv_writer = None
|
|
|
|
def _log_csv(self, raw: bytes, parsed: Dict[str, Any]):
|
|
"""Write to the optional single generic CSV."""
|
|
if not self._csv_writer:
|
|
return
|
|
try:
|
|
self._csv_writer.writerow(
|
|
[parsed.get("ts_ms"), parsed.get("msg_id"), raw.hex(), parsed]
|
|
)
|
|
self._csv_file.flush()
|
|
except Exception as e:
|
|
self.store.set_error(f"CSV write error: {e}")
|
|
|
|
def _run(self):
|
|
backoff = 0.5
|
|
while not self._stop.is_set():
|
|
if not self._ser or not self._ser.is_open:
|
|
# reconnect with exponential backoff (capped)
|
|
self._close_serial()
|
|
time.sleep(backoff)
|
|
self.store.inc_restart()
|
|
self._open_serial()
|
|
backoff = min(backoff * 1.5, 5.0)
|
|
continue
|
|
|
|
backoff = 0.5
|
|
|
|
try:
|
|
data = self._ser.read(4096) # non-blocking due to timeout
|
|
if data:
|
|
self._buffer += data
|
|
frames, remaining, errors = self.decoder(self._buffer)
|
|
self._buffer = remaining
|
|
|
|
for raw, parsed in frames:
|
|
# store
|
|
self.store.add(parsed, ok=True)
|
|
# optional generic CSV
|
|
self._log_csv(raw, parsed)
|
|
# optional segregated sink
|
|
if self.on_message:
|
|
try:
|
|
self.on_message(parsed)
|
|
except Exception as e:
|
|
self.store.set_error(f"CSV sink error: {e}")
|
|
|
|
# count decode errors
|
|
for _ in range(errors):
|
|
self.store.add({"error": "decode"}, ok=False)
|
|
else:
|
|
time.sleep(0.01)
|
|
|
|
except Exception as e:
|
|
self.store.set_error(f"Read/Decode error: {e}")
|
|
self._close_serial()
|
|
time.sleep(0.5)
|