"""Standalone digital-IO monitor/logger for the CAN-IO board. Deliberately separate from the CAN path: it talks to the board over the USB '!'/'@' channel (see can-io/firmware/src/usb_bridge.h), which keeps working when the CAN bus has no other powered node - the situation you're in before the EPS gets its 12V enable, and exactly when the CAN status frame goes missing because the shared TX queue backs up behind 100ms-timing-out transmits. Logs each direction as its own signal: IN1 = the car's 12V ignition/enable signal arriving at this board OUT1 = the 12V signal this board repeats onward to the EPS CSV columns: t, uptime_s, in1..in4, out1, out2, event event is "change" for a real transition, "sample" for periodic polls. """ from __future__ import annotations import csv import threading import time from pathlib import Path from typing import Optional class DioLogger: """Appends IO states to a CSV, noting which rows were real transitions.""" def __init__(self, path: Path): self._file = open(path, "w", newline="") self._writer = csv.writer(self._file) self._writer.writerow( ["t", "wall", "uptime_s", "in1", "in2", "in3", "in4", "out1", "out2", "event"] ) self._lock = threading.Lock() self._t0 = time.time() self._prev: Optional[tuple[int, int]] = None def record(self, inputs: int, outputs: int, uptime_s: int) -> bool: """Returns True if this was a change (not just a periodic sample).""" with self._lock: changed = self._prev is not None and (inputs, outputs) != self._prev first = self._prev is None self._prev = (inputs, outputs) now = time.time() self._writer.writerow( [ round(now - self._t0, 4), round(now, 4), uptime_s, *[(inputs >> i) & 1 for i in range(4)], outputs & 1, (outputs >> 1) & 1, "change" if changed else ("boot" if first else "sample"), ] ) self._file.flush() # keep the log usable while a session is still running return changed def close(self) -> None: self._file.close() class DioMonitor: """Polls the board's IO channel on its own thread, independent of any CAN relay/replay activity, optionally logging every update. `owns_reads` must be False whenever something else (the Gateway's relay thread, the replay loop) is already calling read_frame() on the same adapter: two threads reading one serial port interleave partial lines and corrupt both streams. In that case this only writes '@G' requests and samples the io_state the other reader populates. Set it True when this monitor is the sole user of the port. """ def __init__(self, adapter, logger: Optional[DioLogger] = None, poll_interval: float = 0.25, owns_reads: bool = False): self.adapter = adapter self.logger = logger self.poll_interval = poll_interval self.owns_reads = owns_reads self.inputs = 0 self.outputs = 0 self.uptime_s = 0 self.last_update: Optional[float] = None self.updates = 0 self.changes = 0 self._stop = threading.Event() self._thread: Optional[threading.Thread] = None def is_running(self) -> bool: return bool(self._thread and self._thread.is_alive()) def start(self) -> None: if self.is_running(): return self._stop.clear() self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() def stop(self) -> None: self._stop.set() if self._thread: self._thread.join(timeout=2) if self.logger is not None: self.logger.close() def set_output(self, index: int, on: bool) -> None: self.adapter.set_output(index, on) def set_output_verified(self, index: int, on: bool, attempts: int = 4, timeout: float = 0.6) -> bool: """Command an output and confirm the board actually reports it, retrying. A single '@S' write can be lost (USB CDC hiccup, or the line arriving while the board is mid-parse), which shows up as an output that occasionally just doesn't switch. Re-asserting until the reported state matches makes that self-healing - the command is idempotent, so a duplicate is harmless. """ want = 1 if on else 0 for _ in range(attempts): self.adapter.set_output(index, on) deadline = time.monotonic() + timeout while time.monotonic() < deadline: self.adapter.request_io() time.sleep(0.08) state = self.adapter.io_state if state is not None and ((state.outputs >> index) & 1) == want: self.outputs = state.outputs return True return False def _run(self) -> None: while not self._stop.is_set(): self.adapter.request_io() if self.owns_reads: deadline = time.monotonic() + self.poll_interval while time.monotonic() < deadline: self.adapter.read_frame(timeout=0.05) else: time.sleep(self.poll_interval) state = self.adapter.io_state if state is None: continue self.inputs = state.inputs self.outputs = state.outputs self.uptime_s = state.uptime_s self.last_update = state.recv_time self.updates += 1 if self.logger is not None and self.logger.record(state.inputs, state.outputs, state.uptime_s): self.changes += 1