"""Decode BMW E8x PT-CAN frames and keep rolling state for the UI. Reuses the signal definitions from files/decode_ptcan.py (which mirrors files/PTCAN_protocol.md and files/bmw_e8x_ptcan.dbc) so the CLI decoder and the GUI stay in sync. """ from __future__ import annotations import sys import threading from collections import defaultdict, deque from pathlib import Path from typing import Optional sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "files")) from decode_ptcan import CHECKSUMS, COUNTERS, NAMES, SIGNALS, TERMINAL, i16le, ocsum, u16le # noqa: E402,F401 HISTORY_LEN = 6000 # samples kept per ID for charting RATE_WINDOW = 20 # frames used for the rolling Hz estimate class FrameStore: """Thread-safe: a background capture thread (or replay loop) feeds it, the Streamlit render pass reads snapshots from it.""" def __init__(self) -> None: self._lock = threading.Lock() self._init_state() def _init_state(self) -> None: self.latest: dict[int, dict] = {} self.prev_bytes: dict[int, bytes] = {} self.counts: dict[int, int] = defaultdict(int) self.recent_times: dict[int, deque] = defaultdict(lambda: deque(maxlen=RATE_WINDOW)) self.history: dict[int, deque] = defaultdict(lambda: deque(maxlen=HISTORY_LEN)) self.t0: Optional[float] = None def reset(self) -> None: with self._lock: self._init_state() def feed(self, arbitration_id: int, data: bytes, t: float) -> None: with self._lock: if self.t0 is None: self.t0 = t decoded = {} for name, (fn, unit) in SIGNALS.get(arbitration_id, {}).items(): try: decoded[name] = (fn(data), unit) except Exception: pass prev = self.prev_bytes.get(arbitration_id) changed = bytes(a ^ b for a, b in zip(prev, data)) if prev is not None and len(prev) == len(data) else None self.prev_bytes[arbitration_id] = data self.latest[arbitration_id] = {"t": t, "data": data, "decoded": decoded, "changed": changed} self.counts[arbitration_id] += 1 self.recent_times[arbitration_id].append(t) self.history[arbitration_id].append((t, decoded)) def hz(self, arbitration_id: int) -> float: times = self.recent_times.get(arbitration_id) if not times or len(times) < 2: return 0.0 span = times[-1] - times[0] return (len(times) - 1) / span if span > 0 else 0.0 def snapshot(self) -> dict: """Shallow copy safe to read/render from outside the writer.""" with self._lock: return {"latest": dict(self.latest), "counts": dict(self.counts), "t0": self.t0} def series(self, arbitration_id: int, signal: str, window_s: Optional[float] = None): with self._lock: hist = list(self.history.get(arbitration_id, ())) if window_s is not None and hist: cutoff = hist[-1][0] - window_s hist = [h for h in hist if h[0] >= cutoff] xs, ys = [], [] for t, decoded in hist: if signal in decoded: xs.append(t) ys.append(decoded[signal][0]) return xs, ys