"""Bidirectional CAN gateway between the car bus and the EPS bus. car (CANdapter) <==> gateway <==> EPS (CAN-IO board USB bridge) Two pump threads, one per direction, each consulting a FilterRules instance for whether to forward a given arbitration ID. Starts fully transparent (every frame relayed both ways) - block IDs at runtime via the rules objects (see gateway_app.py for a GUI, or drive FilterRules directly from a script). Every frame seen is counted (passed/blocked) and, if a FrameStore is given for that side, fed to it for live decoding/monitoring - reuses decoder/ptcan_decoder.py so this looks the same as the replay dashboard. """ from __future__ import annotations import csv import threading import time from collections import Counter from pathlib import Path from typing import Optional from .rules import FilterRules class GatewayLogger: """CSV log of every frame the gateway sees, whether relayed or not.""" def __init__(self, path: Path): self._file = open(path, "w", newline="") self._writer = csv.writer(self._file) self._writer.writerow(["t", "direction", "arbitration_id", "is_extended", "dlc", "data_hex", "relayed"]) self._lock = threading.Lock() self._t0 = time.time() def write(self, direction: str, arbitration_id: int, is_extended: bool, data: bytes, relayed: bool) -> None: with self._lock: self._writer.writerow( [ round(time.time() - self._t0, 4), direction, f"{arbitration_id:X}", int(is_extended), len(data), data.hex().upper(), int(relayed), ] ) def close(self) -> None: self._file.close() class Stats: def __init__(self): self._lock = threading.Lock() self.passed: Counter = Counter() self.blocked: Counter = Counter() def record(self, arbitration_id: int, passed: bool) -> None: with self._lock: (self.passed if passed else self.blocked)[arbitration_id] += 1 def snapshot(self): with self._lock: return dict(self.passed), dict(self.blocked) def reset(self) -> None: with self._lock: self.passed.clear() self.blocked.clear() class Gateway: """Owns two already-open adapters and relays frames between them.""" def __init__( self, car_adapter, eps_adapter, car_to_eps_rules: Optional[FilterRules] = None, eps_to_car_rules: Optional[FilterRules] = None, car_store=None, eps_store=None, logger: Optional[GatewayLogger] = None, ): self.car = car_adapter self.eps = eps_adapter self.car_to_eps_rules = car_to_eps_rules or FilterRules() self.eps_to_car_rules = eps_to_car_rules or FilterRules() self.car_store = car_store self.eps_store = eps_store self.logger = logger self.stats = Stats() self._stop = threading.Event() self._threads: list[threading.Thread] = [] self._start_wall = time.time() def start(self) -> None: self._stop.clear() self._threads = [ threading.Thread(target=self._pump, args=("car->eps",), daemon=True), threading.Thread(target=self._pump, args=("eps->car",), daemon=True), ] for t in self._threads: t.start() def stop(self) -> None: self._stop.set() for t in self._threads: t.join(timeout=2) if self.logger is not None: self.logger.close() def is_running(self) -> bool: return any(t.is_alive() for t in self._threads) def _pump(self, direction: str) -> None: if direction == "car->eps": src, dst, rules, src_store = self.car, self.eps, self.car_to_eps_rules, self.car_store else: src, dst, rules, src_store = self.eps, self.car, self.eps_to_car_rules, self.eps_store while not self._stop.is_set(): frame = src.read_frame(timeout=0.5) if frame is None: continue allowed = rules.allows(frame.arbitration_id) if allowed: dst.send_frame(frame.arbitration_id, frame.data, frame.is_extended) self.stats.record(frame.arbitration_id, allowed) if src_store is not None: src_store.feed(frame.arbitration_id, frame.data, frame.recv_time - self._start_wall) if self.logger is not None: self.logger.write(direction, frame.arbitration_id, frame.is_extended, frame.data, allowed)