Reverse-engineer BMW E8x EPS for standalone operation

Tooling and findings for running an E8x/E9x electric power steering unit
outside its donor car, e.g. in an EV conversion.

Headline result: the EPS needs only two CAN messages plus a 12V enable
wire, not the 69-message set the car puts on the bus:
  0x130 CAS terminal status (100ms) brings the unit up
  0x1A0 DSC road speed (20ms) sets the assist level
Total required rate is 60 frames/s. Protocol write-up, including what is
proven vs. inferred and the open questions, is in eps-comms/.

Contents:
  adapters/   CANdapter (its SLCAN dialect differs) and generic SLCAN
  gateway/    car<->EPS relay, replay, message bench, EPS controller,
              4-tab Streamlit UI
  decoder/    PT-CAN frame decoding and live/replay sources
  can-io/     XIAO ESP32-S3 firmware: CAN IO board + USB-CAN bridge with
              a CAN-independent digital IO channel
  tools/      capture, bitrate scan, startup-order and session analysis,
              checksum solver
  captures/   reference working session + the replay set eps_control reads
This commit is contained in:
Luca 2026-08-29 19:34:43 +02:00
commit c32c4645b5
64 changed files with 128998 additions and 0 deletions

31
.gitignore vendored Normal file
View file

@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# macOS
.DS_Store
# PlatformIO build output and local IDE state (can-io/firmware)
.pio/
.pioenvs/
.piolibdeps/
.vscode/
!.vscode/extensions.json
# Streamlit
.streamlit/secrets.toml
# ---------------------------------------------------------------- captures --
# Session logs are large (tens of MB) and regenerable by re-running a test,
# so they stay out of git. The two reference captures below are excluded
# from this rule because they are inputs, not outputs:
# - replay_car_to_eps_*.csv is what gateway/eps_control.py reads to get
# genuine per-terminal-state frames; without it the EPS can't be driven.
# - gateway_20260829_164540.csv is the reference working session every
# analysis in eps-comms/ refers back to.
captures/*.csv
!captures/replay_car_to_eps_*.csv
!captures/gateway_20260829_164540.csv

0
adapters/__init__.py Normal file
View file

146
adapters/candapter.py Normal file
View file

@ -0,0 +1,146 @@
"""Minimal driver for the CANdapter (Ewert Energy Systems / candapter.com).
The CANdapter speaks a Lawicel-like ASCII protocol over its FTDI virtual
serial port, but diverges from real SLCAN in ways that break python-can's
stock slcan backend:
- Standard 11-bit frames are reported as ``Tiiildd..`` and extended 29-bit
frames as ``Xiiiiiiiildd..`` (real SLCAN uses lowercase ``t``/``T``
respectively) so the kernel slcan driver and python-can can't parse the
extended-ID frames at all.
- The optional millisecond timestamp (enabled with ``A1``) is appended
after the data bytes but is NOT counted in the DLC field, so it has to
be recovered from whatever is left over on the line.
"""
from __future__ import annotations
import dataclasses
import time
from typing import Optional
import serial
# Lawicel-style bitrate codes accepted by the 'S' command.
BITRATE_CODES = {
10_000: 0,
20_000: 1,
50_000: 2,
100_000: 3,
125_000: 4,
250_000: 5,
500_000: 6,
800_000: 7,
1_000_000: 8,
}
ACK = 0x06
BELL = 0x07
@dataclasses.dataclass
class CanFrame:
arbitration_id: int
is_extended: bool
data: bytes
timestamp_ms: Optional[int]
recv_time: float
class Candapter:
def __init__(self, port: str, bitrate: int, control_baud: int = 115200, timestamps: bool = False):
if bitrate not in BITRATE_CODES:
raise ValueError(f"Unsupported bitrate {bitrate}; choose one of {sorted(BITRATE_CODES)}")
self._ser = serial.Serial(port, control_baud, timeout=0.1)
self._buf = bytearray()
self._send_command("C") # close channel in case it was left open
self._send_command(f"S{BITRATE_CODES[bitrate]}")
self._send_command("A1" if timestamps else "A0")
self._send_command("O")
def _send_command(self, cmd: str) -> None:
self._ser.reset_input_buffer()
self._ser.write((cmd + "\r").encode("ascii"))
time.sleep(0.05)
self._ser.read(self._ser.in_waiting or 1) # drain ACK/BELL/text reply
def close(self) -> None:
try:
self._send_command("C")
finally:
self._ser.close()
def __enter__(self) -> "Candapter":
return self
def __exit__(self, *exc) -> None:
self.close()
def read_frame(self, timeout: Optional[float] = None) -> Optional[CanFrame]:
"""Read one frame, or None if nothing complete arrived within timeout."""
deadline = None if timeout is None else time.monotonic() + timeout
while True:
# Check every iteration, not just while waiting for bytes - a flood of
# malformed lines (e.g. wrong bitrate) would otherwise spin forever.
if deadline is not None and time.monotonic() >= deadline:
return None
nl = self._buf.find(b"\r")
if nl == -1:
chunk = self._ser.read(64)
if chunk:
self._buf.extend(chunk)
continue
line = bytes(self._buf[:nl])
del self._buf[: nl + 1]
if not line or chr(line[0]) not in ("t", "T", "x", "X"):
continue # ignore stray ACK/BELL bytes mixed into the stream
frame = self._parse_frame(line)
if frame is not None:
return frame
@staticmethod
def _parse_frame(line: bytes) -> Optional[CanFrame]:
text = line.decode("ascii", errors="replace")
prefix = text[0]
try:
# Observed on this adapter: lowercase 't' = standard 11-bit frame (real
# SLCAN convention). Extended frames haven't been observed yet, so both
# the documented 'X'/'x' and the real-SLCAN 'T' are accepted as 29-bit.
if prefix == "t":
id_hex, rest = text[1:4], text[4:]
is_extended = False
else: # "T", "x", or "X"
id_hex, rest = text[1:9], text[9:]
is_extended = True
dlc = int(rest[0], 16)
data_hex = rest[1 : 1 + dlc * 2]
data = bytes.fromhex(data_hex)
remainder = rest[1 + dlc * 2 :] # leftover = ms timestamp, if 'A1' was set
timestamp_ms = int(remainder, 16) if len(remainder) == 4 else None
return CanFrame(
arbitration_id=int(id_hex, 16),
is_extended=is_extended,
data=data,
timestamp_ms=timestamp_ms,
recv_time=time.time(),
)
except (ValueError, IndexError):
return None
def send_frame(self, arbitration_id: int, data: bytes, is_extended: bool = False) -> None:
"""Transmit a frame onto the bus.
Standard frames use the observed 't' RX convention. Extended frames use
'T' (the real-SLCAN convention) - this has NOT been verified against
real hardware, since no 29-bit traffic has been seen on this bus yet.
"""
if is_extended:
line = f"T{arbitration_id:08X}{len(data):X}{data.hex().upper()}"
else:
line = f"t{arbitration_id:03X}{len(data):X}{data.hex().upper()}"
self._ser.write((line + "\r").encode("ascii"))

177
adapters/slcan_adapter.py Normal file
View file

@ -0,0 +1,177 @@
"""Generic SLCAN (Lawicel) driver — for the CAN-IO board's USB bridge mode.
Unlike the CANdapter (see candapter.py), this targets a real, unmodified
SLCAN implementation: 't'/'T' frame prefixes, no extra un-counted bytes
after the data field. Written for firmware/src/usb_bridge.cpp on the
CAN-IO board, but works with any standard SLCAN device.
On top of plain SLCAN, the CAN-IO board adds a digital-IO channel on the
same serial link that does NOT depend on the CAN bus being alive: '!' lines
report IN/OUT state, '@' lines set outputs. See io_state / set_output /
request_io below, and usb_bridge.h for the firmware side.
"""
from __future__ import annotations
import dataclasses
import time
from typing import Optional
import serial
from .candapter import BITRATE_CODES, CanFrame
@dataclasses.dataclass
class IoState:
inputs: int # bit0 = IN1 ... bit3 = IN4
outputs: int # bit0 = OUT1, bit1 = OUT2
uptime_s: int
recv_time: float
def input_on(self, index: int) -> bool:
return bool(self.inputs & (1 << index))
def output_on(self, index: int) -> bool:
return bool(self.outputs & (1 << index))
class SlcanAdapter:
def __init__(self, port: str, bitrate: int, control_baud: int = 115200):
if bitrate not in BITRATE_CODES:
raise ValueError(f"Unsupported bitrate {bitrate}; choose one of {sorted(BITRATE_CODES)}")
self._ser = serial.Serial(port, control_baud, timeout=0.1)
self._buf = bytearray()
self.io_state: Optional[IoState] = None
self._send_command("C") # close channel in case it was left open
self._send_command(f"S{BITRATE_CODES[bitrate]}")
self._send_command("O")
def _send_command(self, cmd: str) -> None:
self._ser.reset_input_buffer()
self._ser.write((cmd + "\r").encode("ascii"))
time.sleep(0.05)
self._ser.read(self._ser.in_waiting or 1) # drain ACK/BELL/text reply
def close(self) -> None:
try:
self._send_command("C")
finally:
self._ser.close()
def __enter__(self) -> "SlcanAdapter":
return self
def __exit__(self, *exc) -> None:
self.close()
def send_frame(self, arbitration_id: int, data: bytes, is_extended: bool = False) -> None:
self._ser.write(self._frame_line(arbitration_id, data, is_extended).encode("ascii"))
def send_frames(self, frames) -> None:
"""Write several frames in one go - one syscall per batch instead of
per frame, which is the difference between holding a 10ms cycle and
not when a few dozen IDs come due together."""
if not frames:
return
blob = "".join(self._frame_line(aid, data, ext) for aid, data, ext in frames)
self._ser.write(blob.encode("ascii"))
@staticmethod
def _frame_line(arbitration_id: int, data: bytes, is_extended: bool = False) -> str:
if is_extended:
return f"T{arbitration_id:08X}{len(data):X}{data.hex().upper()}\r"
return f"t{arbitration_id:03X}{len(data):X}{data.hex().upper()}\r"
# ---- CAN-independent digital IO (CAN-IO board only) -------------------
def set_output(self, index: int, on: bool) -> None:
"""Drive OUT<index+1>. Takes effect even with a dead/one-node CAN bus."""
self._ser.write(f"@S{index}{1 if on else 0}\r".encode("ascii"))
def toggle_output(self, index: int) -> None:
self._ser.write(f"@T{index}\r".encode("ascii"))
def request_io(self) -> None:
"""Ask for an immediate IO report; the reply updates self.io_state
the next time read_frame() runs."""
self._ser.write(b"@G\r")
def read_frame(self, timeout: Optional[float] = None) -> Optional[CanFrame]:
"""Read one frame, or None if nothing complete arrived within timeout.
Also consumes '!' IO report lines as they go by, updating io_state -
so callers that poll this in a loop get IO tracking for free.
"""
deadline = None if timeout is None else time.monotonic() + timeout
while True:
if deadline is not None and time.monotonic() >= deadline:
return None
nl = self._buf.find(b"\r")
if nl == -1:
# Only take what's already buffered: a plain read(64) blocks
# until 64 bytes arrive or the port's own timeout expires,
# which would stall a caller that asked for a few ms and
# wreck the transmit cadence of anything sharing this thread.
waiting = self._ser.in_waiting
if waiting:
self._buf.extend(self._ser.read(waiting))
elif deadline is None:
self._buf.extend(self._ser.read(1))
else:
time.sleep(0.0005)
continue
line = bytes(self._buf[:nl])
del self._buf[: nl + 1]
# ACK (0x06) / BELL (0x07) replies carry no terminator of their
# own, so they end up glued to the front of whatever line comes
# next - strip them before looking at the prefix.
line = line.lstrip(b"\x06\x07")
if not line:
continue
if line[0:1] == b"!":
self._parse_io(line)
continue
if chr(line[0]) not in ("t", "T"):
continue # ignore stray ACK/BELL bytes mixed into the stream
frame = self._parse_frame(line)
if frame is not None:
return frame
def _parse_io(self, line: bytes) -> None:
text = line.decode("ascii", errors="replace")
try:
self.io_state = IoState(
inputs=int(text[1:3], 16),
outputs=int(text[3:5], 16),
uptime_s=int(text[5:13], 16),
recv_time=time.time(),
)
except (ValueError, IndexError):
pass
@staticmethod
def _parse_frame(line: bytes) -> Optional[CanFrame]:
text = line.decode("ascii", errors="replace")
is_extended = text[0] == "T"
try:
if is_extended:
id_hex, rest = text[1:9], text[9:]
else:
id_hex, rest = text[1:4], text[4:]
dlc = int(rest[0], 16)
data = bytes.fromhex(rest[1 : 1 + dlc * 2])
return CanFrame(
arbitration_id=int(id_hex, 16),
is_extended=is_extended,
data=data,
timestamp_ms=None,
recv_time=time.time(),
)
except (ValueError, IndexError):
return None

67
can-io/PROTOCOL.md Normal file
View file

@ -0,0 +1,67 @@
# CAN Protocol — CAN IO Board
Bus: **500 kbit/s**, 11-bit (standard) identifiers, classic CAN 2.0.
Base ID: **0x100** (configurable in `firmware/include/config.h`).
| ID | Direction | Purpose |
|--------------|-------------|-------------------------------|
| base + 0 = `0x100` | board → bus | Status frame |
| base + 1 = `0x101` | bus → board | Command frame |
Bit numbering in all bitmaps: bit0 = IN1/OUT1, bit1 = IN2/OUT2, … 1 = active.
## Status frame — ID 0x100, DLC 8
Sent every second (heartbeat), immediately on any input/output change,
in response to `GET_STATUS`, and once at boot.
| Byte | Meaning |
|------|------------------------------------------------|
| 0 | Input bitmap (bit0=IN1 … bit3=IN4) |
| 1 | Output bitmap (bit0=OUT1, bit1=OUT2) |
| 2 | Reason: 0=periodic, 1=change, 2=request, 3=boot|
| 3 | Reserved (0) |
| 4–7 | Uptime in seconds, uint32 little-endian |
Example: `05 02 01 00 3C 00 00 00` → IN1+IN3 active, OUT2 on,
reason=change, uptime 60 s.
## Command frame — ID 0x101
Byte 0 selects the command. Malformed/unknown frames are ignored.
| Cmd | Name | DLC | Layout |
|------|-------------|-----|--------------------------------------------|
| 0x00 | GET_STATUS | ≥1 | `[00]` → board replies with a status frame |
| 0x01 | SET_OUTPUT | ≥3 | `[01, index, value]` index 0-based, value 0=off / ≠0=on |
| 0x02 | SET_ALL | ≥3 | `[02, mask, values]` outputs where mask bit=1 get the corresponding values bit |
| 0x03 | TOGGLE | ≥2 | `[03, index]` |
Examples (send to `0x101`):
| Bytes | Effect |
|--------------|---------------------------|
| `00` | request status |
| `01 00 01` | OUT1 on |
| `01 01 00` | OUT2 off |
| `02 03 03` | OUT1 + OUT2 both on |
| `02 03 00` | OUT1 + OUT2 both off |
| `03 00` | toggle OUT1 |
Every accepted output change triggers an immediate status frame
(reason = change), so the bus always confirms what actually happened —
there is no separate ACK.
## Local rules (run on the board itself)
Evaluated on the activation edge of an input; releasing does nothing
(outputs latch):
| Input | Action |
|-------|-----------|
| IN1 | OUT1 → ON |
| IN2 | OUT1 → OFF|
| IN3 | OUT2 → ON |
| IN4 | OUT2 → OFF|
Inputs already active at power-up are reported but do **not** fire rules.

147
can-io/README.md Normal file
View file

@ -0,0 +1,147 @@
# CAN IO Board — XIAO ESP32-S3
A small CAN bus IO node: 4 optocoupler inputs, 2 relay outputs, built on a
Seeed Studio XIAO ESP32-S3 with an SN65HVD230 transceiver and the ESP32's
built-in TWAI controller (`driver/twai.h`).
- Reports input/output state on the bus (heartbeat + immediately on change)
- Outputs controllable over CAN (set / set-all / toggle)
- Local set/reset rules: IN1→OUT1 on, IN2→OUT1 off, IN3→OUT2 on, IN4→OUT2 off
- Direct passthrough: an input can continuously mirror onto an output (e.g.
repeating the car's 12V wake signal straight through to another unit) -
see `PASSTHROUGH_MAP` in `config.h`
- Optional USB-CAN bridge: mirrors every frame on the bus over USB serial as
SLCAN ASCII, and injects frames sent back - turns the board into a
transparent adapter for its own bus on top of its IO duties. See
`USB_BRIDGE_ENABLE` in `config.h` and `firmware/src/usb_bridge.cpp`.
- Full frame formats: see [PROTOCOL.md](PROTOCOL.md)
```
can-io-board/
├── firmware/ PlatformIO project (Arduino framework + FreeRTOS)
│ ├── include/config.h ← everything tweakable lives here
│ └── src/ ← one module per concern, documented headers
├── gui/ Python/Tkinter tool for an SLCAN adapter
├── PROTOCOL.md CAN frame reference
└── README.md this file
```
## Hardware
| GPIO | XIAO pin | Function | Notes |
|------|----------|----------|--------------------------------|
| 1 | D0 / A0 | CAN TX | → SN65HVD230 D |
| 2 | D1 / A1 | CAN RX | ← SN65HVD230 R |
| 3 | D2 / A2 | IN1 | optocoupler, active low |
| 4 | D3 / A3 | IN2 | optocoupler, active low |
| 5 | D4 / SDA | IN3 | optocoupler, active low |
| 6 | D5 / SCL | IN4 | optocoupler, active low |
| 43 | D6 | OUT1 | darlington → relay 1 |
| 44 | D7 | OUT2 | darlington → relay 2 |
| 21 | — | LED | on-board user LED, heartbeat |
Electrical notes:
- Inputs use internal pull-ups; the opto transistor pulls the pin to GND when
the external contact/button closes. Debounce is done in software (40 ms).
If your optos drive the pin high instead, set `INPUTS_ACTIVE_LOW 0` in
`config.h`.
- Outputs are active high into the darlington (`OUTPUTS_ACTIVE_HIGH 1`).
- SN65HVD230 runs at 3.3 V — direct connection, no level shifting. Remember
bus termination (120 Ω at both bus ends).
### Boot behaviour of GPIO43/44 (worth knowing!)
GPIO43/44 are UART0 TX/RX. The application never uses UART0 (the console is
native USB CDC), but the **ROM bootloader** briefly drives GPIO43 as UART TX
at every reset: boot messages plus an idle-high level until the firmware
reclaims the pin (well under a second). Depending on your darlington input
network, relay 1 may click briefly at power-up, and a pull-up on GPIO44
could do the same for relay 2.
Mitigations if it bothers you: a ~10 kΩ pull-down on each darlington input
helps GPIO44; GPIO43 is actively driven, so if the short pulse is a real
problem, move the outputs to free pins (e.g. GPIO7/8, D8/D9) — it's a
two-line change in `config.h`.
## Building & flashing
```bash
cd firmware
pio run # build
pio run -t upload # flash over USB
pio device monitor # logs via native USB CDC, 115200
```
## Firmware architecture
Plain Arduino framework + FreeRTOS primitives. `setup()` only wires modules
together; everything runs in tasks:
| Task | File | Prio | Purpose |
|-----------------|---------------|------|-------------------------------------------|
| `input` | io_input.cpp | 8 | 5 ms scan, integrator debounce (8 samples) |
| `output` | io_output.cpp | 8 | sole owner of relay pins, fed by a queue |
| `can_rx` | can_bus.cpp | 10 | receive + dispatch command frames |
| `can_tx` | can_bus.cpp | 9 | transmit queued frames |
| `can_alert` | can_bus.cpp | 9 | bus health, automatic bus-off recovery |
| `status` | status.cpp | 5 | heartbeat + event status frames |
FreeRTOS plumbing: a **queue** into the output task (single GPIO writer, no
races), a **queue** into the CAN TX task, an **event group** driving the
status reporter (change/request/boot bits; the wait timeout doubles as the
heartbeat timer), and a **mutex** around the shared IO state. CAN tasks are
pinned to core 0, IO tasks to core 1.
Behaviour details:
- Input rules fire on the **activation edge** only; releasing a button does
nothing, so outputs latch (set/reset stations).
- Inputs already active at boot are reported but do not fire rules.
- Relays are forced OFF first thing in `setup()`.
- Every accepted change (CAN or local) is confirmed by an immediate status
frame — the bus is always the source of truth.
- Bus-off (e.g. shorted CAN lines) recovers automatically.
All tunables — pins, polarities, debounce, IDs, bitrate, rules, priorities —
are in `firmware/include/config.h`.
## USB bridge mode
With `USB_BRIDGE_ENABLE 1` (the default), the board's USB serial doubles as
a plain SLCAN adapter for its own bus: every frame it receives is mirrored
out as `t`/`T` + id + dlc + data (no trailing timestamp), and the same
format sent back is injected onto the bus. This is what lets a PC-side tool
treat the board as a transparent gateway node - see `adapters/slcan_adapter.py`
and `gateway/` at the project root.
While the bridge is on, all human-readable debug logging is compiled out
(`DEBUG_LOG()` in `config.h`) so it can't corrupt the frame stream. Set
`USB_BRIDGE_ENABLE 0` and reflash to get plain-text logs back for local
debugging with `pio device monitor`.
## GUI (SLCAN)
Tkinter tool that talks to the board through any SLCAN adapter (e.g. your
second ESP32 running slcan firmware).
```bash
cd gui
pip install -r requirements.txt
python can_io_gui.py
```
Pick the adapter's serial port, keep 500000 bit/s and base ID 0x100,
Connect. You get live LEDs for IN1–4 / OUT1–2, On/Off/Toggle buttons per
output, a status request button and a decoded frame log. "STALE" in the
status bar means no status frame for >3.5 s (board off / bus problem).
## Troubleshooting
- **No frames at all**: check transceiver RS pin (pin 8) — tie to GND (or
≤10 kΩ) for high-speed mode; check termination and TX/RX not swapped.
- **TX failed / error-passive logs**: usually a bitrate mismatch or a
one-node bus (CAN needs a second node to ACK, the SLCAN adapter counts).
- **GUI can't open the port**: close other serial monitors; on Linux add
yourself to the `dialout` group.
- **Relay clicks at power-up**: see "Boot behaviour of GPIO43/44" above.

5
can-io/firmware/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

View file

@ -0,0 +1,165 @@
/**
* @file config.h
* @brief Central configuration for the CAN IO board.
*
* Everything tweakable lives here: pin mapping, signal polarities, debounce
* timing, CAN identifiers/bitrate and the local input->output rules.
* The rest of the code should never contain magic numbers.
*/
#pragma once
#include <stdint.h>
#include "driver/twai.h"
/* ------------------------------------------------------------------ pins --
* XIAO ESP32-S3 pin map. Numbers are raw ESP32-S3 GPIO numbers, NOT the
* "D" numbers on the silkscreen.
*
* GPIO 1 (D0) CAN TX -> SN65HVD230 D (driver input)
* GPIO 2 (D1) CAN RX <- SN65HVD230 R (receiver output)
* GPIO 3 (D2) IN1 <- optocoupler
* GPIO 4 (D3) IN2 <- optocoupler
* GPIO 5 (D4) IN3 <- optocoupler
* GPIO 6 (D5) IN4 <- optocoupler
* GPIO 43 (D6) OUT1 -> darlington -> relay 1
* GPIO 44 (D7) OUT2 -> darlington -> relay 2
*
* NOTE: GPIO43/44 double as UART0 TX/RX. The ROM bootloader chirps on
* GPIO43 for a moment at reset — see README "Boot behaviour" for details.
*/
#define PIN_CAN_TX GPIO_NUM_1
#define PIN_CAN_RX GPIO_NUM_2
#define NUM_INPUTS 4
#define NUM_OUTPUTS 2
static const uint8_t PIN_INPUTS[NUM_INPUTS] = { 3, 4, 5, 6 };
static const uint8_t PIN_OUTPUTS[NUM_OUTPUTS] = { 43, 44 };
#define PIN_STATUS_LED 21 /* XIAO on-board user LED, active LOW */
/* ------------------------------------------------------------ polarities --
* Inputs: the optocoupler transistor pulls the GPIO to GND when the external
* contact/button is closed. Pins use internal pull-ups, so LOW = active.
* Set to 0 if your optos drive the pin high instead.
*/
#define INPUTS_ACTIVE_LOW 1
/* Outputs: darlington driver — GPIO HIGH energises the relay.
* Set to 0 for an active-low driver stage.
*/
#define OUTPUTS_ACTIVE_HIGH 1
/* -------------------------------------------------------------- debounce --
* Inputs are sampled every INPUT_SCAN_PERIOD_MS. A new level is accepted
* only after INPUT_DEBOUNCE_SAMPLES consecutive identical samples:
* 8 x 5 ms = 40 ms — plenty for both relay contacts and push buttons.
*/
#define INPUT_SCAN_PERIOD_MS 5
#define INPUT_DEBOUNCE_SAMPLES 8
/* ------------------------------------------------------------------- CAN --
* 500 kbit/s, 11-bit (standard) identifiers.
* The full frame formats are documented in PROTOCOL.md.
*/
#define CAN_TIMING_CONFIG TWAI_TIMING_CONFIG_500KBITS()
#define CAN_BASE_ID 0x100
#define CAN_ID_STATUS (CAN_BASE_ID + 0) /* board -> bus (TX) */
#define CAN_ID_COMMAND (CAN_BASE_ID + 1) /* bus -> board (RX) */
/* Period of the unsolicited status ("heartbeat") frame. Changes are
* additionally reported immediately. */
#define STATUS_HEARTBEAT_MS 1000
/** Command codes: first data byte of a CAN_ID_COMMAND frame. */
enum can_command : uint8_t {
CMD_GET_STATUS = 0x00, /* reply with a status frame */
CMD_SET_OUTPUT = 0x01, /* data[1] = output index, data[2] = 0/1 */
CMD_SET_ALL = 0x02, /* data[1] = bit mask, data[2] = bit values */
CMD_TOGGLE = 0x03, /* data[1] = output index */
};
/** Reason codes: data[2] of a CAN_ID_STATUS frame — why it was sent. */
enum status_reason : uint8_t {
REASON_PERIODIC = 0x00, /* heartbeat */
REASON_CHANGE = 0x01, /* an input or output changed */
REASON_REQUEST = 0x02, /* answer to CMD_GET_STATUS */
REASON_BOOT = 0x03, /* first frame after power-up/reset */
};
/* ---------------------------------------------------- input -> output map --
* Local rules, evaluated on the ACTIVATION EDGE of an input (i.e. the moment
* a button is pressed / a contact closes). Releasing does nothing, so the
* outputs latch — classic set/reset stations:
*
* IN1 -> OUT1 ON IN2 -> OUT1 OFF
* IN3 -> OUT2 ON IN4 -> OUT2 OFF
*
* Inputs that are already active at boot do NOT fire rules (safe start);
* see io_input.cpp if you want different behaviour.
*/
typedef struct {
uint8_t input; /* 0-based input index (0 = IN1) */
uint8_t output; /* 0-based output index (0 = OUT1) */
bool turn_on; /* true: switch output ON, false: OFF */
} input_rule_t;
/* Empty: output control is fully handed to the PC app (gateway/gateway_app.py's
* "software passthrough" toggle + manual On/Off/Toggle), which reads IN1-4
* back from the board's status frames and drives OUT1/OUT2 via CAN commands.
* A firmware-side rule here would keep re-asserting on every debounced edge
* (including input noise) and fight manual/software control within ~40ms. */
static const input_rule_t INPUT_RULES[] = {};
#define NUM_INPUT_RULES (sizeof(INPUT_RULES) / sizeof(INPUT_RULES[0]))
/* --------------------------------------------------- direct passthrough --
* Unlike INPUT_RULES (edge-triggered latch), entries here make an output
* continuously follow an input's live level - e.g. mirroring the car's 12V
* wake/terminal signal straight through to the EPS unit. Evaluated on every
* debounced level change, in EITHER direction (not just the activation edge).
*
* Empty by default - add a mapping once you know which IN/OUT pair carries
* the 12V passthrough signal, e.g.:
* static const passthrough_map_t PASSTHROUGH_MAP[] = { { 0, 0 } }; // IN1 -> OUT1
*/
typedef struct {
uint8_t input; /* 0-based input index (0 = IN1) */
uint8_t output; /* 0-based output index (0 = OUT1) */
} passthrough_map_t;
/* Empty for the same reason as INPUT_RULES above - see gateway_app.py's
* software passthrough instead. */
static const passthrough_map_t PASSTHROUGH_MAP[] = {};
#define NUM_PASSTHROUGH (sizeof(PASSTHROUGH_MAP) / sizeof(PASSTHROUGH_MAP[0]))
/* ------------------------------------------------------------- USB bridge --
* When enabled, the board mirrors every CAN frame it receives out over USB
* serial as SLCAN ASCII ('t'/'T' + id + dlc + data), and accepts the same
* format back to inject frames onto the bus - turning the board into a
* transparent USB-CAN adapter for its bus, on top of its normal IO duties.
* See firmware/src/usb_bridge.cpp and adapters/slcan_adapter.py (PC side).
*
* IMPORTANT: while enabled, Serial is reserved for the SLCAN stream - all
* human-readable debug logging is compiled out via DEBUG_LOG() below, so it
* never corrupts the frame stream. Set to 0 and reflash to get plain-text
* logs back for local debugging (e.g. `pio device monitor`).
*/
#define USB_BRIDGE_ENABLE 1
#if USB_BRIDGE_ENABLE
#define DEBUG_LOG(...) do {} while (0)
#else
#define DEBUG_LOG(...) Serial.printf(__VA_ARGS__)
#endif
/* ------------------------------------------------------- FreeRTOS tuning -- */
#define TASK_STACK_SIZE 4096 /* bytes, generous for all tasks */
#define PRIO_CAN_RX 10 /* react to bus traffic first */
#define PRIO_CAN_TX 9
#define PRIO_CAN_ALERT 9
#define PRIO_USB_BRIDGE 9
#define PRIO_INPUT 8 /* keeps the 5 ms scan on time */
#define PRIO_OUTPUT 8
#define PRIO_STATUS 5

View file

@ -0,0 +1,24 @@
; ============================================================================
; CAN IO Board — Seeed Studio XIAO ESP32-S3
;
; 4 optocoupler inputs, 2 relay outputs (darlington), SN65HVD230 CAN
; transceiver on the ESP32-S3's built-in TWAI controller.
;
; Build: pio run
; Upload: pio run -t upload (XIAO connected over USB)
; Monitor: pio device monitor (log output over native USB CDC)
; ============================================================================
[env:seeed_xiao_esp32s3]
platform = espressif32
board = seeed_xiao_esp32s3
framework = arduino
monitor_speed = 115200
build_flags =
; Route Serial to the native USB CDC port. These are already the board
; defaults for the XIAO ESP32-S3, kept explicit for clarity: it frees
; UART0's pins (GPIO43/44) for use as Digital out 1/2.
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1

View file

@ -0,0 +1,209 @@
/**
* @file can_bus.cpp
* @brief Implementation of the TWAI wrapper and its tasks.
*/
#include <Arduino.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/twai.h"
#include "config.h"
#include "can_bus.h"
#include "io_output.h"
#include "status.h"
#include "usb_bridge.h"
static QueueHandle_t s_tx_queue = NULL;
/** TX queue entry: the frame plus whether to mirror it out over USB. */
typedef struct {
twai_message_t msg;
bool mirror_to_usb;
} tx_item_t;
/* ------------------------------------------------------------ RX parsing -- */
/**
* Handle one CAN_ID_COMMAND frame - whether it arrived via twai_receive()
* from another node, or was injected locally by the USB bridge (which
* can't rely on TWAI looping its own transmissions back to RX). Ignores
* anything that isn't a well-formed, non-extended, non-RTR command frame.
*/
void can_handle_command_frame(const twai_message_t *msg)
{
if (msg->extd || msg->rtr || msg->identifier != CAN_ID_COMMAND || msg->data_length_code < 1) {
return;
}
switch (msg->data[0]) {
case CMD_GET_STATUS:
status_notify_request();
break;
case CMD_SET_OUTPUT:
/* data[1] = output index (0-based), data[2] = 0 off / anything on */
if (msg->data_length_code >= 3 && msg->data[1] < NUM_OUTPUTS) {
output_request(msg->data[1],
msg->data[2] ? OUT_ON : OUT_OFF);
}
break;
case CMD_SET_ALL:
/* data[1] = mask of outputs to touch, data[2] = their new values */
if (msg->data_length_code >= 3) {
for (uint8_t i = 0; i < NUM_OUTPUTS; i++) {
if (msg->data[1] & (1u << i)) {
output_request(i, (msg->data[2] & (1u << i)) ? OUT_ON
: OUT_OFF);
}
}
}
break;
case CMD_TOGGLE:
if (msg->data_length_code >= 2 && msg->data[1] < NUM_OUTPUTS) {
output_request(msg->data[1], OUT_TOGGLE);
}
break;
default:
DEBUG_LOG("[can] unknown command 0x%02X ignored\n", msg->data[0]);
break;
}
}
/* ----------------------------------------------------------------- tasks -- */
/** Blocks on twai_receive() and dispatches command frames. */
static void can_rx_task(void *arg)
{
(void)arg;
twai_message_t msg;
for (;;) {
if (twai_receive(&msg, portMAX_DELAY) != ESP_OK) {
continue;
}
/* Mirror every frame to the USB bridge first (no-op if disabled),
* then handle our own command frame if that's what this one is
* (can_handle_command_frame() no-ops on anything else). */
usb_bridge_on_rx(&msg);
can_handle_command_frame(&msg);
}
}
/** Drains the TX queue into the driver. */
static void can_tx_task(void *arg)
{
(void)arg;
tx_item_t item;
for (;;) {
if (xQueueReceive(s_tx_queue, &item, portMAX_DELAY) != pdTRUE) {
continue;
}
esp_err_t err = twai_transmit(&item.msg, pdMS_TO_TICKS(100));
if (err != ESP_OK) {
DEBUG_LOG("[can] TX failed: %s\n", esp_err_to_name(err));
}
if (item.mirror_to_usb) {
/* TWAI has no RX loopback of our own frames, so the USB bridge
* would never see this board's own status/heartbeat frames
* unless we mirror them here explicitly. Deliberately mirrored
* even when the transmit FAILED: with no other powered node on
* the bus (e.g. the EPS is still waiting for its 12V enable,
* which this board's own output has to provide) every TX times
* out for lack of an ACK, and the PC would otherwise be blind
* to IN/OUT state exactly when it needs it to break that
* chicken-and-egg. Only for board-originated frames - see
* can_send()'s doc comment. */
usb_bridge_on_rx(&item.msg);
}
}
}
/**
* Watches driver alerts. Most importantly: if the node ever goes bus-off
* (e.g. shorted bus), recovery is started automatically and the driver is
* restarted once the bus is healthy again.
*/
static void can_alert_task(void *arg)
{
(void)arg;
uint32_t alerts;
for (;;) {
if (twai_read_alerts(&alerts, portMAX_DELAY) != ESP_OK) {
continue;
}
if (alerts & TWAI_ALERT_ERR_PASS) {
DEBUG_LOG("[can] WARN: error-passive state\n");
}
if (alerts & TWAI_ALERT_RX_QUEUE_FULL) {
DEBUG_LOG("[can] WARN: RX queue overflow, frames lost\n");
}
if (alerts & TWAI_ALERT_BUS_OFF) {
DEBUG_LOG("[can] BUS-OFF! starting recovery...\n");
twai_initiate_recovery();
}
if (alerts & TWAI_ALERT_BUS_RECOVERED) {
DEBUG_LOG("[can] bus recovered, restarting driver\n");
twai_start();
}
}
}
/* ------------------------------------------------------------------ API -- */
bool can_init(void)
{
twai_general_config_t g_config =
TWAI_GENERAL_CONFIG_DEFAULT(PIN_CAN_TX, PIN_CAN_RX, TWAI_MODE_NORMAL);
g_config.rx_queue_len = 16;
g_config.tx_queue_len = 8;
twai_timing_config_t t_config = CAN_TIMING_CONFIG;
/* Accept everything in hardware, filter in software (can_rx_task).
* With only two IDs in play this is simpler and easy to extend. */
twai_filter_config_t f_config = TWAI_FILTER_CONFIG_ACCEPT_ALL();
if (twai_driver_install(&g_config, &t_config, &f_config) != ESP_OK) {
Serial.println("[can] ERROR: driver install failed");
return false;
}
if (twai_start() != ESP_OK) {
Serial.println("[can] ERROR: driver start failed");
return false;
}
twai_reconfigure_alerts(TWAI_ALERT_BUS_OFF | TWAI_ALERT_BUS_RECOVERED |
TWAI_ALERT_ERR_PASS | TWAI_ALERT_RX_QUEUE_FULL,
NULL);
s_tx_queue = xQueueCreate(16, sizeof(tx_item_t));
/* CAN tasks on core 0, application tasks (IO) on core 1. */
xTaskCreatePinnedToCore(can_rx_task, "can_rx", TASK_STACK_SIZE,
NULL, PRIO_CAN_RX, NULL, 0);
xTaskCreatePinnedToCore(can_tx_task, "can_tx", TASK_STACK_SIZE,
NULL, PRIO_CAN_TX, NULL, 0);
xTaskCreatePinnedToCore(can_alert_task, "can_alert", TASK_STACK_SIZE,
NULL, PRIO_CAN_ALERT, NULL, 0);
DEBUG_LOG("[can] up (bitrate per config.h), status=0x%03X, command=0x%03X\n",
CAN_ID_STATUS, CAN_ID_COMMAND);
return true;
}
bool can_send(const twai_message_t *msg, bool mirror_to_usb)
{
if (s_tx_queue == NULL) {
return false;
}
tx_item_t item = { *msg, mirror_to_usb };
return xQueueSend(s_tx_queue, &item, 0) == pdTRUE;
}

View file

@ -0,0 +1,49 @@
/**
* @file can_bus.h
* @brief TWAI (CAN) driver wrapper: init, RX/TX/alert tasks.
*
* Uses the ESP32-S3's built-in TWAI controller via driver/twai.h with an
* SN65HVD230 transceiver. Three small tasks:
*
* can_rx_task blocks on twai_receive(), parses command frames and
* dispatches them (output queue / status request).
* can_tx_task drains a FreeRTOS queue of outgoing frames into
* twai_transmit(). Everything that wants to send goes
* through can_send(), never twai_transmit() directly.
* can_alert_task blocks on twai_read_alerts() and handles error states,
* including automatic bus-off recovery.
*/
#pragma once
#include <stdbool.h>
#include "driver/twai.h"
/**
* Install and start the TWAI driver (pins/bitrate from config.h),
* create the TX queue and start the RX/TX/alert tasks.
* @return true on success.
*/
bool can_init(void);
/**
* Queue a frame for transmission. Safe to call from any task.
* @param mirror_to_usb Mirror this frame out over the USB bridge - true for
* frames the board originates itself (status/heartbeat), false for frames
* that came FROM the bridge in the first place (the PC already knows about
* those; echoing them back would look like new incoming bus traffic and
* create a relay feedback loop). Mirroring happens whether or not the
* transmit actually succeeded, so the PC still sees IO state when the bus
* has no other powered node to ACK it.
* @return false if the driver is not ready or the queue is full.
*/
bool can_send(const twai_message_t *msg, bool mirror_to_usb = true);
/**
* Run the CAN_ID_COMMAND handler on a frame directly, without it having to
* arrive via twai_receive() first. Needed because TWAI does not loop a
* node's own transmissions back to its own RX: a command frame the USB
* bridge injects onto the bus (see usb_bridge.cpp) would otherwise never
* reach the board's own command handling, even though it was "sent to
* itself". No-op for anything that isn't a well-formed CAN_ID_COMMAND frame.
*/
void can_handle_command_frame(const twai_message_t *msg);

View file

@ -0,0 +1,118 @@
/**
* @file io_input.cpp
* @brief Implementation of the debounced input scanner task.
*/
#include <Arduino.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "config.h"
#include "io_input.h"
#include "io_output.h"
#include "io_state.h"
#include "logic.h"
#include "status.h"
/** Read one input and translate it to a logical "active" flag. */
static bool read_active(uint8_t index)
{
int level = digitalRead(PIN_INPUTS[index]);
#if INPUTS_ACTIVE_LOW
return level == LOW;
#else
return level == HIGH;
#endif
}
/**
* Scanner task.
*
* Debounce per channel:
* - candidate: the level seen in the most recent sample(s)
* - count: how many consecutive samples agreed with candidate
* - stable: the accepted (debounced) level
* A candidate becomes stable after INPUT_DEBOUNCE_SAMPLES agreements.
*
* Boot behaviour: the initial levels are taken as-is and reported, but do
* NOT fire input rules — a relay contact that is already closed at power-up
* will not switch anything by itself. If you want boot-time rule evaluation,
* call logic_input_activated() in the init loop below.
*/
static void input_task(void *arg)
{
(void)arg;
bool stable[NUM_INPUTS];
bool candidate[NUM_INPUTS];
uint8_t count[NUM_INPUTS];
/* Let pull-ups and optocouplers settle after pinMode. */
vTaskDelay(pdMS_TO_TICKS(50));
/* Take the initial snapshot (no rule evaluation, see above). */
for (uint8_t i = 0; i < NUM_INPUTS; i++) {
stable[i] = read_active(i);
candidate[i] = stable[i];
count[i] = INPUT_DEBOUNCE_SAMPLES;
io_state_set_input(i, stable[i]);
}
status_notify_change();
TickType_t last_wake = xTaskGetTickCount();
for (;;) {
/* vTaskDelayUntil keeps the sample period exact regardless of how
* long one pass takes — better debounce math than vTaskDelay. */
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(INPUT_SCAN_PERIOD_MS));
for (uint8_t i = 0; i < NUM_INPUTS; i++) {
bool raw = read_active(i);
if (raw != candidate[i]) {
/* Level flipped: restart the agreement counter. */
candidate[i] = raw;
count[i] = 1;
} else if (count[i] < INPUT_DEBOUNCE_SAMPLES) {
count[i]++;
if (count[i] == INPUT_DEBOUNCE_SAMPLES &&
candidate[i] != stable[i]) {
/* Debounced edge accepted. */
stable[i] = candidate[i];
DEBUG_LOG("[in ] IN%u -> %s\n",
i + 1, stable[i] ? "ACTIVE" : "inactive");
io_state_set_input(i, stable[i]);
status_notify_change();
for (size_t p = 0; p < NUM_PASSTHROUGH; p++) {
if (PASSTHROUGH_MAP[p].input == i) {
output_request(PASSTHROUGH_MAP[p].output,
stable[i] ? OUT_ON : OUT_OFF);
}
}
if (stable[i]) {
/* Activation edge: run the local rules. */
logic_input_activated(i);
}
}
}
/* count already saturated: stable, nothing to do. */
}
}
}
void input_init(void)
{
for (uint8_t i = 0; i < NUM_INPUTS; i++) {
#if INPUTS_ACTIVE_LOW
pinMode(PIN_INPUTS[i], INPUT_PULLUP);
#else
pinMode(PIN_INPUTS[i], INPUT_PULLDOWN);
#endif
}
xTaskCreatePinnedToCore(input_task, "input", TASK_STACK_SIZE,
NULL, PRIO_INPUT, NULL, 1);
}

View file

@ -0,0 +1,21 @@
/**
* @file io_input.h
* @brief Optocoupler input scanner with software debounce.
*
* A dedicated task samples all inputs every INPUT_SCAN_PERIOD_MS and accepts
* a new level only after INPUT_DEBOUNCE_SAMPLES identical samples in a row
* (integrator debounce). Works equally well for bouncy relay contacts and
* push buttons.
*
* On every accepted change the shared state is updated and a status frame is
* triggered. On an activation edge (inactive -> active) the local logic rules
* are evaluated (see logic.h).
*/
#pragma once
/**
* Configure input pins and start the scanner task.
* Call LAST in setup(): outputs, CAN and status must be running so the
* initial input snapshot and any rule actions have somewhere to go.
*/
void input_init(void);

View file

@ -0,0 +1,82 @@
/**
* @file io_output.cpp
* @brief Implementation of the relay output task.
*/
#include <Arduino.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "config.h"
#include "io_output.h"
#include "io_state.h"
#include "status.h"
/** One queued output request. */
typedef struct {
uint8_t index;
output_action_t action;
} output_cmd_t;
static QueueHandle_t s_queue = NULL;
/** Translate logical state -> pin level, honouring OUTPUTS_ACTIVE_HIGH. */
static void write_output_pin(uint8_t index, bool on)
{
#if OUTPUTS_ACTIVE_HIGH
digitalWrite(PIN_OUTPUTS[index], on ? HIGH : LOW);
#else
digitalWrite(PIN_OUTPUTS[index], on ? LOW : HIGH);
#endif
}
/**
* Output task: waits for commands, applies them to the pins, updates the
* shared state and pokes the status reporter when something changed.
*/
static void output_task(void *arg)
{
(void)arg;
output_cmd_t cmd;
for (;;) {
if (xQueueReceive(s_queue, &cmd, portMAX_DELAY) != pdTRUE) {
continue;
}
bool current = io_state_output(cmd.index);
bool target = (cmd.action == OUT_TOGGLE) ? !current
: (cmd.action == OUT_ON);
write_output_pin(cmd.index, target);
if (io_state_set_output(cmd.index, target)) {
DEBUG_LOG("[out] OUT%u -> %s\n",
cmd.index + 1, target ? "ON" : "OFF");
status_notify_change();
}
}
}
void output_init(void)
{
/* Drive the relays to a safe OFF state before anything else runs. */
for (uint8_t i = 0; i < NUM_OUTPUTS; i++) {
pinMode(PIN_OUTPUTS[i], OUTPUT);
write_output_pin(i, false);
}
s_queue = xQueueCreate(16, sizeof(output_cmd_t));
xTaskCreatePinnedToCore(output_task, "output", TASK_STACK_SIZE,
NULL, PRIO_OUTPUT, NULL, 1);
}
bool output_request(uint8_t index, output_action_t action)
{
if (index >= NUM_OUTPUTS || s_queue == NULL) {
return false;
}
output_cmd_t cmd = { index, action };
return xQueueSend(s_queue, &cmd, 0) == pdTRUE;
}

View file

@ -0,0 +1,34 @@
/**
* @file io_output.h
* @brief Relay output driver — a FreeRTOS task that owns the output pins.
*
* All output changes (from CAN commands or local input rules) go through
* a single command queue, so there is exactly one writer to the GPIOs and
* no race conditions. On any actual change the status reporter is notified,
* which broadcasts a status frame on the bus.
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
/** What to do with an output. */
typedef enum {
OUT_OFF = 0,
OUT_ON = 1,
OUT_TOGGLE = 2,
} output_action_t;
/**
* Configure output pins (relays OFF), create the command queue and start the
* output task. Call early in setup() so the relays are in a safe state.
*/
void output_init(void);
/**
* Request an output change. Safe to call from any task.
* @param index 0-based output index (0 = OUT1).
* @param action OUT_OFF / OUT_ON / OUT_TOGGLE.
* @return false if the index is invalid or the queue is full/not ready.
*/
bool output_request(uint8_t index, output_action_t action);

View file

@ -0,0 +1,75 @@
/**
* @file io_state.cpp
* @brief Implementation of the mutex-guarded IO state store.
*/
#include <Arduino.h>
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "io_state.h"
static SemaphoreHandle_t s_mutex = NULL;
static uint8_t s_inputs = 0;
static uint8_t s_outputs = 0;
void io_state_init(void)
{
s_mutex = xSemaphoreCreateMutex();
}
/** Set/clear one bit in a bitmap. @return true if the bitmap changed. */
static bool set_bit(uint8_t *bitmap, uint8_t index, bool value)
{
uint8_t old = *bitmap;
if (value) {
*bitmap |= (uint8_t)(1u << index);
} else {
*bitmap &= (uint8_t)~(1u << index);
}
return *bitmap != old;
}
bool io_state_set_input(uint8_t index, bool active)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
bool changed = set_bit(&s_inputs, index, active);
xSemaphoreGive(s_mutex);
return changed;
}
bool io_state_set_output(uint8_t index, bool active)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
bool changed = set_bit(&s_outputs, index, active);
xSemaphoreGive(s_mutex);
return changed;
}
uint8_t io_state_inputs(void)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
uint8_t v = s_inputs;
xSemaphoreGive(s_mutex);
return v;
}
uint8_t io_state_outputs(void)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
uint8_t v = s_outputs;
xSemaphoreGive(s_mutex);
return v;
}
bool io_state_output(uint8_t index)
{
return (io_state_outputs() >> index) & 1u;
}
void io_state_snapshot(uint8_t *inputs, uint8_t *outputs)
{
xSemaphoreTake(s_mutex, portMAX_DELAY);
*inputs = s_inputs;
*outputs = s_outputs;
xSemaphoreGive(s_mutex);
}

View file

@ -0,0 +1,41 @@
/**
* @file io_state.h
* @brief Thread-safe snapshot of the current input/output state.
*
* Several tasks read and write the IO state (input scanner, output task,
* status reporter). This module owns the two state bitmaps and guards them
* with a FreeRTOS mutex so every reader gets a consistent view.
*
* Bit layout: bit0 = IN1/OUT1, bit1 = IN2/OUT2, ... 1 = active.
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
/** Create the mutex. Call once from setup() before any task starts. */
void io_state_init(void);
/**
* Update one input bit.
* @return true if the stored value actually changed.
*/
bool io_state_set_input(uint8_t index, bool active);
/**
* Update one output bit.
* @return true if the stored value actually changed.
*/
bool io_state_set_output(uint8_t index, bool active);
/** @return current input bitmap. */
uint8_t io_state_inputs(void);
/** @return current output bitmap. */
uint8_t io_state_outputs(void);
/** @return current state of a single output. */
bool io_state_output(uint8_t index);
/** Atomically read both bitmaps (used to build status frames). */
void io_state_snapshot(uint8_t *inputs, uint8_t *outputs);

View file

@ -0,0 +1,28 @@
/**
* @file logic.cpp
* @brief Implementation of the local input->output rules.
*/
#include <Arduino.h>
#include "config.h"
#include "logic.h"
#include "io_output.h"
void logic_input_activated(uint8_t input_index)
{
for (size_t r = 0; r < NUM_INPUT_RULES; r++) {
const input_rule_t *rule = &INPUT_RULES[r];
if (rule->input != input_index) {
continue;
}
DEBUG_LOG("[log] rule: IN%u -> OUT%u %s\n",
rule->input + 1, rule->output + 1,
rule->turn_on ? "ON" : "OFF");
if (!output_request(rule->output,
rule->turn_on ? OUT_ON : OUT_OFF)) {
DEBUG_LOG("[log] WARN: output queue rejected request\n");
}
}
}

View file

@ -0,0 +1,17 @@
/**
* @file logic.h
* @brief Local input->output rules (set/reset stations).
*
* Deliberately NOT a task: it is a pure lookup called from the input
* scanner's context that forwards actions to the output task's queue.
* The rule table itself lives in config.h (INPUT_RULES).
*/
#pragma once
#include <stdint.h>
/**
* Called by the input scanner on an activation edge (inactive -> active).
* Looks up all matching rules and queues the resulting output actions.
*/
void logic_input_activated(uint8_t input_index);

View file

@ -0,0 +1,93 @@
/**
* @file main.cpp
* @brief CAN IO board — entry point.
*
* Board: Seeed Studio XIAO ESP32-S3
* Role: 4-in / 2-out CAN IO node (see README.md and PROTOCOL.md)
*
* setup() only wires the modules together; all real work happens in
* FreeRTOS tasks:
*
* input_task (io_input.cpp) 5 ms scan + debounce of the 4 opto inputs
* output_task (io_output.cpp) owns the 2 relay pins, fed by a queue
* status_task (status.cpp) heartbeat + on-change status frames
* can_rx_task (can_bus.cpp) receives + dispatches command frames
* can_tx_task (can_bus.cpp) transmits queued frames
* can_alert_task (can_bus.cpp) bus health, automatic bus-off recovery
* usb_bridge_task (usb_bridge.cpp) mirrors every RX frame over USB as
* SLCAN ASCII, injects frames sent back
* (see config.h USB_BRIDGE_ENABLE)
*
* Data flow:
*
* inputs --debounce--> logic rules ----+
* \-> passthrough map (continuous mirror, e.g. 12V)
* v
* CAN cmd frame --> can_rx_task --> [output queue] --> output_task --> relays
* | |
* v (get status) v (changed)
* [status event group] <------------------+
* |
* v
* status_task --> [tx queue] --> can_tx_task --> CAN bus
*
* Init order matters:
* 1. io_state (mutex)
* 2. outputs (relays to safe OFF as early as possible)
* 3. CAN (so the status task can send)
* 4. status (sends the boot frame)
* 5. inputs (last — everything is ready when rules start firing)
*/
#include <Arduino.h>
#include "config.h"
#include "io_state.h"
#include "io_output.h"
#include "io_input.h"
#include "can_bus.h"
#include "status.h"
#include "usb_bridge.h"
void setup()
{
/* On-board LED (active low) as a heartbeat indicator, off for now. */
pinMode(PIN_STATUS_LED, OUTPUT);
digitalWrite(PIN_STATUS_LED, HIGH);
/* Native USB CDC console. Wait briefly for a host, but never block:
* the board must come up with or without a PC attached. */
Serial.begin(115200);
uint32_t t0 = millis();
while (!Serial && millis() - t0 < 2000) {
delay(10);
}
Serial.println();
DEBUG_LOG("=== CAN IO board (XIAO ESP32-S3) ===\n");
io_state_init();
output_init();
if (!can_init()) {
Serial.println("FATAL: CAN init failed — check transceiver wiring");
/* Keep running: local input->output rules still work without CAN. */
}
usb_bridge_init();
status_init();
input_init();
DEBUG_LOG("[sys] all tasks running\n");
}
/**
* Arduino loop task: nothing left to do except blink the heartbeat LED.
* All functionality lives in the FreeRTOS tasks created in setup().
*/
void loop()
{
digitalWrite(PIN_STATUS_LED, LOW); /* LED on (active low) */
vTaskDelay(pdMS_TO_TICKS(50));
digitalWrite(PIN_STATUS_LED, HIGH); /* LED off */
vTaskDelay(pdMS_TO_TICKS(1950));
}

View file

@ -0,0 +1,103 @@
/**
* @file status.cpp
* @brief Implementation of the status reporter task.
*/
#include <Arduino.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "driver/twai.h"
#include "config.h"
#include "status.h"
#include "io_state.h"
#include "can_bus.h"
#include "usb_bridge.h"
/* Event group bits. */
#define EVT_REQUEST (1u << 0)
#define EVT_CHANGE (1u << 1)
#define EVT_BOOT (1u << 2)
#define EVT_ALL (EVT_REQUEST | EVT_CHANGE | EVT_BOOT)
static EventGroupHandle_t s_events = NULL;
/** Build and queue one status frame. See PROTOCOL.md for the layout. */
static void send_status(uint8_t reason)
{
twai_message_t msg = {};
msg.identifier = CAN_ID_STATUS;
msg.data_length_code = 8;
io_state_snapshot(&msg.data[0], &msg.data[1]); /* inputs, outputs */
msg.data[2] = reason;
msg.data[3] = 0; /* reserved */
uint32_t uptime_s = millis() / 1000u; /* little-endian u32 */
msg.data[4] = (uint8_t)(uptime_s);
msg.data[5] = (uint8_t)(uptime_s >> 8);
msg.data[6] = (uint8_t)(uptime_s >> 16);
msg.data[7] = (uint8_t)(uptime_s >> 24);
/* Direct USB path first: it can't be starved by a backed-up CAN TX
* queue, so the PC keeps seeing IO state even on a dead/one-node bus. */
usb_bridge_report_io(msg.data[0], msg.data[1], uptime_s);
if (!can_send(&msg)) {
DEBUG_LOG("[sta] WARN: could not queue status frame\n");
}
}
/**
* Status task: block on the event group with the heartbeat period as
* timeout. Waking up on a bit -> event frame; timing out -> heartbeat.
*/
static void status_task(void *arg)
{
(void)arg;
for (;;) {
EventBits_t bits = xEventGroupWaitBits(
s_events, EVT_ALL,
pdTRUE, /* clear bits on exit */
pdFALSE, /* wait for ANY bit */
pdMS_TO_TICKS(STATUS_HEARTBEAT_MS));
uint8_t reason = REASON_PERIODIC;
if (bits & EVT_BOOT) {
reason = REASON_BOOT;
} else if (bits & EVT_REQUEST) {
reason = REASON_REQUEST;
} else if (bits & EVT_CHANGE) {
reason = REASON_CHANGE;
}
send_status(reason);
}
}
void status_init(void)
{
s_events = xEventGroupCreate();
/* Make the very first frame announce the boot. */
xEventGroupSetBits(s_events, EVT_BOOT);
xTaskCreatePinnedToCore(status_task, "status", TASK_STACK_SIZE,
NULL, PRIO_STATUS, NULL, 1);
}
void status_notify_change(void)
{
if (s_events != NULL) {
xEventGroupSetBits(s_events, EVT_CHANGE);
}
}
void status_notify_request(void)
{
if (s_events != NULL) {
xEventGroupSetBits(s_events, EVT_REQUEST);
}
}

View file

@ -0,0 +1,22 @@
/**
* @file status.h
* @brief Status reporter — broadcasts the IO state on the CAN bus.
*
* One task, driven by a FreeRTOS event group:
* - a heartbeat status frame every STATUS_HEARTBEAT_MS (wait timeout),
* - an immediate frame when an input/output changes (EVT_CHANGE),
* - an immediate frame when a CMD_GET_STATUS arrives (EVT_REQUEST),
* - one boot frame right after startup (EVT_BOOT).
*
* The frame format is described in PROTOCOL.md.
*/
#pragma once
/** Create the event group and start the status task (flags the boot frame). */
void status_init(void);
/** Any task: request an immediate "state changed" status frame. */
void status_notify_change(void);
/** CAN RX: request an immediate "response" status frame (CMD_GET_STATUS). */
void status_notify_request(void);

View file

@ -0,0 +1,199 @@
/**
* @file usb_bridge.cpp
* @brief SLCAN-style USB-CAN bridge implementation, plus a direct IO channel.
*
* Minimal on purpose: bitrate is fixed at compile time (config.h), so 'S'
* and 'O'/'C' are accepted-and-acknowledged for compatibility with generic
* SLCAN tooling but don't actually reconfigure anything. Frame lines
* ('t'/'T' + id + dlc + data, no trailing timestamp) are injected onto the
* bus via can_send(..., mirror_to_usb=false) - NOT echoed back over USB,
* since the PC already knows about a frame it just sent; only genuinely
* received bus traffic and the board's own status/heartbeat get mirrored
* (see can_bus.cpp's can_rx_task and can_tx_task).
*
* On top of that, '@' command lines and '!' report lines carry digital IO
* state directly, with no CAN involvement at all - see
* usb_bridge_report_io() in the header for why that separation matters.
*/
#include <Arduino.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/twai.h"
#include "config.h"
#include "usb_bridge.h"
#include "can_bus.h"
#include "io_output.h"
#include "io_state.h"
#if USB_BRIDGE_ENABLE
static const uint8_t ACK = 0x06;
static const uint8_t BELL = 0x07;
/** Parse one SLCAN-style command/frame line and act on it. */
static void handle_line(const String &line)
{
if (line.length() == 0) {
return;
}
char c = line[0];
switch (c) {
case 'O': /* open channel - no-op, the bus is always running */
case 'C': /* close channel - no-op, same reason */
case 'S': /* set bitrate - fixed at compile time, ignored */
Serial.write(ACK);
break;
case 'V':
Serial.print("V1000\r");
break;
case 'N':
Serial.print("NCANIO01\r");
break;
case 't':
case 'T': {
bool extd = (c == 'T');
uint8_t id_len = extd ? 8 : 3;
if (line.length() < (unsigned)(2 + id_len)) {
Serial.write(BELL);
break;
}
uint32_t id = strtoul(line.substring(1, 1 + id_len).c_str(), NULL, 16);
uint8_t dlc = (uint8_t)strtoul(line.substring(1 + id_len, 2 + id_len).c_str(), NULL, 16);
if (dlc > 8 || line.length() < (unsigned)(2 + id_len + dlc * 2)) {
Serial.write(BELL);
break;
}
twai_message_t msg = {};
msg.identifier = id;
msg.extd = extd;
msg.data_length_code = dlc;
for (uint8_t i = 0; i < dlc; i++) {
int pos = 2 + id_len + i * 2;
msg.data[i] = (uint8_t)strtoul(line.substring(pos, pos + 2).c_str(), NULL, 16);
}
/* TWAI won't loop this back to our own RX, so the command handler
* (SET_OUTPUT/TOGGLE/etc.) has to be run directly here too - not
* just transmitted onto the bus - or bridge-injected commands would
* silently never take effect on this board. No-op for other IDs. */
can_handle_command_frame(&msg);
Serial.write(can_send(&msg, false) ? ACK : BELL);
break;
}
case '@': {
/* Direct IO channel, deliberately independent of CAN (see
* usb_bridge_report_io()'s doc comment for why).
* @G -> emit an IO report now
* @S<i><v> -> set output i (0-based) to v (0/1), e.g. "@S01"
* @T<i> -> toggle output i
*/
if (line.length() < 2) {
Serial.write(BELL);
break;
}
char sub = line[1];
if (sub == 'G') {
uint8_t ins, outs;
io_state_snapshot(&ins, &outs);
usb_bridge_report_io(ins, outs, millis() / 1000u);
} else if (sub == 'S' && line.length() >= 4) {
uint8_t idx = (uint8_t)(line[2] - '0');
bool on = (line[3] != '0');
if (idx < NUM_OUTPUTS) {
output_request(idx, on ? OUT_ON : OUT_OFF);
Serial.write(ACK);
} else {
Serial.write(BELL);
}
} else if (sub == 'T' && line.length() >= 3) {
uint8_t idx = (uint8_t)(line[2] - '0');
if (idx < NUM_OUTPUTS) {
output_request(idx, OUT_TOGGLE);
Serial.write(ACK);
} else {
Serial.write(BELL);
}
} else {
Serial.write(BELL);
}
break;
}
default:
Serial.write(BELL);
break;
}
}
/** Reads Serial a line at a time (CR-terminated, SLCAN style) and dispatches it. */
static void usb_bridge_task(void *arg)
{
(void)arg;
String line;
line.reserve(32);
for (;;) {
while (Serial.available()) {
char ch = (char)Serial.read();
if (ch == '\r') {
handle_line(line);
line = "";
} else if (line.length() < 32) {
line += ch;
}
}
vTaskDelay(pdMS_TO_TICKS(2));
}
}
void usb_bridge_init(void)
{
xTaskCreatePinnedToCore(usb_bridge_task, "usb_bridge", TASK_STACK_SIZE,
NULL, PRIO_USB_BRIDGE, NULL, 0);
}
void usb_bridge_on_rx(const twai_message_t *msg)
{
char line[32];
int n = 0;
line[n++] = msg->extd ? 'T' : 't';
n += snprintf(line + n, sizeof(line) - n, msg->extd ? "%08lX" : "%03lX",
(unsigned long)msg->identifier);
n += snprintf(line + n, sizeof(line) - n, "%X", msg->data_length_code);
for (uint8_t i = 0; i < msg->data_length_code; i++) {
n += snprintf(line + n, sizeof(line) - n, "%02X", msg->data[i]);
}
line[n++] = '\r';
Serial.write((const uint8_t *)line, n);
}
void usb_bridge_report_io(uint8_t inputs, uint8_t outputs, uint32_t uptime_s)
{
char line[24];
int n = snprintf(line, sizeof(line), "!%02X%02X%08lX\r",
inputs, outputs, (unsigned long)uptime_s);
Serial.write((const uint8_t *)line, n);
}
#else /* !USB_BRIDGE_ENABLE */
void usb_bridge_init(void) {}
void usb_bridge_on_rx(const twai_message_t *msg) { (void)msg; }
void usb_bridge_report_io(uint8_t inputs, uint8_t outputs, uint32_t uptime_s)
{
(void)inputs; (void)outputs; (void)uptime_s;
}
#endif

View file

@ -0,0 +1,31 @@
/**
* @file usb_bridge.h
* @brief Optional SLCAN-style USB-CAN bridge (see config.h USB_BRIDGE_ENABLE).
*/
#pragma once
#include <stdint.h>
#include "driver/twai.h"
/** Starts the bridge task (reads Serial, injects frames onto the bus). */
void usb_bridge_init(void);
/** Call for every frame the board receives, so the bridge can mirror it
* over USB. No-op when USB_BRIDGE_ENABLE is 0. */
void usb_bridge_on_rx(const twai_message_t *msg);
/**
* Report digital IO state straight out over USB, bypassing CAN entirely.
*
* The 0x100 status frame is the "on the bus" view of the same data, but it
* has to go through the shared CAN TX queue - which, on a bus with no other
* powered node, backs up behind 100ms-timing-out transmits and starts
* dropping frames as soon as the PC relays any real traffic through. That
* makes IO state intermittently invisible exactly when it's needed most
* (the EPS can't wake until this board's output enables it). This path has
* no such dependency: one line, written directly to Serial.
*
* Emits `!<in:02X><out:02X><uptime_s:08X>` + CR. No-op when the bridge is
* disabled.
*/
void usb_bridge_report_io(uint8_t inputs, uint8_t outputs, uint32_t uptime_s);

17
can-io/gui/README.md Normal file
View file

@ -0,0 +1,17 @@
# CAN IO Board GUI
Tkinter front-end for the CAN IO board via an SLCAN serial adapter.
```bash
pip install -r requirements.txt
python can_io_gui.py
```
1. Pick the SLCAN device's serial port (⟳ refreshes the list).
2. Bitrate 500000 and base ID 0x100 match the firmware defaults.
3. Connect — the GUI requests status automatically and then tracks the
board's heartbeat/change frames.
Green LED = active input / energised output. Buttons send SET_OUTPUT /
TOGGLE commands; the board confirms with a status frame, which is what
actually updates the display. Frame formats: ../PROTOCOL.md.

391
can-io/gui/can_io_gui.py Normal file
View file

@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""
CAN IO Board GUI
================
Simple Tkinter front-end for the XIAO ESP32-S3 CAN IO board, talking through
a second ESP32 running SLCAN firmware (any SLCAN/LAWICEL adapter works).
Features
--------
- Serial port picker (with refresh) + bitrate + base ID
- Live view of the 4 inputs and 2 outputs (green = active)
- Per-output ON / OFF / Toggle buttons + "Request status"
- Frame log with decoded status frames
Protocol (must match the firmware's config.h / PROTOCOL.md)
------------------------------------------------------------
- STATUS (board -> bus, ID = base + 0):
data[0] input bitmap, data[1] output bitmap, data[2] reason,
data[3] reserved, data[4:8] uptime seconds (uint32 LE)
- COMMAND (bus -> board, ID = base + 1):
data[0] = 0x00 GET_STATUS
0x01 SET_OUTPUT (data[1] index, data[2] 0/1)
0x02 SET_ALL (data[1] mask, data[2] values)
0x03 TOGGLE (data[1] index)
Usage
-----
pip install -r requirements.txt
python can_io_gui.py
"""
import queue
import threading
import time
import tkinter as tk
from tkinter import ttk, messagebox
try:
import can
from serial.tools import list_ports
except ImportError as exc: # pragma: no cover
raise SystemExit(
f"Missing dependency: {exc.name}\n"
"Install requirements first: pip install -r requirements.txt"
)
# --------------------------------------------------------------------------
# Protocol constants (keep in sync with firmware include/config.h)
# --------------------------------------------------------------------------
DEFAULT_BASE_ID = 0x100
NUM_INPUTS = 4
NUM_OUTPUTS = 2
CMD_GET_STATUS = 0x00
CMD_SET_OUTPUT = 0x01
CMD_SET_ALL = 0x02
CMD_TOGGLE = 0x03
REASONS = {0: "periodic", 1: "change", 2: "request", 3: "boot"}
BITRATES = [125000, 250000, 500000, 1000000]
DEFAULT_BITRATE = 500000
DEFAULT_TTY_BAUD = 115200 # ignored by native-USB SLCAN adapters
STALE_AFTER_S = 3.5 # no status for this long -> mark stale
COLOR_ON = "#2ecc71"
COLOR_OFF = "#4a4a4a"
COLOR_STALE = "#b3771e"
class CanIoGui:
"""Main application window."""
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("CAN IO Board")
self.root.resizable(False, False)
self.bus = None # can.Bus when connected
self.rx_thread = None
self.rx_running = threading.Event()
self.rx_queue = queue.Queue() # RX thread -> GUI thread
self.last_status_time = None
self.inputs = 0
self.outputs = 0
self.active_base = DEFAULT_BASE_ID # parsed once per connect
self._build_ui()
self._refresh_ports()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
self.root.after(50, self._poll_rx_queue)
# ------------------------------------------------------------------ UI --
def _build_ui(self):
pad = {"padx": 6, "pady": 4}
# --- connection bar -------------------------------------------------
conn = ttk.LabelFrame(self.root, text="SLCAN connection")
conn.grid(row=0, column=0, columnspan=2, sticky="ew", **pad)
ttk.Label(conn, text="Port:").grid(row=0, column=0, sticky="e")
self.port_combo = ttk.Combobox(conn, width=34, state="readonly")
self.port_combo.grid(row=0, column=1, **pad)
ttk.Button(conn, text="⟳", width=3,
command=self._refresh_ports).grid(row=0, column=2)
ttk.Label(conn, text="Bitrate:").grid(row=0, column=3, sticky="e")
self.bitrate_combo = ttk.Combobox(
conn, width=8, state="readonly",
values=[str(b) for b in BITRATES])
self.bitrate_combo.set(str(DEFAULT_BITRATE))
self.bitrate_combo.grid(row=0, column=4, **pad)
ttk.Label(conn, text="Base ID:").grid(row=1, column=0, sticky="e")
self.base_id_entry = ttk.Entry(conn, width=8)
self.base_id_entry.insert(0, f"0x{DEFAULT_BASE_ID:03X}")
self.base_id_entry.grid(row=1, column=1, sticky="w", **pad)
self.connect_btn = ttk.Button(conn, text="Connect",
command=self._toggle_connection)
self.connect_btn.grid(row=1, column=4, sticky="ew", **pad)
self.conn_label = ttk.Label(conn, text="disconnected",
foreground="gray")
self.conn_label.grid(row=1, column=2, columnspan=2)
# --- inputs ----------------------------------------------------------
in_frame = ttk.LabelFrame(self.root, text="Inputs")
in_frame.grid(row=1, column=0, sticky="nsew", **pad)
self.input_leds = []
for i in range(NUM_INPUTS):
led = self._make_led(in_frame, f"IN{i + 1}", row=0, col=i)
self.input_leds.append(led)
# --- outputs ---------------------------------------------------------
out_frame = ttk.LabelFrame(self.root, text="Outputs")
out_frame.grid(row=1, column=1, sticky="nsew", **pad)
self.output_leds = []
for i in range(NUM_OUTPUTS):
led = self._make_led(out_frame, f"OUT{i + 1}", row=0, col=i)
self.output_leds.append(led)
btns = ttk.Frame(out_frame)
btns.grid(row=2, column=i, padx=4, pady=2)
ttk.Button(btns, text="On", width=4,
command=lambda i=i: self._set_output(i, True)
).pack(side="left")
ttk.Button(btns, text="Off", width=4,
command=lambda i=i: self._set_output(i, False)
).pack(side="left")
ttk.Button(btns, text="Toggle", width=7,
command=lambda i=i: self._toggle_output(i)
).pack(side="left")
# --- status row --------------------------------------------------
status_bar = ttk.Frame(self.root)
status_bar.grid(row=2, column=0, columnspan=2, sticky="ew", **pad)
ttk.Button(status_bar, text="Request status",
command=self._request_status).pack(side="left")
self.uptime_label = ttk.Label(status_bar, text="uptime: —")
self.uptime_label.pack(side="left", padx=12)
self.fresh_label = ttk.Label(status_bar, text="")
self.fresh_label.pack(side="left", padx=12)
# --- log -----------------------------------------------------------
log_frame = ttk.LabelFrame(self.root, text="Bus log")
log_frame.grid(row=3, column=0, columnspan=2, sticky="nsew", **pad)
self.log_text = tk.Text(log_frame, width=88, height=12,
state="disabled", font=("Courier", 9))
self.log_text.pack(side="left", fill="both", expand=True)
scroll = ttk.Scrollbar(log_frame, command=self.log_text.yview)
scroll.pack(side="right", fill="y")
self.log_text.configure(yscrollcommand=scroll.set)
def _make_led(self, parent, label, row, col):
"""Create one round indicator + caption; return the canvas."""
canvas = tk.Canvas(parent, width=34, height=34, highlightthickness=0)
canvas.grid(row=row, column=col, padx=10, pady=(6, 0))
oval = canvas.create_oval(4, 4, 30, 30, fill=COLOR_OFF, outline="#222")
canvas.oval = oval
ttk.Label(parent, text=label).grid(row=row + 1, column=col)
return canvas
def _set_led(self, canvas, on, stale=False):
color = COLOR_STALE if stale else (COLOR_ON if on else COLOR_OFF)
canvas.itemconfig(canvas.oval, fill=color)
# ------------------------------------------------------------ connection --
def _refresh_ports(self):
ports = list_ports.comports()
values = [f"{p.device} — {p.description}" for p in ports]
self.port_combo["values"] = values
if values and not self.port_combo.get():
self.port_combo.current(0)
def _selected_port(self):
sel = self.port_combo.get()
return sel.split(" — ")[0].strip() if sel else None
@property
def status_id(self):
return self.active_base + 0
@property
def command_id(self):
return self.active_base + 1
def _toggle_connection(self):
if self.bus:
self._disconnect()
else:
self._connect()
def _connect(self):
port = self._selected_port()
if not port:
messagebox.showwarning("No port", "Select a serial port first.")
return
try:
# Base ID entry is interpreted as hex ("0x100" or "100").
self.active_base = int(
self.base_id_entry.get().strip().replace("0x", ""), 16)
except ValueError:
messagebox.showerror("Bad base ID",
"Base ID must be hex, e.g. 0x100")
return
try:
self.bus = can.Bus(
interface="slcan",
channel=port,
ttyBaudrate=DEFAULT_TTY_BAUD,
bitrate=int(self.bitrate_combo.get()),
)
except Exception as exc:
messagebox.showerror("Connection failed", str(exc))
self.bus = None
return
self.rx_running.set()
self.rx_thread = threading.Thread(target=self._rx_loop, daemon=True)
self.rx_thread.start()
self.connect_btn.config(text="Disconnect")
self.conn_label.config(text=f"connected: {port}", foreground="green")
self._log(f"connected to {port} @ {self.bitrate_combo.get()} bit/s")
# Ask the board for its current state right away.
self.root.after(300, self._request_status)
def _disconnect(self):
self.rx_running.clear()
if self.rx_thread:
self.rx_thread.join(timeout=1.0)
self.rx_thread = None
if self.bus:
try:
self.bus.shutdown()
except Exception:
pass
self.bus = None
self.connect_btn.config(text="Connect")
self.conn_label.config(text="disconnected", foreground="gray")
self.last_status_time = None
self._log("disconnected")
# ------------------------------------------------------------------- RX --
def _rx_loop(self):
"""Background thread: read frames, hand them to the GUI thread."""
while self.rx_running.is_set():
try:
msg = self.bus.recv(timeout=0.2)
except Exception as exc:
self.rx_queue.put(("error", str(exc)))
break
if msg is not None:
self.rx_queue.put(("frame", msg))
def _poll_rx_queue(self):
"""GUI thread: apply everything the RX thread queued up."""
try:
while True:
kind, payload = self.rx_queue.get_nowait()
if kind == "frame":
self._handle_frame(payload)
elif kind == "error":
self._log(f"RX error: {payload}")
self._disconnect()
except queue.Empty:
pass
self._update_freshness()
self.root.after(50, self._poll_rx_queue)
def _handle_frame(self, msg):
data = bytes(msg.data)
if (not msg.is_extended_id and msg.arbitration_id == self.status_id
and len(data) >= 8):
self.inputs, self.outputs = data[0], data[1]
reason = REASONS.get(data[2], f"?{data[2]}")
uptime = int.from_bytes(data[4:8], "little")
self.last_status_time = time.monotonic()
for i, led in enumerate(self.input_leds):
self._set_led(led, (self.inputs >> i) & 1)
for i, led in enumerate(self.output_leds):
self._set_led(led, (self.outputs >> i) & 1)
h, rem = divmod(uptime, 3600)
m, s = divmod(rem, 60)
self.uptime_label.config(text=f"uptime: {h:d}:{m:02d}:{s:02d}")
# Heartbeats every second would flood the log; only log events.
if reason != "periodic":
self._log(f"RX 0x{msg.arbitration_id:03X} "
f"{data.hex(' ')} (status: {reason})")
else:
self._log(f"RX 0x{msg.arbitration_id:03X} {data.hex(' ')}")
def _update_freshness(self):
"""Gray out the view when the board stops talking."""
if self.bus is None:
self.fresh_label.config(text="")
return
if self.last_status_time is None:
self.fresh_label.config(text="waiting for board...",
foreground="orange")
return
age = time.monotonic() - self.last_status_time
if age > STALE_AFTER_S:
self.fresh_label.config(
text=f"STALE — last status {age:.0f}s ago",
foreground="red")
for led in self.input_leds + self.output_leds:
self._set_led(led, False, stale=True)
else:
self.fresh_label.config(text="live", foreground="green")
# ------------------------------------------------------------------- TX --
def _send(self, data, what):
if not self.bus:
messagebox.showinfo("Not connected", "Connect to an SLCAN "
"device first.")
return
try:
msg = can.Message(arbitration_id=self.command_id,
data=bytes(data), is_extended_id=False)
self.bus.send(msg, timeout=0.5)
self._log(f"TX 0x{self.command_id:03X} "
f"{bytes(data).hex(' ')} ({what})")
except Exception as exc:
self._log(f"TX error: {exc}")
def _request_status(self):
self._send([CMD_GET_STATUS], "get status")
def _set_output(self, index, on):
self._send([CMD_SET_OUTPUT, index, 1 if on else 0],
f"OUT{index + 1} {'on' if on else 'off'}")
def _toggle_output(self, index):
self._send([CMD_TOGGLE, index], f"toggle OUT{index + 1}")
# ------------------------------------------------------------------ misc --
def _log(self, text):
stamp = time.strftime("%H:%M:%S")
self.log_text.configure(state="normal")
self.log_text.insert("end", f"{stamp} {text}\n")
# Cap the log at ~500 lines so long sessions stay snappy.
if int(self.log_text.index("end-1c").split(".")[0]) > 500:
self.log_text.delete("1.0", "100.0")
self.log_text.see("end")
self.log_text.configure(state="disabled")
def _on_close(self):
self._disconnect()
self.root.destroy()
def main():
root = tk.Tk()
try: # nicer widgets where available
ttk.Style().theme_use("clam")
except tk.TclError:
pass
CanIoGui(root)
root.mainloop()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,2 @@
python-can>=4.2
pyserial>=3.5

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

248
dashboard/app.py Normal file
View file

@ -0,0 +1,248 @@
"""Live/replay dashboard for the BMW E8x PT-CAN capture.
"/Users/luca/Projects/Stuurhuis CAN/.venv/bin/python" -m streamlit run dashboard/app.py
Two data sources, selectable in the sidebar:
- Live capture: reads the CANdapter in the background and feeds the dashboard
in real time. Optionally records the session to a new CSV.
- Replay file: steps through an existing capture_*.csv at an adjustable
speed, so you can scrub through what happened without the car running.
Decoding uses decoder/ptcan_decoder.py, which wraps the signal definitions from
files/decode_ptcan.py / files/PTCAN_protocol.md / files/bmw_e8x_ptcan.dbc.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from plotly.subplots import make_subplots
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from decoder.live_source import LiveCapture # noqa: E402
from decoder.ptcan_decoder import FrameStore, NAMES # noqa: E402
from decoder.replay_source import ReplayPlayer, load_capture # noqa: E402
CAPTURES_DIR = ROOT / "captures"
DEFAULT_PORT = "/dev/cu.usbserial-DNBJV4F5"
REFRESH_INTERVAL = 0.25 # seconds between dashboard reruns while a source is active
st.set_page_config(page_title="PT-CAN — BMW E8x", layout="wide")
@st.cache_resource
def get_store() -> FrameStore:
return FrameStore()
@st.cache_resource
def get_live() -> LiveCapture:
return LiveCapture()
@st.cache_resource
def get_player(csv_path: str) -> ReplayPlayer:
return ReplayPlayer(load_capture(Path(csv_path)))
store = get_store()
live = get_live()
# ---------------------------------------------------------------- sidebar --
st.sidebar.title("PT-CAN — BMW E8x")
mode = st.sidebar.radio("Source", ["Live capture", "Replay file"])
active = False # whether the dashboard should keep auto-rerunning
if mode == "Live capture":
port = st.sidebar.text_input("Port", DEFAULT_PORT)
bitrate = st.sidebar.selectbox("Bitrate", [500_000, 250_000, 125_000, 1_000_000], index=0)
record = st.sidebar.checkbox("Record session to CSV", value=True)
c1, c2 = st.sidebar.columns(2)
if c1.button("Start", disabled=live.is_running(), width="stretch"):
store.reset()
record_path = CAPTURES_DIR / time.strftime("capture_%Y%m%d_%H%M%S.csv") if record else None
live.start(port, bitrate, store, record_path=record_path)
if record_path:
st.sidebar.success(f"Recording to {record_path.name}")
if c2.button("Stop", disabled=not live.is_running(), width="stretch"):
live.stop()
if live.error:
st.sidebar.error(f"Adapter error: {live.error}")
elif live.is_running():
st.sidebar.success(f"Connected — {live.frame_count} frames")
else:
st.sidebar.caption("Stopped")
active = live.is_running()
else:
files = sorted(p.name for p in CAPTURES_DIR.glob("capture*.csv"))
if not files:
st.sidebar.warning("No capture_*.csv files found in captures/.")
st.stop()
chosen = st.sidebar.selectbox("Capture file", files)
player = get_player(str(CAPTURES_DIR / chosen))
if "replay_playing" not in st.session_state:
st.session_state.replay_playing = False
st.session_state.replay_wall = None
speed = st.sidebar.slider("Playback speed", 0.25, 8.0, 1.0, step=0.25)
c1, c2, c3 = st.sidebar.columns(3)
label = "Pause" if st.session_state.replay_playing else "Play"
if c1.button(label, width="stretch"):
st.session_state.replay_playing = not st.session_state.replay_playing
st.session_state.replay_wall = time.monotonic()
if c2.button("Restart", width="stretch"):
player.reset()
store.reset()
st.session_state.replay_playing = False
if c3.button("Seek", width="stretch"):
pass # handled via the slider below, this just forces a rerun
seek_to = st.sidebar.slider("Position (s)", 0.0, max(player.duration, 0.1), min(player.clock, player.duration), step=0.5)
if abs(seek_to - player.clock) > 0.5:
player.seek(seek_to, store)
if st.session_state.replay_playing:
now = time.monotonic()
dt = min(now - (st.session_state.replay_wall or now), 1.0)
st.session_state.replay_wall = now
player.advance(dt, speed, store)
if player.at_end():
st.session_state.replay_playing = False
st.sidebar.caption(f"{player.clock:.1f}s / {player.duration:.1f}s")
active = st.session_state.replay_playing
st.sidebar.divider()
window_s = st.sidebar.slider("Chart window (s)", 5, 120, 30)
if st.sidebar.button("Clear dashboard data"):
store.reset()
# --------------------------------------------------------------- helpers --
COLORS = {"rpm": "#FFA733", "steer": "#FF5C4D", "speed": "#56AEF2", "term": "#5FD6A0", "volt": "#B48CF2", "fuel": "#E8C87A"}
def signal_value(snapshot: dict, arbitration_id: int, name: str):
entry = snapshot["latest"].get(arbitration_id)
if not entry:
return None, ""
return entry["decoded"].get(name, (None, ""))
snap = store.snapshot()
# ------------------------------------------------------------------ KPIs --
st.subheader("Key signals")
k1, k2, k3, k4, k5, k6 = st.columns(6)
angle, _ = signal_value(snap, 0x0C4, "steering_angle")
rate, _ = signal_value(snap, 0x0C4, "steering_rate")
rpm, _ = signal_value(snap, 0x0AA, "engine_speed")
speed_kmh, _ = signal_value(snap, 0x1A0, "road_speed")
volt, _ = signal_value(snap, 0x0A9, "system_voltage")
fuel, _ = signal_value(snap, 0x1D0, "fuel_accum")
terminal, _ = signal_value(snap, 0x130, "terminal")
k1.metric("Steering angle (0x0C4)", f"{angle:.1f}°" if angle is not None else "—")
k2.metric("Steering rate (0x0C4)", f"{rate:.0f}°/s" if rate is not None else "—")
k3.metric("Engine speed (0x0AA)", f"{rpm:.0f} rpm" if rpm is not None else "—")
k4.metric("Road speed (0x1A0)", f"{speed_kmh:.1f} km/h" if speed_kmh is not None else "—")
k5.metric("System voltage (0x0A9)", f"{volt:.2f} V" if volt is not None else "—")
k6.metric("Terminal (0x130)", str(terminal) if terminal is not None else "—")
# --------------------------------------------------------------- charts --
st.subheader("Decoded channels over time")
fig = make_subplots(
rows=4,
cols=1,
shared_xaxes=True,
vertical_spacing=0.04,
specs=[[{}], [{"secondary_y": True}], [{}], [{}]],
subplot_titles=("Engine speed (rpm)", "Steering angle / rate", "System voltage (V)", "Road speed (km/h)"),
)
xs, ys = store.series(0x0AA, "engine_speed", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=COLORS["rpm"]), name="rpm"), row=1, col=1)
xs, ys = store.series(0x0C4, "steering_angle", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=COLORS["steer"]), name="angle (deg)"), row=2, col=1, secondary_y=False)
xs, ys = store.series(0x0C4, "steering_rate", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color="#7a4238"), name="rate (deg/s)"), row=2, col=1, secondary_y=True)
xs, ys = store.series(0x0A9, "system_voltage", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=COLORS["volt"]), name="voltage"), row=3, col=1)
xs, ys = store.series(0x1A0, "road_speed", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=COLORS["speed"]), name="speed"), row=4, col=1)
fig.update_layout(height=650, showlegend=False, margin=dict(l=40, r=20, t=30, b=20), template="plotly_dark")
st.plotly_chart(fig, width="stretch")
# --------------------------------------------------------------- monitor --
st.subheader("Bus monitor")
rows = []
for aid in sorted(snap["latest"]):
entry = snap["latest"][aid]
decoded = ", ".join(f"{k}={v[0]:.2f}{v[1]}" if isinstance(v[0], float) else f"{k}={v[0]}{v[1]}" for k, v in entry["decoded"].items())
rows.append(
{
"ID": f"0x{aid:03X}",
"Message": NAMES.get(aid, ""),
"Hz": round(store.hz(aid), 1),
"Count": snap["counts"].get(aid, 0),
"Data": entry["data"].hex(" ").upper(),
"Decoded": decoded,
}
)
if rows:
df = pd.DataFrame(rows).sort_values("ID")
st.dataframe(df, width="stretch", height=380, hide_index=True)
else:
st.info("No frames yet — start a live capture or play a replay file.")
# ------------------------------------------------------------- bit view --
st.subheader("Bit inspector")
st.caption("Pick an ID to see its raw bytes bit-by-bit. Highlighted bits changed since the previous frame — the fastest way to spot an unidentified signal (candidates from the protocol notes: 0x1B6 and 0x335).")
ids_present = sorted(snap["latest"])
if ids_present:
default_idx = ids_present.index(0x1B6) if 0x1B6 in ids_present else 0
picked = st.selectbox("Arbitration ID", ids_present, index=default_idx, format_func=lambda i: f"0x{i:03X} {NAMES.get(i, '')}")
entry = snap["latest"][picked]
data, changed = entry["data"], entry["changed"]
html = ["<div style='font-family:monospace;font-size:13px'>"]
html.append("<table style='border-collapse:collapse'><tr><th style='padding:2px 8px'>byte</th>" + "".join(f"<th style='padding:2px 6px'>{b}</th>" for b in range(7, -1, -1)) + "<th style='padding:2px 8px'>hex</th></tr>")
for i, byte in enumerate(data):
cells = []
chg_byte = changed[i] if changed else 0
for bit in range(7, -1, -1):
on = (byte >> bit) & 1
toggled = (chg_byte >> bit) & 1
bg = "#FF5C4D" if toggled else ("#3a4a58" if on else "#151D25")
fg = "#0E1419" if on or toggled else "#485A67"
cells.append(f"<td style='padding:3px 6px;text-align:center;background:{bg};color:{fg};border:1px solid #28343E'>{on}</td>")
html.append(f"<tr><td style='padding:2px 8px;color:#6E8290'>{i}</td>{''.join(cells)}<td style='padding:2px 8px;color:#C9D7E0'>0x{byte:02X}</td></tr>")
html.append("</table></div>")
st.markdown("".join(html), unsafe_allow_html=True)
if entry["decoded"]:
st.write({name: f"{val:.3f}{unit}" if isinstance(val, float) else f"{val}{unit}" for name, (val, unit) in entry["decoded"].items()})
else:
st.info("No frames yet.")
# ----------------------------------------------------------- keep alive --
if active:
time.sleep(REFRESH_INTERVAL)
st.rerun()

0
decoder/__init__.py Normal file
View file

89
decoder/live_source.py Normal file
View file

@ -0,0 +1,89 @@
"""Background-thread wrapper around Candapter for use from the Streamlit UI."""
from __future__ import annotations
import csv
import sys
import threading
import time
from pathlib import Path
from typing import Optional
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from adapters.candapter import Candapter # noqa: E402
class CsvRecorder:
"""Appends frames to a CSV in the same format log_can.py uses."""
def __init__(self, path: Path):
self._file = open(path, "w", newline="")
self._writer = csv.writer(self._file)
self._writer.writerow(["timestamp_ms", "arbitration_id", "is_extended", "dlc", "data_hex"])
def write(self, frame) -> None:
self._writer.writerow(
[
frame.timestamp_ms if frame.timestamp_ms is not None else "",
f"{frame.arbitration_id:X}",
int(frame.is_extended),
len(frame.data),
frame.data.hex().upper(),
]
)
def close(self) -> None:
self._file.close()
class LiveCapture:
"""Runs Candapter.read_frame() on a background thread, feeding a FrameStore."""
def __init__(self) -> None:
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self.error: Optional[str] = None
self.connected = False
self.frame_count = 0
def is_running(self) -> bool:
return bool(self._thread and self._thread.is_alive())
def start(self, port: str, bitrate: int, store, record_path: Optional[Path] = None) -> None:
if self.is_running():
return
self._stop.clear()
self.error = None
self.frame_count = 0
self._thread = threading.Thread(target=self._run, args=(port, bitrate, store, record_path), daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=2)
self.connected = False
def _run(self, port: str, bitrate: int, store, record_path: Optional[Path]) -> None:
try:
adapter = Candapter(port, bitrate, timestamps=True)
except (OSError, ValueError) as exc:
self.error = str(exc)
return
recorder = CsvRecorder(record_path) if record_path else None
self.connected = True
start_wall = time.time()
try:
with adapter:
while not self._stop.is_set():
frame = adapter.read_frame(timeout=0.5)
if frame is None:
continue
store.feed(frame.arbitration_id, frame.data, frame.recv_time - start_wall)
self.frame_count += 1
if recorder is not None:
recorder.write(frame)
finally:
self.connected = False
if recorder is not None:
recorder.close()

85
decoder/ptcan_decoder.py Normal file
View file

@ -0,0 +1,85 @@
"""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

57
decoder/replay_source.py Normal file
View file

@ -0,0 +1,57 @@
"""Load a capture CSV and step through it as a virtual live feed."""
from __future__ import annotations
import csv
from pathlib import Path
def load_capture(path: Path) -> list[tuple[float, int, bytes]]:
"""Returns (t_seconds, arbitration_id, data) sorted by time, t relative to first frame.
Mirrors files/decode_ptcan.py: sort by timestamp_ms first since these logs can contain
the odd corrupt row, and the adapter's ms counter wraps every 60000ms anyway.
"""
rows = []
with open(path, newline="") as f:
for row in csv.DictReader(f):
hx = row["data_hex"].strip()
rows.append((int(row["timestamp_ms"]), int(row["arbitration_id"], 16), bytes.fromhex(hx)))
rows.sort(key=lambda r: r[0])
if not rows:
return []
t0 = rows[0][0]
return [((ms - t0) / 1000, aid, data) for ms, aid, data in rows]
class ReplayPlayer:
"""Feeds a FrameStore at a scaled real-time rate, tracking position in the log."""
def __init__(self, frames: list[tuple[float, int, bytes]]):
self.frames = frames
self.duration = frames[-1][0] if frames else 0.0
self.index = 0
self.clock = 0.0
def reset(self) -> None:
self.index = 0
self.clock = 0.0
def at_end(self) -> bool:
return self.index >= len(self.frames)
def advance(self, dt_wall: float, speed: float, store) -> None:
self.clock += dt_wall * speed
while self.index < len(self.frames) and self.frames[self.index][0] <= self.clock:
t, aid, data = self.frames[self.index]
store.feed(aid, data, t)
self.index += 1
def seek(self, t: float, store) -> None:
"""Jump to time t, replaying everything up to it into a freshly reset store."""
store.reset()
self.index = 0
self.clock = t
while self.index < len(self.frames) and self.frames[self.index][0] <= self.clock:
frame_t, aid, data = self.frames[self.index]
store.feed(aid, data, frame_t)
self.index += 1

338
eps-comms/EPS_PROTOCOL.md Normal file
View file

@ -0,0 +1,338 @@
# BMW E8x EPS — standalone operating protocol
How to power up, control and monitor the E8x/E9x electric power steering
(EPS) unit outside the donor car — e.g. in a custom EV conversion.
Everything here was derived by observation from a working car and verified
against the real unit on a bench. Where something is inferred rather than
proven, it says so explicitly: **[A]** proven on hardware, **[B]** strongly
indicated, **[C]** inference worth testing. Trusting a **[C]** without
checking it is how you end up debugging the wrong layer.
Source material: `eps-comms/findings.md` (chronological log),
`files/PTCAN_protocol.md` (whole-bus reverse engineering), and the capture
and bench logs in `captures/`.
---
## 1. What the EPS actually needs
The short version, and the surprise of this project: **two CAN messages and
one 12V wire.** Not the 69-message firehose the car puts on the bus.
| Requirement | Detail | Confidence |
|---|---|---|
| 12V enable signal | Discrete wire, not CAN. Unit stays dead without it | **[A]** |
| `0x130` CAS terminal status | 100 ms cycle. Brings the unit up | **[A]** |
| `0x1A0` DSC road speed | 20 ms cycle. Sets assist level | **[A]** |
| CAN bus @ 500 kbit/s | 11-bit identifiers, classic CAN 2.0 | **[A]** |
| A second node on the bus | See §2.3 — CAN needs someone to ACK | **[A]** |
Everything else the car transmits (engine data, wheel speeds, diagnostics,
VIN, cluster, comfort modules) turned out to be unnecessary for steering
assist. That was established by loading progressively smaller transmit sets
and watching whether the unit still came up and assisted.
---
## 2. Electrical and bus setup
### 2.1 Power and enable
The EPS needs its main power feed **and** a separate 12V enable/wake signal.
With power but no enable, the unit is completely inert: no CAN transmission,
no assist. **[A]**
This creates a chicken-and-egg worth designing around: until the EPS is
enabled it is not on the bus, so a lone controller has nobody to ACK its
frames and every transmit fails (see §2.3). Your controller must therefore be
able to assert the enable line *without* depending on successful CAN traffic.
### 2.2 Bus parameters
```
Bitrate 500 kbit/s
Identifiers 11-bit (standard)
Frame format classic CAN 2.0A
Termination 120 Ω at each end of the segment (~60 Ω measured across H/L, powered down)
```
### 2.3 The ACK requirement
CAN requires at least one *other* node to acknowledge a frame. On a bench
with only your controller and an unpowered EPS, every transmission fails
arbitration and the controller reports timeouts — with the ESP32 TWAI
peripheral this surfaces as `ESP_ERR_TIMEOUT` on every send. **[A]**
This is normal, not a fault. It resolves the moment the EPS is enabled and
joins the bus. Design implication: don't gate your enable logic behind
"CAN is working", and don't treat early TX failures as a hardware problem.
---
## 3. Messages you must transmit
### 3.1 `0x130` — CAS terminal status · 5 bytes · 100 ms **[A]**
The message that brings the unit up. Byte 0 carries the terminal (ignition)
state; the rest is supporting state plus a counter and checksum.
| Byte | Contents | Confidence |
|---|---|---|
| 0 | Terminal state — see table below | **[A]** |
| 1 | `0x00` when terminal is off, `0x40` in every other state | **[A]** |
| 2 | Supporting flags. 9 distinct values observed, `0x21` and `0xD0` dominate | **[B]** |
| 3 | Supporting flags. 9 distinct values, `0x8F`/`0x0F` dominate | **[B]** |
| 4 | Low nibble: alive counter 0–14, skips 15. High nibble: checksum | **[A]** / **[B]** |
Terminal states, all observed in the donor car:
| `b0` | Meaning | Use |
|---|---|---|
| `0x00` | Everything off | Resting state |
| `0x40` | Terminal R (accessory) | First step of a wake-up |
| `0x41` | Terminal 15 (ignition on, engine off) | Unit powers up here |
| `0x45` | Engine running | **Normal operating state** |
| `0x55` | Cranking (starter engaged) | Transient; assist inhibited |
### 3.2 `0x1A0` — DSC road speed · 8 bytes · 20 ms **[A]**
Controls how much assist the unit provides — more speed, less assist, the
standard speed-sensitive steering behaviour.
| Bits | Signal | Format | Confidence |
|---|---|---|---|
| b0 + low nibble of b1 | Road speed | 12-bit unsigned, **0.1 km/h per bit** | **[A]** |
| b1 bit 7 | Standstill/validity flag — set while stationary | flag | **[B]** |
| b2–b5 | DSC state (all constant in the reference capture) | — | **[C]** |
| b6 high nibble | Alive counter | 0–14 | **[B]** |
| b7 | Checksum | See §6 | **[B]** |
Decoding, and the encoding used by `gateway/eps_control.py`:
```python
speed_kmh = (b0 | ((b1 & 0x0F) << 8)) * 0.1
raw = int(round(speed_kmh / 0.1)) # 0..4095
b0 = raw & 0xFF
b1 = (b1 & 0xF0) | ((raw >> 8) & 0x0F) # keep the flag nibble
# clear bit 7 of b1 when moving, set it when stationary
```
---
## 4. Messages the EPS transmits
Useful for health monitoring and fault detection. None of it needs to be
consumed for the unit to function.
### 4.1 `0x1FB` — alive counter · 2 bytes · ~4 Hz **[A]**
The cleanest health indicator available.
| Byte | Contents |
|---|---|
| 0 | High nibble constant `0xF`; low nibble = alive counter, 0–14, skipping 15 |
| 1 | `0xFF` constant |
**If this counter stops advancing, the EPS has stopped.** It is the single
best signal to watch in a control loop.
### 4.2 `0x4B0` — status heartbeat · 8 bytes · ~1 Hz **[B]**
| Byte | Contents | Confidence |
|---|---|---|
| 0 | Checksum — high entropy, 5 values observed | **[C]** |
| 1 | Low nibble counter-like; high nibble flags (`0x0`, `0x1`, `0x4` seen) | **[C]** |
| 2 | `0xFE` constant | **[A]** |
| 3 | Normally `0xFF`; briefly `0x01` during start-up | **[B]** |
| 4–7 | `0xFF` constant (unused) | **[A]** |
### 4.3 `0x5B0` — power-up state · 8 bytes · event-driven **[B]**
Only two payloads ever observed, which makes this a clean state marker:
| Payload | Meaning |
|---|---|
| `01 03 80 FF FF FF FF FF` | Initialising, immediately after enable |
| `40 81 01 15 FF FF FF FF` | Ready / settled |
---
## 5. Bring-up and shutdown
### 5.1 Startup **[A]**
Order matters. The unit prefers to wake into a bus that is already
populated, rather than into silence:
```
1. Begin transmitting 0x130 with b0 = 0x00 (off) and 0x1A0 at 0 km/h
→ the bus is alive before the EPS joins it
2. Wait ~1 s
3. Assert the 12V enable
4. Wait ~0.5 s → 0x5B0 "initialising" appears
5. 0x130 b0 → 0x40 (terminal R), hold ~1.5 s
6. 0x130 b0 → 0x41 (terminal 15), hold ~1.5 s
7. 0x130 b0 → 0x45 (engine running), hold ~2 s
8. Confirm 0x1FB is advancing and 0x5B0 reports "ready"
```
Expect the unit to answer within a few seconds of the enable. Keep
transmitting continuously from step 1 — a gap is treated as sender failure.
### 5.2 Shutdown **[B]**
Walk the states back down before removing power, rather than cutting the
enable outright:
```
1. 0x130 b0 → 0x41 (terminal 15), hold ~1 s
2. 0x130 b0 → 0x40 (terminal R), hold ~1 s
3. 0x130 b0 → 0x00 (off), hold ~1 s
4. Remove the 12V enable
5. Stop transmitting
```
---
## 6. Counters and checksums — the honest status
Both required messages carry an alive counter and a checksum. **The counter
scheme is solved; the checksums are not.** This is the main open problem for
a fully synthetic implementation.
### 6.1 Alive counters **[A]**
Universal on this bus: a 4-bit counter running **0 → 14, skipping 15**,
incrementing once per transmitted frame.
| Message | Counter location |
|---|---|
| `0x130` | byte 4, low nibble |
| `0x1A0` | byte 6, high nibble |
A frozen counter is read as a stale or faulty sender and the message is
ignored — this was the single biggest cause of "the unit won't come up"
during development. Retransmitting a captured payload verbatim does not
work; the counter must advance.
### 6.2 Checksums — unsolved
Brute-forced across sum, one's-complement sum and XOR, every byte range, all
256 constants, with and without counter contribution:
| Message | Checksum byte | Best fit found | Match rate |
|---|---|---|---|
| `0x1A0` | b7 | fold(sum(b2..b6)) + `0x22` | **87.5 %** |
| `0x130` | b4 high nibble | sum of nibbles(b0..b3) + counter + `0x6` | **64 %** |
Neither is good enough to generate frames blind. A CRC-8 search over all 256
polynomials and seeds also found nothing (`files/PTCAN_protocol.md`).
**One useful consequence of the `0x1A0` result:** the best-fitting range is
**bytes 2–6, which excludes the speed field in bytes 0–1**. If that is
correct, changing road speed does not invalidate the checksum. `eps_control.py`
relies on this, and it holds in testing — but it is **[C]**, so verify on
your own unit by watching `0x1FB` when you move the speed slider.
### 6.3 The workaround that actually works **[A]**
Rather than synthesising checksums, **replay genuine captured frames**:
- For `0x130`, record the car sending each terminal state and keep those
frame sequences. Changing state means switching which recorded sequence
you are cycling. Counters and checksums are then exactly what the car
produced.
- For `0x1A0`, patch the speed field into a captured frame and leave bytes
2–7 untouched (per §6.2).
This is what `gateway/eps_control.py` does, and it drives the unit reliably.
The frame inventory available in `captures/replay_car_to_eps_20260829.csv`:
| Terminal state | Captured frames |
|---|---|
| off (`0x00`) | 230 |
| terminal R (`0x40`) | 21 |
| ignition (`0x41`) | 121 |
| engine running (`0x45`) | 456 |
| cranking (`0x55`) | 8 |
For a production build, solving the checksums properly is worth the effort —
the replay approach ties you to a recording, and cannot express a state the
donor car never produced.
---
## 7. Timing requirements
Underestimated during development, and the cause of most of the
intermittent faults seen along the way.
| Message | Period | Rate |
|---|---|---|
| `0x130` | 100 ms | 10 frames/s |
| `0x1A0` | 20 ms | 50 frames/s |
| **Minimum total** | | **60 frames/s** |
Late frames look identical to a faulty sender. A PC-hosted controller
transmitting the full 69-message set needs ~1330 frames/s and struggled to
sustain it, producing exactly the intermittent dropouts that looked like an
EPS fault. The two-message set needs 60 frames/s — trivial for any
microcontroller, and far more reliable.
**Design guidance for a custom EV:** generate these messages on a
microcontroller with a hardware CAN controller and a timer-driven loop. Do
not put a PC, USB-serial link or non-realtime OS in the path.
---
## 8. Minimum viable controller
What a standalone implementation has to do:
1. Drive a 12V enable output, independently of CAN state.
2. Transmit `0x130` every 100 ms with the desired terminal state, an
advancing counter and a valid checksum.
3. Transmit `0x1A0` every 20 ms with the current road speed, an advancing
counter and a valid checksum.
4. Monitor `0x1FB` — if its counter stops advancing, the EPS has dropped
out; re-run the startup sequence.
5. Follow the startup and shutdown sequences in §5.
Optional but recommended: watch `0x5B0` for the ready transition, and log
`0x4B0` for post-mortem fault analysis.
---
## 9. Verification checklist
Before trusting this on a moving vehicle:
- [ ] Confirm assist actually varies with the `0x1A0` speed value, across
the range you intend to use. Only 0 km/h is proven from the donor car.
- [ ] Confirm the `0x1A0` checksum inference (§6.2) by sweeping speed and
watching `0x1FB` for dropouts.
- [ ] Establish what the EPS does when CAN stops entirely at speed —
does assist fade, or cut abruptly?
- [ ] Establish behaviour on counter/checksum errors: ignored frame, or
latched fault needing a power cycle?
- [ ] Confirm whether `0x45` (engine running) is required for full assist,
or whether `0x41` (ignition) is sufficient.
- [ ] Solve the checksums (§6.2) if you need states the donor car never
produced.
- [ ] Check for a torque-sensor or steering-angle input requirement under
load — the bench testing was done stationary.
---
## 10. Tooling in this repository
| Tool | Purpose |
|---|---|
| `gateway/gateway_app.py` | Four-tab UI: live relay, replay, bench, EPS control |
| `gateway/eps_control.py` | Minimal two-message controller — the reference implementation |
| `gateway/eps_bench.py` | Transmit arbitrary message sets to find what's needed |
| `tools/analyze_bench_session.py` | Enable-vs-online timing, dropouts, TX rate health |
| `tools/analyze_startup_order.py` | What preceded the unit coming online |
| `tools/solve_checksum.py` | Brute-force checksum solver |

101
eps-comms/README.md Normal file
View file

@ -0,0 +1,101 @@
# EPS communication notes
Findings about what the Electric Power Steering (EPS) unit needs on the bus,
built up from gateway sessions between the car's PT-CAN bus and the EPS's
(isolated) bus via the CAN-IO board.
- **[EPS_PROTOCOL.md](EPS_PROTOCOL.md)** — the consolidated result: how to
power up, control and monitor the EPS standalone (e.g. in a custom EV).
Start here.
- **[findings.md](findings.md)** — chronological working log: what was
tested, what broke, and why.
**Headline result:** the EPS needs only two CAN messages (`0x130` terminal
status, `0x1A0` road speed) plus a 12V enable wire — not the 69 messages the
car puts on the bus. Total required rate: 60 frames/s.
## Setup
```
car PT-CAN --[CANdapter]-- gateway (this repo) --[CAN-IO board USB bridge]-- EPS bus -- EPS unit
```
- Car side: CANdapter, port varies (`/dev/cu.usbserial-DNBJV4F5` as of writing).
- EPS side: CAN-IO board (XIAO ESP32-S3, `can-io/`), port varies (`/dev/cu.usbmodemXXXX` -
changes on replug, always re-check with `ls /dev/cu.*`).
- Both sides run at 500 kbit/s.
- Tool: `gateway/gateway_app.py` (live relay + per-ID filter + IO test) or
`gateway/replay_to_bus.py` (replay a capture onto one side, no car needed).
- Every gateway session logs every frame seen (relayed or not) to
`captures/gateway_<timestamp>.csv` - columns: `t, direction, arbitration_id,
is_extended, dlc, data_hex, relayed`.
## Workflow
1. **Transparent relay** (current stage): everything allowed both ways,
confirm EPS + car both still behave normally through the gateway.
2. **Filter down**: block IDs one at a time (or in groups) via the "Allow"
checkboxes in `gateway_app.py`, re-test after each cut, to find the
minimal set of car-bus messages the EPS actually needs.
3. **Replay-only**: once step 2 gives a candidate minimal set, use
`gateway/replay_to_bus.py` to feed just those messages to the EPS with
the car fully disconnected, to confirm it's really enough to bring the
EPS up on its own.
4. **Synthesize**: once replay works, write a generator that produces those
messages from scratch (no capture needed) - not started yet.
## Tools
- `gateway/gateway_app.py` - live relay + per-ID filter + IO test (Streamlit).
- `gateway/replay_to_bus.py` - replay a capture CSV onto one adapter, with
the same FilterRules filtering, `--dry-run` needs no hardware.
- `tools/gateway_log_to_capture.py` - turns a gateway session log (which has
both directions + relay/block status) into a plain capture CSV for
`replay_to_bus.py`, e.g. to replay exactly what a working live session
sent, filtered to one direction and optionally only the frames that were
actually relayed.
- `tools/extract_dio_timeline.py` - pulls the CAN-IO board's own digital
IN1-4/OUT1-2 timeline out of a gateway log (decoded from its 0x100 status
frames) - answers "when was the 12V signal high/low", and feeds
`replay_to_bus.py --dio-log` so a replay reproduces the digital signal
(e.g. the 12V repeat to the EPS) in sync with the CAN traffic, not just
the CAN frames alone. Resolution is limited to whenever the board sent a
status frame (on change, plus a 1s heartbeat) - a change undone within a
couple of scheduling ticks might not show up as its own sample.
- `gateway/rules.suggested.json` / `rules.suggested_car_to_eps.json` -
current best-guess filter (see findings.md for what's blocked and why):
the combined form loads in `gateway_app.py`'s sidebar, the plain form
loads via `replay_to_bus.py --rules`.
## Synthesis plan (not started)
Once a minimal, replay-confirmed message set is known:
1. For each required ID, write an encoder (the mirror image of
`files/decode_ptcan.py`'s `SIGNALS` decoders) that produces the raw bytes
from a target value (angle, speed, rpm, terminal state, ...), including
the alive-counter and checksum schemes already documented in
`files/PTCAN_protocol.md`.
2. Start with static/slowly-changing values (terminal state, a fixed
"engine running" RPM, zero speed) to prove the EPS accepts synthetic
frames at all, before worrying about realistic dynamic values.
3. Time the generator against `gateway/replay_to_bus.py`'s measured periods
per ID (10ms for steering angle, 20ms for road speed, etc.) - this is
also where the jitter hypothesis from findings.md gets tested: a
dedicated generator (ideally on the CAN-IO board itself, not the PC) can
hit those periods far more precisely than the PC-mediated relay.
4. Longer term: move the generator onto the CAN-IO board's firmware itself,
so the final setup needs no PC in the loop at all.
## Known pitfalls (see findings.md for detail)
- **TX-echo feedback loop (fixed 2026-08-29)**: the CAN-IO board's firmware
used to mirror every frame it successfully transmitted back over USB,
including frames the gateway had just asked it to relay from the car bus -
not just its own status/heartbeat. This made every relayed ID falsely
reappear as "new EPS-bus traffic" a moment later, which is what session
`gateway_20260829_162235.csv` shows (64 of 69 "eps->car" IDs were things
just relayed `car->eps` seconds earlier). Fixed in `can_bus.cpp`/`usb_bridge.cpp`
by only mirroring board-originated transmits, not bridge-injected ones.
**Any gateway log captured before this fix should be treated as unreliable
for "what does the EPS bus actually carry" purposes.**

192
eps-comms/findings.md Normal file
View file

@ -0,0 +1,192 @@
# EPS communication findings — running log
Newest entries at the top. Each entry: date, what was tested, what we saw,
what it means, what's still open.
For the consolidated result — how to run the EPS standalone — see
[EPS_PROTOCOL.md](EPS_PROTOCOL.md). This file is the working history.
---
## 2026-08-29 (evening) — minimum message set found: 0x130 + 0x1A0
**Result**: the EPS needs only **two** car messages, not the 69 the bus
carries: `0x130` CAS terminal status brings it up, `0x1A0` DSC road speed
sets the assist level. Plus the 12V enable on a discrete wire. Confirmed on
the bench by narrowing the transmit set.
**Two bugs found along the way, both mine, both looked like EPS faults:**
1. *Frozen counters.* A captured payload retransmitted verbatim leaves its
alive counter stuck, which every consumer treats as a stale sender. This
is why replay worked and static transmission didn't. Fixed by cycling the
captured payload **sequence** per ID rather than one frozen frame.
2. *Transmit rate starvation.* `read_frame()` used a blocking
`serial.read(64)` that could stall the transmit loop for up to 100 ms,
so the bench managed 264 frames/s against the ~1330 the full set needs -
every message arriving late, indistinguishable from a faulty sender.
Fixed by reading only buffered bytes and batching writes: 264 -> 1282
frames/s standalone. The two-message set needs just 60 frames/s, which
removes the problem entirely.
**Checksums remain unsolved.** Brute-forced sum/one's-complement/XOR over
every byte range, all constants, with and without counter contribution:
`0x1A0` b7 best fit 87.5% (fold(b2..b6) + 0x22), `0x130` b4 high nibble best
fit 64%. Not good enough to generate frames blind. Workaround in use:
replay genuine captured frames per terminal state, and patch only the speed
field of `0x1A0` (bytes 0-1, which the best-fitting checksum range excludes).
See EPS_PROTOCOL.md §6.
**EPS output messages decoded**: `0x1FB` is a pure alive counter (b0 low
nibble, 0-14, high nibble 0xF) - the best health signal available; `0x4B0`
is a status heartbeat with a likely checksum in b0; `0x5B0` has exactly two
payloads, `01 03 80…` (initialising) and `40 81 01 15…` (ready).
**Open**: assist has only been observed at 0 km/h, so the speed response
curve is unverified; behaviour on CAN loss at speed is untested; whether
`0x45` is needed or `0x41` suffices is unknown. Full list in
EPS_PROTOCOL.md §9.
---
## 2026-08-29 — clean isolated-bus test (post-fix), EPS's own IDs identified, first ruleset
**Test conditions**: same sequence as before, firmware TX-echo bug fixed
first. Engine actually had to be started this time to get the EPS working;
the dashboard EPS fault was intermittent even then. Nothing blocked yet -
fully transparent relay. Log: `captures/gateway_20260829_164540.csv` (84s,
60643 `car->eps` frames, only **37** `eps->car` frames - the fix worked).
**EPS's own traffic, isolated and confirmed for the first time**:
- `0x100` - our own CAN-IO board status (not the EPS - a test artifact, drop
before any real vehicle use).
- `0x1FB` (2 bytes) - genuinely the EPS module's own alive/counter frame.
Previously mislabeled "counter + checksum, unidentified module" in
`files/PTCAN_protocol.md`, which was written from a whole-bus capture -
now confirmed it's the EPS.
- `0x4B0` (8 bytes, `XX 01 FE FF FF FF FF FF`) - the EPS's own module-status
heartbeat. Byte 0 toggled between `0x00` and `0x30` - possibly a state or
fault-flag nibble worth watching once we can correlate it with the
cluster's fault indicator turning on/off.
**Intermittent fault - two live hypotheses, not yet distinguished**:
1. *Missing/incorrect input*: the EPS needs something we're not feeding it,
or feeding it in a state that only appears with the engine actually
running (e.g. a DME "engine actually running" flag vs. just "cranking
allowed", or a voltage/RPM value only valid once running).
2. *Relay latency/jitter*: this is a software (PC + 2x USB-serial) gateway,
which adds tens of ms of jitter per hop on top of whatever the real bus
had. 10ms-cycle messages like `0x0C4` (steering angle) arriving late
often enough could plausibly trip an EPS freshness/plausibility check
intermittently. **This is a real architectural limit of a PC-mediated
gateway** - if it turns out to be the cause, the fix is a dedicated
hardware relay/generator (no PC round-trip) once we know the required
message set, not more filtering.
Both are testable via replay (see below): replaying the known-working
`car->eps` sequence with **no live car** removes any car-side variability
and isolates the test to the gateway/EPS interaction. If the same
intermittent fault reproduces on replay, that points at (2) rather than (1).
**Digital IO was also recorded, indirectly**: the CAN-IO board reports its
own IN1-4/OUT1-2 state in its `0x100` status frame (on every change, plus a
1s heartbeat), which got logged like any other frame. Extracted with
`tools/extract_dio_timeline.py` - in this session, `IN1`/`OUT1` (the car's
12V signal and its repeat to the EPS) was high `3.539s -> 4.083s`, then
`21.794s -> 67.123s`, then off. Sparse (only 12 status samples in 84s) but
enough to know the on/off windows. `gateway/replay_to_bus.py --dio-log`
now replays this alongside the CAN traffic - **replaying CAN frames alone
would leave the EPS never seeing the 12V signal at all**, since a bench
replay has no real car to drive that input.
**Ruleset**: first best-guess filter saved to `gateway/rules.suggested.json`
(app's combined format) and `gateway/rules.suggested_car_to_eps.json` (plain
format for `replay_to_bus.py`/`FilterRules`). Blocks only what's almost
certainly irrelevant to EPS assist function - see table below. Verified by
dry-run replay: skips exactly the expected 3084 of 60643 frames.
| Blocked ID | Name | Why |
|---|---|---|
| 0x380 | VIN tail | Not needed for operation, and shouldn't be replayed/shared anyway |
| 0x1D6 | MFL steering wheel buttons | Unrelated function (audio/cruise control) |
| 0x1D0 | DME temps + fuel | Unrelated to steering assist |
| 0x1B4 | Instrument cluster (warning lamps) | Cluster's own broadcast; EPS is a lamp *source*, not consumer, of this one |
| 0x480, 0x492, 0x497, 0x4A9 | Other modules' generic status heartbeats | Not `0x4B0` - that one is the EPS's own, kept out of this list on purpose |
| 0x580, 0x592, 0x5A9, 0x5C0 | Diagnostic / ISO-TP session frames | Not part of normal operation |
Left allowed (either likely needed, or not confident enough to block yet):
steering angle/rate (`0x0C4`/`0x0C8`), road speed (`0x1A0`), wheel speeds
(`0x0CE`), DME engine speed/voltage/torque (`0xAA`/`0xA9`/`0xA8`), CAS
terminal (`0x130`), DSC status/counter/accumulator (`0x19E`/`0x0B6`/`0x1A6`),
`0x1B6` (flagged in the protocol notes as a possible steering-torque
signal - do not block without testing specifically), and everything still
unidentified.
**Next steps**:
1. Replay `captures/replay_car_to_eps_20260829.csv` (the exact car->eps
traffic from this working session) onto the EPS bus alone, car
disconnected, with `gateway/rules.suggested_car_to_eps.json` applied and
`--dio-log captures/dio_20260829.csv` so the 12V signal repeat is
reproduced too. Confirm the EPS still comes up the same way from replay
as it did live.
2. If the intermittent fault reproduces identically on replay, that's
evidence for the jitter hypothesis over a missing-message hypothesis.
3. Iterate the ruleset: block one more plausible-non-essential group at a
time (start with `0x1B4`'s replacement candidates, `0x0B6`, `0x1A6`),
re-replay, watch for the fault changing character.
4. Once a minimal set is confirmed stable, start the synthesis plan (see
README.md) - a generator that produces just those IDs from scratch.
---
## 2026-08-29 — first gateway sessions, discovered a firmware bug (not a bus-sharing issue)
**Test conditions**: car + EPS both wired through the gateway (CANdapter on
car PT-CAN, CAN-IO board on the EPS's own bus). Two sessions:
- `gateway_20260829_162119.csv` (~21s) - no `eps->car` traffic at all.
- `gateway_20260829_162235.csv` (~227s) - ignition on, engine off, then car
fully off. EPS "started working" with ignition on (but showed its usual
dashboard error - apparently normal without the engine running) and did
not start with the car fully off.
**What the data showed**: in the second session, 69 unique arbitration IDs
appeared in the `eps->car` direction; 64 of them were IDs the gateway had
*just* relayed `car->eps` moments earlier (recognisable car ECU messages:
`0xA8/0xA9/0xAA` DME, `0xC4/0xC8` SZL steering, `0xCE/0x19E/0x1A0` DSC,
`0x130` CAS). Only `0x100` (the board's own status frame) was genuinely
native to the EPS bus.
**Initial hypothesis (wrong)**: that the car and EPS buses were still
electrically the same bus. Ruled out - confirmed the EPS bus is physically
isolated, and the car/EPS ports used in the test were correct and distinct
(`/dev/cu.usbserial-DNBJV4F5` vs `/dev/cu.usbmodem101`).
**Actual cause (fixed)**: a firmware bug. The CAN-IO board mirrors its own
CAN transmissions back over the USB bridge so the PC can see frames the
board originates locally (status/heartbeat), since TWAI has no RX loopback.
That mirroring was too broad - it also fired for frames the *gateway* asked
the board to relay from the car bus, so every relayed ID echoed straight
back over USB looking exactly like new incoming EPS-bus traffic, which the
Python gateway then dutifully relayed back to the car bus (a real feedback
loop, not just a logging artifact). Fixed by tagging TX-queue entries with
whether they originated on the board itself (mirror) or came from the
bridge (don't mirror) - see `can-io/firmware/src/can_bus.{h,cpp}` and
`usb_bridge.cpp`, `can_send(msg, mirror_on_success)`.
**Conclusion**: both sessions captured before this fix are unreliable for
answering "what does the EPS bus actually carry" - re-test needed.
**Next test to run**: repeat the ignition-on / engine-off / car-off sequence
now that the firmware is fixed, and check whether `eps->car` traffic now
shows only genuinely EPS-native messages (plausibly very little beyond
`0x100`, until the EPS module itself responds to something).
**Open questions**:
- What does the EPS module transmit on its own bus, if anything, once it's
awake? (Could not tell from data so far - contaminated by the bug above.)
- Is a wake/terminal signal (e.g. the 12V line into the CAN-IO board's
input) required in addition to CAN traffic, or does CAN alone bring the
EPS up? Not yet tested independently.
- No messages have been blocked yet in `gateway_app.py` - full transparent
relay only so far.

278
files/PTCAN_protocol.md Normal file
View file

@ -0,0 +1,278 @@
# BMW E8x PT-CAN — decoder notes
Derived from `capture_Start-Stop.csv` (60.0 s, 26 782 frames, 72 arbitration IDs, all 11-bit).
Bus is **PT-CAN, 500 kbit/s** — the ID set (DME at 0x0A8–0x0AA, SZL at 0x0C4, DSC at 0x0CE/0x1A0,
CAS at 0x130) is the standard E8x/E9x powertrain bus that the EPS unit hangs off.
Confidence is marked per row: **[A]** proven inside this log, **[B]** strongly indicated,
**[C]** informed guess, worth checking.
---
## 1. What the log contains
| t (s) | Event | Evidence |
|---|---|---|
| 0 → 14.9 | Engine idling ~705 rpm | 0x0AA rpm field |
| 13.4 | Ignition drops to KL15 (engine commanded off) | 0x130 b0: `0x45` → `0x41` |
| 14.9 | Engine stops | 0x0AA rpm → 0 |
| 17.7 → 19.0 | Key to accessory, then off | 0x130 b0: `0x40` → `0x00` |
| **22.5 → 47.5** | **Bus asleep — zero frames on the wire** | frame histogram |
| 47.5 | Bus wakes | traffic resumes |
| 51.2 | Key in / terminal R | 0x130 b0: `0x80` → `0x40` → `0x41` |
| 55.4 → 56.3 | **Cranking** | 0x130 b0 = `0x55`, voltage sags |
| 56.3 → 60 | Running, idle settles ~705 rpm | 0x0AA |
So this is a full stop → sleep → wake → start cycle, not the auto start-stop (MSA) function.
---
One row in the capture is corrupt: line 9261 carries `timestamp_ms = 4` for ID 0x0C4, tens of
seconds out of place. One bad row in 26 782 is nothing, but sort by timestamp before computing
rates or your periods come out negative.
## 2. Two structural rules that apply to most messages
**Alive counter.** A 4-bit counter that runs `0 → 14` and **skips 15 (0xF)**. Position varies:
| ID | counter |
|---|---|
| 0x0A8, 0x0A9, 0x0AA | byte 1, low nibble |
| 0x0B6 | byte 1, full byte |
| 0x130 | byte 4, low nibble |
| 0x1A0 | byte 6, high nibble |
| 0x1B4 | byte 3, low nibble (byte 3 = `0xF0 \| cnt`) |
| 0x194, 0x1E1, 0x2F1 | byte 1, low nibble |
| 0x1FB, 0x2F3 | byte 0, low nibble |
| 0x308 | byte 0, high nibble |
**Checksum.** One's-complement sum (add bytes, fold the carry back in) of every *other* byte,
plus a constant that is unique per ID. Verified 100 % on:
```
0x1B4 b7 = ocsum(b0..b6) + 0xB6
0x0B6 b0 = ocsum(b1..b4) + 0xB7
0x194 b0 = ocsum(b1..b3) + 0x00
0x1E1 b0 = ocsum(b1..b5) + 0xE3
0x200 b7 = ocsum(b0..b6) + 0xBF
```
```python
def ocsum(bs):
c = 0
for b in bs:
c += b
if c > 0xFF:
c = (c & 0xFF) + 1
return c & 0xFF
```
For 0x0A8/0x0A9/0x0AA/0x1A0/0x19E the same formula matches 60–75 % of frames, so the algorithm is
right but something else is folded in (probably a nibble that only moves when the payload does).
0x130, 0x1D0 and 0x1A6 don't fit it at all — different scheme.
**A CRC-8 search over all 256 polynomials and all seeds found nothing**, so it isn't a CRC.
---
## 3. Decoded messages
### 0x0C4 — steering angle, SZL → DSC/EPS · 7 bytes · 10 ms **[A]**
The one to build the visualiser around, and the message the EPS actually cares about.
| bits | signal | format |
|---|---|---|
| b0–b1 | **steering wheel angle** | int16 LE, 0.04395 °/bit |
| b2 | `0xFC` constant | |
| b3–b4 | **steering wheel angular rate** | int16 LE, same unit per second |
| b5 | `0xFF` constant | |
| b6 | `0xF1` constant | |
Proof: differentiating the angle channel reproduces the rate channel with **ratio 1.010,
correlation 0.996** — so they share a scale and the pairing is certain. Range in this log is
−3813 … +3891 → about ±170°.
The 0.04395 °/bit factor (= 360/8192) is the usual BMW steering constant but it is **not proven by
this log** — the *shape* is proven, the *unit* isn't. To pin it: park, log 0x0C4, turn the wheel
exactly one full turn, and check the raw delta. 8192 → 0.04395 is right. 3600 → use 0.1.
### 0x0C8 — steering angle, slow copy · 6 bytes · 200 ms **[A]**
Same b0–b1 angle and b3–b4 rate as 0x0C4. Correlation against 0x0C4 is **1.0000**, mean absolute
difference 1.9 raw counts. b5 high nibble is the counter. Useful as a sanity check on your decoder.
### 0x0AA — DME engine speed · 8 bytes · 10 ms **[A/B]**
| bits | signal |
|---|---|
| b0 | checksum |
| b1 low nibble | alive counter |
| b4–b5 | **engine speed**, uint16 LE |
| b6 | flags: `0xF4` engine off, `0x84` cranking, `0x80` running |
| b7 | tracks load — 253 right after start, decaying to ~172 at idle, 0 when off **[C]** |
Scaling: the raw value is **always a multiple of 4**, so the real field is 14 bits (bits 2–15) with
a 0.625 rpm LSB — i.e. **rpm = raw / 6.4**. That gives idle 691–746 rpm, cranking 200–229 rpm,
peak 1181 rpm, all textbook. Some BMW references use `raw / 4` instead; that would put idle at
1125 rpm and cranking at 350 rpm, which fits the numbers much less well. Check against your
tachometer once and you'll know.
### 0x0A9 — DME, includes system voltage · 8 bytes · 10 ms **[B]**
12-bit field assembled as `(b3 >> 4) | (b4 << 4)`. It behaves exactly like electrical system
voltage and nothing else:
```
215 everything off, bus just woken
207 held flat through the whole cranking window ← starter sag
215 → 227 → 237 → 251 → 255 engine catches, alternator picks up
254–255 steady while running
```
Range across the log is 206–290. The unit needs a multimeter to pin down; `raw × 0.0555` puts
running at 14.1 V and rest at 11.9 V, which is close but the rest value reads low. b5 moves fast
and independently — a separate signal.
### 0x0A8 — DME torque · 8 bytes · 10 ms **[C]**
b2 read as int8 gives −5…+14, sitting at 0 while running and −4/−5 while stopped, which looks like
a small signed torque or torque-loss figure. b3 high nibble is a second small field. b5–b6 pinned
at `0x0C 0xCF` / `0x0D 0xCF` looks like a calibration constant. b7 ∈ {0x00, 0x02, 0x20, 0x22} —
two flag bits that track engine state.
### 0x130 — CAS terminal status · 5 bytes · 100 ms **[A]**
byte 0 is the whole story, and every value below actually occurs in this log:
| b0 | meaning |
|---|---|
| `0x00` | everything off |
| `0x40` | terminal R (accessory) |
| `0x41` | terminal 15 (ignition on, engine off) |
| `0x45` | engine running |
| `0x55` | **cranking** (starter engaged) |
| `0x80` | wake-up / key detected |
b1 follows: `0x41` awake, `0x01` off, `0x0D` transitional. b4 low nibble is the counter, high
nibble moves with it but isn't a plain sum.
### 0x1D0 — DME temperatures + fuel · 8 bytes · 100 ms **[A/B]**
| byte | reading |
|---|---|
| b0 | `0x45`/`0x46` → 21/22 °C with the standard −48 offset **[B]** |
| b1 | `0x46`/`0x47` → 22/23 °C **[B]** |
| b4–b5 | **fuel consumption accumulator**, uint16 LE **[A]** |
| b6 | `0x0D` constant |
| b7 | checksum |
The b4–b5 accumulator is the cleanest result in the log: it climbs monotonically while the engine
runs, **freezes the instant rpm hits zero**, and **resets to 0** after the DME power-cycles. Slope
at idle is **987 counts/s**. If that's 1 µL per count it works out to 3.55 L/h, which is too much
for a warm idle, so a count is probably a fraction of a µL — calibrate it against a tank fill.
Since the engine is warm here (idle ~705 rpm), b0/b1 at 21/22 °C are more likely intake air and
ambient than coolant. Log a cold start and watch: coolant will climb to ~136 raw (88 °C).
### 0x1A0 — DSC road speed · 8 bytes · 20 ms **[B]**
`speed = (b0 | ((b1 & 0x0F) << 8)) × 0.1 km/h`. Reads 0 for the whole log (stationary), with
b1 bit 7 set as a validity/standstill flag. b6 high nibble = counter, b7 = checksum.
### 0x1B4 — instrument cluster · 8 bytes · 100 ms **[A/B]**
b0 + low nibble of b1 is a second speed field (0 here, b1 = `0xC0` flags). b3 = `0xF0 | counter`,
b7 = the fully-solved checksum. **b4/b5 are warning-lamp bits** and they step exactly with the
start sequence:
```
04 30 running 12 30 cranking begins
00 30 engine off 56 75 lamps lit during/after crank
02 30 ignition on 56 76 → 46 76 → 04 30 lamps clearing
```
### 0x0CE — DSC wheel speeds · 8 bytes · 20 ms **[B]**
Four 16-bit fields, all zero for the entire log. Consistent with a stationary car; you'll need a
rolling capture to get the scale.
### 0x19E — DSC status · 8 bytes · 20 ms **[B]**
b1 = `0xE0` engine running / `0xEA` engine off; b5 = `0x00` running, `0x20` ignition-on,
`0x63` off. b2 high nibble is a **slow** counter — it steps once every ~11 frames (≈4.5 Hz), not
once per frame, so don't treat it as the usual alive counter. b7 moves by +0x10 whenever b2 does,
which is what a sum-based checksum would do.
### 0x380 — VIN · 7 bytes · ~2 s **[A]**
Seven printable ASCII bytes: the **last 7 characters of the VIN**. Your log contains `VK89782`.
Worth knowing before you post captures publicly.
### 0x1A6 — accumulator · 8 bytes · 100 ms **[C]**
`(b6 >> 4) | ((b7 & 0x0F) << 4)` is a free-running 8-bit counter that advances **+25 per frame
≈ 256 Hz**, and b0 creeps 0x0C → 0x10 over the minute, resetting after the sleep. It keeps running
with the car stationary, so it's a time base rather than distance — though 0x1A6 is normally
described as the distance-pulse message, so a rolling log would settle it.
### 0x1D6 — MFL steering wheel buttons · 2 bytes **[B]**
`FFFF` for the whole log = nothing pressed. Press each button and the bits will fall out in
one pass.
### 0x592 / 0x5A9 / 0x5C0 / 0x580 — diagnostics **[B]**
ISO-TP framing with a BMW address byte in front:
```
0x5C0: 82 10 07 21 6D 34 2B CB addr 0x82, first frame, len 7, KWP service 0x21
82 21 E2 CF 00 00 00 00 addr 0x82, consecutive frame #1
0x592: 80 10 11 10 ED 12 AC 5F addr 0x80, first frame, len 17, service 0x10
80 21 ... / 80 22 ... consecutive frames #1, #2
```
Byte 0 = address, byte 1 high nibble = ISO-TP PDU type (1 = first, 2 = consecutive), low nibble =
length or sequence. Only appear around key events — some module doing a handshake.
### 0x480 / 0x492 / 0x497 / 0x4A9 / 0x4B0 — module status **[C]**
All 8 bytes, ~470 ms, all shaped `XX 42 .. .. FF FF FF FF` with byte 0 unique per ID
(0x12 / 0x17 / 0x29 / 0x30 / 0x00). Looks like a per-module alive/status broadcast.
---
## 4. Counter and checksum only
These carry no payload that moves in this log — they're heartbeats, or their real content only
appears when the car is driving:
`0x194` (4B), `0x1E1` (6B), `0x1FB` (2B), `0x2F3` (3B), `0x2F1` (3B), `0x0B6` (5B, payload pinned
at `80 00 08`), `0x200` (8B, fully constant), `0x2B2` (8B — b4 toggles 0x00/0x10/0x20 fast and
irregularly, looks like a validity strobe).
## 5. Not yet identified
`0x135 0x195 0x1B5 0x1B6 0x202 0x21A 0x23A 0x242 0x252 0x2A6 0x2C0 0x2CF 0x2D2 0x2F6 0x2F8 0x2FA`
`0x2FC 0x308 0x310 0x31D 0x330 0x332 0x335 0x337 0x34F 0x374 0x381 0x383 0x388 0x395 0x3AC 0x3B0`
`0x3B3 0x3B4 0x3B9 0x3BE 0x5D6 0x5E0 0x5E3 0x5F2 0x5F8`
Two worth chasing:
- **0x1B6** (7 B, 50 ms) — b4 wanders 0…34 continuously with the car parked, b5 = `0xA8`/`0xAC`.
A live analogue channel on a stationary car; brake pressure or a steering torque signal are
both plausible. If it's steering torque it's the single most interesting message for EPS work.
- **0x335** (8 B, ~1 s) — several slowly drifting bytes. `0x8B` and `0xA8` with the −48 offset
would be 91 °C and 120 °C, which would suit coolant and oil on a warm engine.
## 6. Fastest ways to fill the gaps
Each of these isolates one variable, which is what makes a diff-based approach work:
1. **Parked, engine off, wheel only.** Confirms the 0x0C4 scale and separates steering from
everything else. Turn exactly one full turn each way.
2. **Parked, press every MFL button in sequence.** Cracks 0x1D6 in one log.
3. **Rolling in a straight line.** Unlocks 0x0CE wheel speeds, 0x1A0 speed, 0x1A6, and tells you
whether 0x1B6 is brake-related.
4. **Cold start to full warm-up.** Settles every temperature byte at once — the coolant byte is
the one that ends near 136 (88 °C).
5. **Idle, blip the throttle.** Separates rpm from load and torque across 0x0A8/0x0A9/0x0AA.

146
files/bmw_e8x_ptcan.dbc Normal file
View file

@ -0,0 +1,146 @@
VERSION "BMW E8x PT-CAN - reverse engineered from capture_Start-Stop.csv"
NS_ :
NS_DESC_
CM_
BA_DEF_
BA_
VAL_
CAT_DEF_
CAT_
FILTER
BA_DEF_DEF_
EV_DATA_
ENVVAR_DATA_
SGTYPE_
SGTYPE_VAL_
BA_DEF_SGTYPE_
BA_SGTYPE_
SIG_TYPE_REF_
VAL_TABLE_
SIG_GROUP_
SIG_VALTYPE_
SIGTYPE_VALTYPE_
BO_TX_BU_
BA_DEF_REL_
BA_REL_
BA_DEF_DEF_REL_
BU_SG_REL_
BU_EV_REL_
BU_BO_REL_
BS_:
BU_: DME DSC SZL CAS KOMBI EPS UNKNOWN
BO_ 168 DME_Torque: 8 DME
SG_ Checksum : 0|8@1+ (1,0) [0|255] "" EPS
SG_ AliveCounter : 8|4@1+ (1,0) [0|14] "" EPS
SG_ TorqueSigned : 16|8@1- (1,0) [-128|127] "" EPS
SG_ Field_b3hi : 28|4@1+ (1,0) [0|15] "" EPS
SG_ CalConstant : 40|16@1+ (1,0) [0|65535] "" EPS
SG_ StatusFlags : 56|8@1+ (1,0) [0|255] "" EPS
BO_ 169 DME_Voltage: 8 DME
SG_ Checksum : 0|8@1+ (1,0) [0|255] "" EPS
SG_ AliveCounter : 8|4@1+ (1,0) [0|14] "" EPS
SG_ SystemVoltage : 28|12@1+ (0.0555,0) [0|227] "V" EPS
SG_ Unknown_b5 : 40|8@1+ (1,0) [0|255] "" EPS
SG_ Unknown_b6 : 48|8@1+ (1,0) [0|255] "" EPS
BO_ 170 DME_EngineSpeed: 8 DME
SG_ Checksum : 0|8@1+ (1,0) [0|255] "" EPS
SG_ AliveCounter : 8|4@1+ (1,0) [0|14] "" EPS
SG_ EngineSpeed : 32|16@1+ (0.15625,0) [0|10000] "rpm" EPS
SG_ EngineStateFlags : 48|8@1+ (1,0) [0|255] "" EPS
SG_ LoadEstimate : 56|8@1+ (1,0) [0|255] "" EPS
BO_ 182 DSC_B6: 5 DSC
SG_ Checksum : 0|8@1+ (1,0) [0|255] "" EPS
SG_ AliveCounter : 8|8@1+ (1,0) [0|14] "" EPS
SG_ Payload : 16|24@1+ (1,0) [0|16777215] "" EPS
BO_ 196 SZL_SteeringAngle: 7 SZL
SG_ SteeringAngle : 0|16@1- (0.04395,0) [-1440|1440] "deg" EPS
SG_ SteeringRate : 24|16@1- (0.04395,0) [-2000|2000] "deg/s" EPS
BO_ 200 SZL_SteeringAngleSlow: 6 SZL
SG_ SteeringAngle : 0|16@1- (0.04395,0) [-1440|1440] "deg" EPS
SG_ SteeringRate : 24|16@1- (0.04395,0) [-2000|2000] "deg/s" EPS
SG_ AliveCounter : 44|4@1+ (1,0) [0|14] "" EPS
BO_ 206 DSC_WheelSpeeds: 8 DSC
SG_ WheelSpeed_FL : 0|16@1+ (1,0) [0|65535] "" EPS
SG_ WheelSpeed_FR : 16|16@1+ (1,0) [0|65535] "" EPS
SG_ WheelSpeed_RL : 32|16@1+ (1,0) [0|65535] "" EPS
SG_ WheelSpeed_RR : 48|16@1+ (1,0) [0|65535] "" EPS
BO_ 304 CAS_TerminalStatus: 5 CAS
SG_ TerminalStatus : 0|8@1+ (1,0) [0|255] "" EPS
SG_ TerminalStatus2 : 8|8@1+ (1,0) [0|255] "" EPS
SG_ Unknown_b2 : 16|8@1+ (1,0) [0|255] "" EPS
SG_ Unknown_b3 : 24|8@1+ (1,0) [0|255] "" EPS
SG_ AliveCounter : 32|4@1+ (1,0) [0|14] "" EPS
SG_ CheckNibble : 36|4@1+ (1,0) [0|15] "" EPS
BO_ 414 DSC_Status: 8 DSC
SG_ EngineRunFlag : 8|8@1+ (1,0) [0|255] "" EPS
SG_ SlowCounter : 20|4@1+ (1,0) [0|15] "" EPS
SG_ StateFlags : 40|8@1+ (1,0) [0|255] "" EPS
SG_ Checksum : 56|8@1+ (1,0) [0|255] "" EPS
BO_ 416 DSC_RoadSpeed: 8 DSC
SG_ VehicleSpeed : 0|12@1+ (0.1,0) [0|400] "km/h" EPS
SG_ SpeedFlags : 12|4@1+ (1,0) [0|15] "" EPS
SG_ AliveCounter : 28|4@1+ (1,0) [0|14] "" EPS
SG_ Checksum : 56|8@1+ (1,0) [0|255] "" EPS
BO_ 422 DSC_Accumulator: 8 DSC
SG_ SlowCounter : 0|8@1+ (1,0) [0|255] "" EPS
SG_ FastCounter : 52|8@1+ (1,0) [0|255] "" EPS
BO_ 436 KOMBI_Status: 8 KOMBI
SG_ VehicleSpeed : 0|12@1+ (0.1,0) [0|400] "km/h" EPS
SG_ SpeedFlags : 12|4@1+ (1,0) [0|15] "" EPS
SG_ AliveCounter : 24|4@1+ (1,0) [0|14] "" EPS
SG_ WarningLamps : 32|16@1+ (1,0) [0|65535] "" EPS
SG_ Checksum : 56|8@1+ (1,0) [0|255] "" EPS
BO_ 464 DME_TempsAndFuel: 8 DME
SG_ Temp1 : 0|8@1+ (1,-48) [-48|207] "degC" EPS
SG_ Temp2 : 8|8@1+ (1,-48) [-48|207] "degC" EPS
SG_ Unknown_b2 : 16|8@1+ (1,0) [0|255] "" EPS
SG_ FuelAccumulator : 32|16@1+ (1,0) [0|65535] "" EPS
SG_ Checksum : 56|8@1+ (1,0) [0|255] "" EPS
BO_ 470 SZL_MFLButtons: 2 SZL
SG_ ButtonBits : 0|16@1+ (1,0) [0|65535] "" EPS
BO_ 896 VIN_LastSeven: 7 CAS
SG_ VinChar1 : 0|8@1+ (1,0) [0|255] "" EPS
SG_ VinChar2 : 8|8@1+ (1,0) [0|255] "" EPS
SG_ VinChar3 : 16|8@1+ (1,0) [0|255] "" EPS
SG_ VinChar4 : 24|8@1+ (1,0) [0|255] "" EPS
SG_ VinChar5 : 32|8@1+ (1,0) [0|255] "" EPS
SG_ VinChar6 : 40|8@1+ (1,0) [0|255] "" EPS
SG_ VinChar7 : 48|8@1+ (1,0) [0|255] "" EPS
CM_ BO_ 170 "Engine speed. Raw is always a multiple of 4, so the real field is 14 bits with a 0.625 rpm LSB. Factor 0.15625 = 1/6.4 gives idle 705 rpm and cranking 220 rpm.";
CM_ SG_ 170 EngineSpeed "rpm = raw / 6.4. Some references use raw / 4 instead - verify against the tachometer.";
CM_ SG_ 170 LoadEstimate "Unconfirmed. 253 right after start, decays to ~172 at idle, 0 with engine off.";
CM_ BO_ 196 "Steering angle from the SZL, 10 ms. Differentiating SteeringAngle reproduces SteeringRate with ratio 1.010 and correlation 0.996.";
CM_ SG_ 196 SteeringAngle "Scale 0.04395 deg/bit is the BMW convention but is NOT proven by the source log. Calibrate with one full turn of the wheel.";
CM_ BO_ 200 "Same angle and rate as 0x0C4 at 5 Hz. Correlation against 0x0C4 is 1.0000.";
CM_ SG_ 169 SystemVoltage "Shape is certain (sags flat through cranking, jumps when the alternator picks up). Scale needs a multimeter; 0.0555 puts running at 14.1 V.";
CM_ SG_ 304 TerminalStatus "0x00 off, 0x40 terminal R, 0x41 terminal 15, 0x45 running, 0x55 cranking, 0x80 wake-up.";
CM_ SG_ 464 FuelAccumulator "Monotonic while running, freezes when rpm hits 0, resets when the DME power-cycles. 987 counts/s at idle. Unit not calibrated.";
CM_ SG_ 414 SlowCounter "Increments about every 11 frames (~4.5 Hz), not once per frame - it is not the usual alive counter.";
CM_ SG_ 464 Temp1 "Reads 21 degC here on a warm engine, so more likely intake air than coolant. Log a cold start to confirm the -48 offset.";
CM_ SG_ 436 WarningLamps "Steps with the start sequence: 0x3004 running, 0x3000 off, 0x3002 ignition, 0x3012 cranking, 0x7556 lamps lit.";
CM_ BO_ 896 "Last seven characters of the VIN as ASCII.";
VAL_ 304 TerminalStatus 0 "Off" 64 "TerminalR" 65 "Terminal15" 69 "EngineRunning" 85 "Cranking" 128 "WakeUp" ;

191
files/decode_ptcan.py Normal file
View file

@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
Decode a BMW E8x PT-CAN capture (timestamp_ms, arbitration_id, is_extended, dlc, data_hex).
python3 decode_ptcan.py capture_Start-Stop.csv # timeline + summary
python3 decode_ptcan.py capture_Start-Stop.csv -o signals.csv # tidy signal table
python3 decode_ptcan.py capture_Start-Stop.csv --check # verify checksums
No dependencies beyond the standard library. The signal definitions match
bmw_e8x_ptcan.dbc and PTCAN_protocol.md; edit SIGNALS to add your own.
"""
import argparse, csv, sys
from collections import defaultdict
u16le = lambda b, i: b[i] | (b[i + 1] << 8)
def i16le(b, i):
v = u16le(b, i)
return v - 65536 if v > 32767 else v
TERMINAL = {0x00: "off", 0x40: "terminal_R", 0x41: "terminal_15",
0x45: "engine_running", 0x55: "cranking", 0x80: "wake_up"}
# id -> {signal name: (callable, unit)}. Confidence noted in PTCAN_protocol.md.
SIGNALS = {
0x0AA: {"engine_speed": (lambda b: u16le(b, 4) / 6.4, "rpm"),
"engine_flags": (lambda b: b[6], ""),
"load_estimate": (lambda b: b[7], "")},
0x0C4: {"steering_angle": (lambda b: i16le(b, 0) * 0.04395, "deg"),
"steering_rate": (lambda b: i16le(b, 3) * 0.04395, "deg/s")},
0x0C8: {"steering_angle_slow": (lambda b: i16le(b, 0) * 0.04395, "deg")},
0x0A9: {"system_voltage": (lambda b: ((b[3] >> 4) | (b[4] << 4)) * 0.0555, "V")},
0x130: {"terminal": (lambda b: TERMINAL.get(b[0], hex(b[0])), "")},
0x1D0: {"temp_1": (lambda b: b[0] - 48, "degC"),
"temp_2": (lambda b: b[1] - 48, "degC"),
"fuel_accum": (lambda b: u16le(b, 4), "count")},
0x1A0: {"road_speed": (lambda b: (b[0] | ((b[1] & 0x0F) << 8)) * 0.1, "km/h")},
0x1B4: {"warning_lamps": (lambda b: (b[5] << 8) | b[4], ""),
"cluster_speed": (lambda b: (b[0] | ((b[1] & 0x0F) << 8)) * 0.1, "km/h")},
0x0CE: {"wheel_fl": (lambda b: u16le(b, 0), ""), "wheel_fr": (lambda b: u16le(b, 2), ""),
"wheel_rl": (lambda b: u16le(b, 4), ""), "wheel_rr": (lambda b: u16le(b, 6), "")},
0x1D6: {"mfl_buttons": (lambda b: u16le(b, 0), "")},
0x380: {"vin_tail": (lambda b: bytes(b).decode("ascii", "replace"), "")},
0x1A6: {"accum_fast": (lambda b: (b[6] >> 4) | ((b[7] & 0x0F) << 4), "count"),
"accum_slow": (lambda b: b[0], "count")},
}
NAMES = {0x0A8: "DME torque", 0x0A9: "DME voltage", 0x0AA: "DME engine speed",
0x0B6: "DSC counter", 0x0C4: "steering angle", 0x0C8: "steering angle 5Hz",
0x0CE: "DSC wheel speeds", 0x130: "CAS terminal", 0x19E: "DSC status",
0x1A0: "DSC road speed", 0x1A6: "DSC accumulator", 0x1B4: "instrument cluster",
0x1D0: "DME temps + fuel", 0x1D6: "MFL buttons", 0x380: "VIN tail"}
# checksum byte index -> constant, for the IDs where one's-complement sum verifies 100%
CHECKSUMS = {0x1B4: (7, 0xB6), 0x0B6: (0, 0xB7), 0x194: (0, 0x00),
0x1E1: (0, 0xE3), 0x200: (7, 0xBF)}
# 4-bit alive counter position: (byte, shift)
COUNTERS = {0x0A8: (1, 0), 0x0A9: (1, 0), 0x0AA: (1, 0), 0x0B6: (1, 0), 0x130: (4, 0),
0x1A0: (6, 4), 0x1B4: (3, 0), 0x194: (1, 0), 0x1E1: (1, 0),
0x1FB: (0, 0), 0x2F3: (0, 0), 0x2F1: (1, 0), 0x308: (0, 4)}
def ocsum(bs):
"""One's-complement sum: add, folding each carry back into the low byte."""
c = 0
for b in bs:
c += b
if c > 0xFF:
c = (c & 0xFF) + 1
return c & 0xFF
def read(path):
with open(path, newline="") as f:
for row in csv.DictReader(f):
hx = row["data_hex"].strip()
yield (int(row["timestamp_ms"]), int(row["arbitration_id"], 16),
bytes.fromhex(hx))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("csv")
ap.add_argument("-o", "--out", help="write a tidy time,id,signal,value,unit table")
ap.add_argument("--check", action="store_true", help="verify checksums and counters")
a = ap.parse_args()
frames = list(read(a.csv))
if not frames:
sys.exit("no frames found — check the CSV columns")
# the source capture has one row with a corrupt timestamp; sorting keeps the
# rate maths and the timeline honest
frames.sort(key=lambda f: f[0])
t0 = frames[0][0]
counts, last_t, gaps, dsum = defaultdict(int), {}, defaultdict(int), defaultdict(float)
events, rows = [], []
prev_term = prev_run = None
bad_cs = defaultdict(int)
bad_cnt = defaultdict(int)
prev_cnt = {}
for ms, aid, b in frames:
t = (ms - t0) / 1000
counts[aid] += 1
if aid in last_t:
dt = t - last_t[aid]
if 0 < dt < 1:
dsum[aid] += dt
gaps[aid] += 1
last_t[aid] = t
if a.check:
if aid in CHECKSUMS:
idx, k = CHECKSUMS[aid]
if len(b) > idx:
rest = [b[i] for i in range(len(b)) if i != idx]
if (ocsum(rest) + k) & 0xFF != b[idx]:
bad_cs[aid] += 1
if aid in COUNTERS:
by, sh = COUNTERS[aid]
if len(b) > by:
c = (b[by] >> sh) & 0x0F
p = prev_cnt.get(aid)
if p is not None and c != (p + 1) % 15:
bad_cnt[aid] += 1
prev_cnt[aid] = c
if aid == 0x130 and b[0] != prev_term:
events.append((t, "terminal", TERMINAL.get(b[0], hex(b[0]))))
prev_term = b[0]
if aid == 0x0AA:
run = u16le(b, 4) > 0
if run != prev_run:
events.append((t, "engine", "running" if run else "stopped"))
prev_run = run
if aid in SIGNALS:
for name, (fn, unit) in SIGNALS[aid].items():
try:
rows.append((round(t, 3), f"0x{aid:03X}", name, fn(b), unit))
except Exception:
pass
dur = (frames[-1][0] - t0) / 1000
print(f"{len(frames)} frames · {dur:.2f} s · {len(counts)} arbitration IDs\n")
# bus-silent windows
silent, pt = [], (frames[0][0] - t0) / 1000
for ms, _, _ in frames:
t = (ms - t0) / 1000
if t - pt > 2:
silent.append((pt, t))
pt = t
for s, e in silent:
print(f" bus silent {s:6.2f} → {e:6.2f} s ({e - s:.1f} s)")
if silent:
print()
print("timeline")
for t, kind, val in events:
print(f" {t:6.2f}s {kind:9s} {val}")
print("\n%-7s %-22s %7s %6s %s" % ("ID", "message", "Hz", "frames", "last data"))
seen_last = {}
for ms, aid, b in frames:
seen_last[aid] = b
for aid in sorted(counts):
hz = gaps[aid] / dsum[aid] if dsum[aid] else counts[aid] / dur
print("0x%03X %-22s %7.1f %6d %s"
% (aid, NAMES.get(aid, ""), hz, counts[aid], seen_last[aid].hex().upper()))
if a.check:
print("\nchecksum / counter check")
for aid in sorted(set(CHECKSUMS) | set(COUNTERS)):
if aid not in counts:
continue
cs = f"{counts[aid] - bad_cs[aid]}/{counts[aid]}" if aid in CHECKSUMS else " —"
ct = f"{counts[aid] - bad_cnt[aid]}/{counts[aid]}" if aid in COUNTERS else " —"
print(f" 0x{aid:03X} checksum {cs:>12} counter {ct:>12}")
if a.out:
with open(a.out, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["t_s", "id", "signal", "value", "unit"])
w.writerows(rows)
print(f"\nwrote {len(rows)} signal samples to {a.out}")
if __name__ == "__main__":
main()

470
files/ptcan_replay.html Normal file

File diff suppressed because one or more lines are too long

0
gateway/__init__.py Normal file
View file

152
gateway/dio_monitor.py Normal file
View file

@ -0,0 +1,152 @@
"""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

331
gateway/eps_bench.py Normal file
View file

@ -0,0 +1,331 @@
"""EPS experiment bench: drive the EPS on its own, watch what it says back.
This is the bridge between replay and synthesis. Instead of replaying a
recording frame-for-frame, it transmits a chosen set of messages on a
fixed cycle - each ID at its own period, with a payload you control - so
you can answer "which messages does the EPS actually need to come up, and
what does it report while it's up?" by adding/removing entries rather than
re-recording.
Runs its own thread so timing doesn't depend on the UI's rerun cadence,
and tracks per-ID receive stats (count, rate, last payload, changed bytes)
for whatever the EPS transmits back.
"""
from __future__ import annotations
import csv
import sys
import threading
import time
from collections import deque
from dataclasses import dataclass, field
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, ocsum # noqa: E402
@dataclass
class TxEntry:
"""One message the bench transmits on a cycle.
`sequence` holds the payloads this ID actually sent in the source
capture, in order. Cycling through them reproduces the real alive
counter and checksum progression byte-for-byte - which matters because
a frozen payload reads as a stale sender, and not every checksum scheme
on this bus is solved well enough to synthesise (0x1A0's only matches
60-75% of the time, per files/PTCAN_protocol.md). Falls back to `data`
when the sequence is empty.
"""
arbitration_id: int
data: bytes
period_s: float
enabled: bool = True
sent: int = 0
sequence: list[bytes] = field(default_factory=list)
_next_due: float = 0.0
_counter: int = 0
_seq_i: int = 0
def next_payload(self, refresh_counters: bool) -> bytes:
if self.sequence:
payload = self.sequence[self._seq_i % len(self.sequence)]
self._seq_i += 1
return payload
self._counter += 1
if refresh_counters:
return apply_counter_and_checksum(self.arbitration_id, self.data, self._counter)
return self.data
def apply_counter_and_checksum(arbitration_id: int, data: bytes, counter: int) -> bytes:
"""Refresh a frame's alive counter and checksum in place.
Retransmitting a captured payload verbatim leaves its alive counter
frozen, which every consumer of these messages treats as a stale/faulty
sender - so a static transmit set gets ignored even though the exact
same bytes worked during replay. Positions and the one's-complement
checksum scheme come from files/PTCAN_protocol.md.
"""
out = bytearray(data)
pos = COUNTERS.get(arbitration_id)
if pos is not None:
byte_i, shift = pos
if byte_i < len(out):
# 4-bit counter that runs 0..14 and skips 15.
out[byte_i] = (out[byte_i] & ~(0x0F << shift) & 0xFF) | ((counter % 15) << shift)
cs = CHECKSUMS.get(arbitration_id)
if cs is not None:
idx, const = cs
if idx < len(out):
rest = [out[i] for i in range(len(out)) if i != idx]
out[idx] = (ocsum(rest) + const) & 0xFF
return bytes(out)
@dataclass
class RxInfo:
"""What we've seen back from the EPS for one arbitration ID."""
count: int = 0
first_seen: float = 0.0
last_seen: float = 0.0
last_data: bytes = b""
changed_mask: int = 0 # bits that have ever differed between frames
recent: deque = field(default_factory=lambda: deque(maxlen=32))
@property
def hz(self) -> float:
if len(self.recent) < 2:
return 0.0
span = self.recent[-1] - self.recent[0]
return (len(self.recent) - 1) / span if span > 0 else 0.0
class EpsBench:
def __init__(self, adapter, log_path: Optional[Path] = None):
self.adapter = adapter
self.tx: dict[int, TxEntry] = {}
self.rx: dict[int, RxInfo] = {}
self.refresh_counters = True # keep alive counters/checksums live
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._t0 = 0.0
self._log_file = None
self._log_writer = None
if log_path is not None:
self._log_file = open(log_path, "w", newline="")
self._log_writer = csv.writer(self._log_file)
self._log_writer.writerow(["t", "wall", "dir", "arbitration_id", "dlc", "data_hex"])
# ---- transmit set -----------------------------------------------------
def set_entries(self, entries: list[TxEntry]) -> None:
with self._lock:
now = time.monotonic()
for e in entries:
e._next_due = now
self.tx = {e.arbitration_id: e for e in entries}
def set_enabled(self, arbitration_id: int, enabled: bool) -> None:
with self._lock:
if arbitration_id in self.tx:
self.tx[arbitration_id].enabled = enabled
def set_payload(self, arbitration_id: int, data: bytes) -> None:
with self._lock:
if arbitration_id in self.tx:
self.tx[arbitration_id].data = data
def enable_all(self, enabled: bool) -> None:
with self._lock:
for e in self.tx.values():
e.enabled = enabled
# ---- lifecycle --------------------------------------------------------
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._t0 = time.monotonic()
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._log_file is not None:
self._log_file.close()
self._log_file = None
self._log_writer = None # a restart must not write to the closed file
def reset_rx(self) -> None:
with self._lock:
self.rx.clear()
def actual_rate(self) -> Optional[float]:
"""Frames/s actually achieved so far, or None before transmitting."""
if not self._t0:
return None
elapsed = time.monotonic() - self._t0
if elapsed <= 0:
return None
with self._lock:
total = sum(e.sent for e in self.tx.values())
return total / elapsed if total else None
def run_startup_sequence(self, monitor, pre_bus_s: float = 1.0,
settle_s: float = 2.0) -> None:
"""Bring the EPS up in the order a real car does it.
Order matters: the module wants a populated bus already talking when
its 12V enable arrives, rather than waking into silence and latching
a fault. So: start transmitting, let the bus look "alive" for a
moment, then apply the enable, then hold while it initialises.
Counters keep advancing throughout, which is the point.
"""
self.reset_rx()
if not self.is_running():
self.start()
time.sleep(pre_bus_s)
monitor.set_output_verified(0, True)
time.sleep(settle_s)
def snapshot(self):
with self._lock:
return dict(self.tx), dict(self.rx)
# ---- worker -----------------------------------------------------------
def _run(self) -> None:
while not self._stop.is_set():
now = time.monotonic()
with self._lock:
due = [e for e in self.tx.values() if e.enabled and e._next_due <= now]
payloads = []
for e in due:
e._next_due = now + e.period_s
e.sent += 1
payloads.append(e.next_payload(self.refresh_counters))
if due:
self.adapter.send_frames([(e.arbitration_id, d, False) for e, d in zip(due, payloads)])
if self._log_writer is not None:
wall = time.time()
for e, data in zip(due, payloads):
self._log_writer.writerow(
[round(now - self._t0, 4), round(wall, 4), "tx", f"{e.arbitration_id:X}",
len(data), data.hex().upper()]
)
# Drain whatever the EPS sent back, but never past the next due
# time - falling behind here is what makes messages arrive stale.
with self._lock:
deadline = min((e._next_due for e in self.tx.values() if e.enabled), default=now + 0.02)
while time.monotonic() < deadline:
frame = self.adapter.read_frame(timeout=0.001)
if frame is None:
break
self._record_rx(frame)
slack = deadline - time.monotonic()
if slack > 0:
time.sleep(min(slack, 0.005))
def _record_rx(self, frame) -> None:
t = time.monotonic() - self._t0
with self._lock:
info = self.rx.get(frame.arbitration_id)
if info is None:
info = RxInfo(first_seen=t)
self.rx[frame.arbitration_id] = info
if info.last_data and len(info.last_data) == len(frame.data):
for i, (a, b) in enumerate(zip(info.last_data, frame.data)):
if a != b:
info.changed_mask |= 1 << i
info.count += 1
info.last_seen = t
info.last_data = frame.data
info.recent.append(t)
if self._log_writer is not None:
self._log_writer.writerow(
[round(t, 4), round(time.time(), 4), "rx", f"{frame.arbitration_id:X}",
len(frame.data), frame.data.hex().upper()]
)
def entries_from_capture(path: Path, ids: Optional[set[int]] = None,
min_count: int = 2, max_sequence: int = 400) -> list[TxEntry]:
"""Build a transmit set from a capture: one entry per ID, using that ID's
median period and the payload sequence it actually sent.
Keeping the sequence (rather than just the last payload) is what makes a
synthetic transmit set acceptable to the EPS - see TxEntry.
"""
times: dict[int, list[float]] = {}
payloads: dict[int, list[bytes]] = {}
with open(path, newline="") as f:
rows = sorted(csv.DictReader(f), key=lambda r: int(r["timestamp_ms"]))
if not rows:
return []
t0 = int(rows[0]["timestamp_ms"])
for row in rows:
aid = int(row["arbitration_id"], 16)
if ids is not None and aid not in ids:
continue
times.setdefault(aid, []).append((int(row["timestamp_ms"]) - t0) / 1000)
seq = payloads.setdefault(aid, [])
if len(seq) < max_sequence:
seq.append(bytes.fromhex(row["data_hex"]))
entries = []
for aid, ts in times.items():
if len(ts) < min_count:
continue
gaps = sorted(b - a for a, b in zip(ts, ts[1:]) if 0 < b - a < 5)
period = gaps[len(gaps) // 2] if gaps else 1.0
seq = payloads[aid]
entries.append(TxEntry(arbitration_id=aid, data=seq[-1],
period_s=round(period, 3), sequence=seq))
entries.sort(key=lambda e: e.arbitration_id)
return entries
# Candidate subsets, smallest first. Narrowing matters for two reasons: it
# answers "what does the EPS actually need", and it cuts the frame rate -
# the full 69-ID set needs ~1330 frames/s, which the PC-side bench can't
# always sustain while the UI is also running, and late frames look exactly
# like a faulty sender to the EPS.
PRESETS: dict[str, set[int]] = {
# Steering, road/wheel speed, engine state, terminal status: the inputs a
# speed-sensitive power steering controller plausibly can't work without.
"Minimal candidate": {
0x0C4, # steering angle + rate (SZL)
0x1A0, # road speed (DSC)
0x0CE, # wheel speeds (DSC)
0x0AA, # engine speed (DME)
0x0A9, # system voltage (DME)
0x0A8, # torque (DME)
0x130, # terminal status (CAS)
0x19E, # DSC status
0x0B6, # DSC counter/heartbeat
},
# Adds the rest of the fast chassis/powertrain traffic that was present
# before the EPS woke in the reference capture.
"Core chassis + powertrain": {
0x0C4, 0x1A0, 0x0CE, 0x0AA, 0x0A9, 0x0A8, 0x130, 0x19E, 0x0B6,
0x0C8, 0x1A6, 0x2B2, 0x1B6, 0x194, 0x1E1, 0x1D0,
},
}
def required_rate(entries: list[TxEntry]) -> float:
"""Frames per second a transmit set demands, all entries enabled."""
return sum(1.0 / e.period_s for e in entries if e.period_s > 0)

283
gateway/eps_control.py Normal file
View file

@ -0,0 +1,283 @@
"""Drive the EPS with a minimal, hand-controlled message set.
Where eps_bench.py replays whole captured sets to find out what's needed,
this assumes the answer is already known - CAS terminal status (0x130) to
bring the unit up, DSC road speed (0x1A0) to set how much assist it gives -
and gives you direct control of those two signals.
Encoding strategy, and why it differs per message:
0x130 terminal: the capture contains real frames for each terminal state
(off / R / 15 / running / cranking), so a state change just switches
which captured sequence is cycling. Counters and checksums stay exactly
as the car produced them - nothing to solve.
0x1A0 road speed: the reference capture is stationary throughout, so
there is no ground truth for a moving-speed frame. What we do know is
that the best-fitting checksum over that data covers bytes 2..6 (87.5%
of frames, tools/solve_checksum.py) - which excludes the speed field in
bytes 0..1. So patching speed into a captured frame should leave its
checksum valid. That is an inference, not a verified fact: watch the
EPS response monitor when you move the slider, and treat a drop-out as
the encoding being rejected.
"""
from __future__ import annotations
import csv
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
CAN_ID_TERMINAL = 0x130
CAN_ID_ROAD_SPEED = 0x1A0
TERMINAL_STATES = {
"off": 0x00,
"terminal R": 0x40,
"ignition (KL15)": 0x41,
"engine running": 0x45,
"cranking": 0x55,
}
# Periods the car uses for these two messages (median from the captures).
PERIOD_TERMINAL = 0.1
PERIOD_ROAD_SPEED = 0.02
SPEED_SCALE = 0.1 # km/h per bit, per files/PTCAN_protocol.md
STANDSTILL_FLAG = 0x80 # b1 bit7, set while the car reports stationary
def load_state_sequences(path: Path, arbitration_id: int, state_byte: int = 0) -> dict[int, list[bytes]]:
"""Group a capture's frames for one ID by the value of one byte.
Used to pull genuine per-terminal-state 0x130 frames out of a recording,
so switching state means switching which real sequence we cycle rather
than synthesising a payload whose checksum we can't verify.
"""
groups: dict[int, list[bytes]] = {}
with open(path, newline="") as f:
for row in csv.DictReader(f):
if int(row["arbitration_id"], 16) != arbitration_id:
continue
data = bytes.fromhex(row["data_hex"])
if len(data) <= state_byte:
continue
groups.setdefault(data[state_byte], []).append(data)
return groups
def load_frames(path: Path, arbitration_id: int, limit: int = 400) -> list[bytes]:
out = []
with open(path, newline="") as f:
for row in csv.DictReader(f):
if int(row["arbitration_id"], 16) == arbitration_id:
out.append(bytes.fromhex(row["data_hex"]))
if len(out) >= limit:
break
return out
def encode_road_speed(template: bytes, kmh: float) -> bytes:
"""Patch a road-speed value into a captured 0x1A0 frame.
Only bytes 0-1 are touched; see the module docstring for why that should
leave the frame's checksum intact.
"""
raw = max(0, min(0x0FFF, int(round(kmh / SPEED_SCALE))))
out = bytearray(template)
out[0] = raw & 0xFF
flags = out[1] & 0xF0
if kmh > 0:
flags &= ~STANDSTILL_FLAG & 0xFF
else:
flags |= STANDSTILL_FLAG
out[1] = flags | ((raw >> 8) & 0x0F)
return bytes(out)
def decode_road_speed(data: bytes) -> float:
return (data[0] | ((data[1] & 0x0F) << 8)) * SPEED_SCALE
def decode_eps_message(arbitration_id: int, data: bytes) -> dict:
"""Best-effort decode of the three messages the EPS transmits.
Derived from byte-frequency analysis of bench captures (see
eps-comms/findings.md). Fields marked "?" are inferred from structure
rather than confirmed against a known-good reference, so treat them as
leads rather than facts.
"""
out: dict[str, str] = {}
if not data:
return out
if arbitration_id == 0x1FB and len(data) >= 2:
# b0 runs F0..FE: high nibble is a constant marker, low nibble the
# 0..14 alive counter (skipping 15) used everywhere on this bus.
out["alive counter"] = str(data[0] & 0x0F)
out["marker"] = f"0x{data[0] >> 4:X}"
if data[0] >> 4 != 0xF:
out["note"] = "unexpected marker - normally 0xF"
elif arbitration_id == 0x4B0 and len(data) >= 4:
out["counter?"] = str(data[1] & 0x0F)
flags = data[1] >> 4
out["flags?"] = f"0x{flags:X}"
out["checksum?"] = f"0x{data[0]:02X}"
if data[3] != 0xFF:
out["b3"] = f"0x{data[3]:02X} (normally FF)"
elif arbitration_id == 0x5B0 and len(data) >= 4:
# Two payloads seen: 01 03 80 FF... right at power-up, then
# 40 81 01 15 FF... once it settles - looks like an init/ready pair.
out["state?"] = "starting" if data[0] == 0x01 else "ready" if data[0] == 0x40 else f"0x{data[0]:02X}"
out["raw"] = data[:4].hex(" ").upper()
return out
@dataclass
class EpsRx:
count: int = 0
last_seen: float = 0.0
last_data: bytes = b""
changed_mask: int = 0
history: list = field(default_factory=list)
class EpsController:
"""Transmits just the terminal + road-speed messages, on their own thread."""
def __init__(self, adapter, capture: Path, log_path: Optional[Path] = None):
self.adapter = adapter
self.terminal_sequences = load_state_sequences(capture, CAN_ID_TERMINAL)
self.speed_templates = load_frames(capture, CAN_ID_ROAD_SPEED)
self.terminal_state = 0x00
self.speed_kmh = 0.0
self.send_speed = True
self.rx: dict[int, EpsRx] = {}
self.tx_count = 0
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._t0 = 0.0
self._term_i = 0
self._speed_i = 0
self._log_file = None
self._log_writer = None
if log_path is not None:
self._log_file = open(log_path, "w", newline="")
self._log_writer = csv.writer(self._log_file)
self._log_writer.writerow(["t", "wall", "dir", "arbitration_id", "dlc", "data_hex", "note"])
def available_states(self) -> dict[str, int]:
"""Terminal states we actually hold captured frames for."""
return {name: v for name, v in TERMINAL_STATES.items() if v in self.terminal_sequences}
def set_terminal(self, value: int) -> None:
with self._lock:
self.terminal_state = value
self._term_i = 0
self._log_note(f"terminal -> 0x{value:02X}")
def set_speed(self, kmh: float) -> None:
with self._lock:
self.speed_kmh = kmh
self._log_note(f"speed -> {kmh:.1f} km/h")
def _log_note(self, note: str) -> None:
if self._log_writer is not None:
self._log_writer.writerow([round(time.monotonic() - self._t0, 4), round(time.time(), 4),
"note", "", "", "", note])
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._t0 = time.monotonic()
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._log_file is not None:
self._log_file.close()
self._log_file = None
self._log_writer = None
def snapshot(self):
with self._lock:
return dict(self.rx), self.tx_count
def _run(self) -> None:
next_term = next_speed = time.monotonic()
while not self._stop.is_set():
now = time.monotonic()
batch = []
if now >= next_term:
next_term = now + PERIOD_TERMINAL
with self._lock:
seq = self.terminal_sequences.get(self.terminal_state)
if seq:
data = seq[self._term_i % len(seq)]
self._term_i += 1
batch.append((CAN_ID_TERMINAL, data, False))
if now >= next_speed:
next_speed = now + PERIOD_ROAD_SPEED
with self._lock:
if self.send_speed and self.speed_templates:
tmpl = self.speed_templates[self._speed_i % len(self.speed_templates)]
self._speed_i += 1
batch.append((CAN_ID_ROAD_SPEED, encode_road_speed(tmpl, self.speed_kmh), False))
if batch:
self.adapter.send_frames(batch)
with self._lock:
self.tx_count += len(batch)
if self._log_writer is not None:
wall = time.time()
for aid, data, _ in batch:
self._log_writer.writerow([round(now - self._t0, 4), round(wall, 4), "tx",
f"{aid:X}", len(data), data.hex().upper(), ""])
deadline = min(next_term, next_speed)
while time.monotonic() < deadline:
frame = self.adapter.read_frame(timeout=0.001)
if frame is None:
break
self._record_rx(frame)
slack = deadline - time.monotonic()
if slack > 0:
time.sleep(min(slack, 0.005))
def _record_rx(self, frame) -> None:
t = time.monotonic() - self._t0
with self._lock:
info = self.rx.get(frame.arbitration_id)
if info is None:
info = EpsRx()
self.rx[frame.arbitration_id] = info
if info.last_data and len(info.last_data) == len(frame.data):
for i, (a, b) in enumerate(zip(info.last_data, frame.data)):
if a != b:
info.changed_mask |= 1 << i
info.count += 1
info.last_seen = t
info.last_data = frame.data
info.history.append((t, frame.data))
if len(info.history) > 200:
del info.history[:100]
if self._log_writer is not None:
self._log_writer.writerow([round(t, 4), round(time.time(), 4), "rx",
f"{frame.arbitration_id:X}", len(frame.data),
frame.data.hex().upper(), ""])

138
gateway/gateway.py Normal file
View file

@ -0,0 +1,138 @@
"""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)

1004
gateway/gateway_app.py Normal file

File diff suppressed because it is too large Load diff

154
gateway/replay_runner.py Normal file
View file

@ -0,0 +1,154 @@
"""Hardware-aware replay engine, shared by gateway_app.py's Replay tab.
Unlike decoder/replay_source.py's ReplayPlayer (which only feeds a
FrameStore for visualization), this actually sends allowed frames to a real
adapter, and can also replay the CAN-IO board's own digital output changes
(see tools/extract_dio_timeline.py) - both driven by the same wall-clock
advance() call so it fits Streamlit's rerun-loop pattern with no background
thread needed.
"""
from __future__ import annotations
import csv
from pathlib import Path
from typing import Optional
CAN_ID_COMMAND = 0x101
CAN_ID_STATUS = 0x100
CMD_SET_ALL = 0x02
def load_capture(path: Path) -> list[tuple[float, int, bytes]]:
"""Same format/behaviour as decoder.replay_source.load_capture."""
rows = []
with open(path, newline="") as f:
for row in csv.DictReader(f):
rows.append((int(row["timestamp_ms"]), int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"])))
rows.sort(key=lambda r: r[0])
if not rows:
return []
t0 = rows[0][0]
return [((ms - t0) / 1000, aid, data) for ms, aid, data in rows]
def load_dio_events(path: Path) -> list[tuple[float, int]]:
"""Read a DIO timeline, keeping only the times the outputs changed.
Accepts both layouts we produce:
- gateway/dio_monitor.py's DioLogger: t, uptime_s, in1..in4, out1, out2, event
- the older tools/extract_dio_timeline.py: t, inputs, outputs
"""
events = []
prev = None
with open(path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
if "out1" in row:
outputs = (int(row["out1"]) & 1) | ((int(row["out2"]) & 1) << 1)
else:
outputs = int(row["outputs"])
if outputs != prev:
events.append((float(row["t"]), outputs))
prev = outputs
return events
def gateway_log_to_capture(gateway_log: Path, direction: str = "car->eps", include_blocked: bool = False) -> list[tuple[float, int, bytes]]:
"""Same idea as tools/gateway_log_to_capture.py, but in-process."""
rows = []
with open(gateway_log, newline="") as f:
for row in csv.DictReader(f):
if row["direction"] != direction:
continue
if not include_blocked and row["relayed"] != "1":
continue
rows.append((float(row["t"]), int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"])))
rows.sort(key=lambda r: r[0])
if not rows:
return []
t0 = rows[0][0]
return [(t - t0, aid, data) for t, aid, data in rows]
def extract_dio_events(gateway_log: Path) -> list[tuple[float, int]]:
"""Same idea as tools/extract_dio_timeline.py, but in-process."""
samples = []
with open(gateway_log, newline="") as f:
for row in csv.DictReader(f):
if int(row["arbitration_id"], 16) != CAN_ID_STATUS:
continue
data = bytes.fromhex(row["data_hex"])
if len(data) < 2:
continue
samples.append((float(row["t"]), data[1]))
events = []
prev = None
for t, outputs in samples:
if outputs != prev:
events.append((t, outputs))
prev = outputs
return events
class HardwareReplayPlayer:
"""Steps through a merged (frames + DIO events) timeline, sending to a
real adapter as it goes, driven by repeated advance() calls (Streamlit
rerun loop) rather than a background thread."""
def __init__(self, frames: list[tuple[float, int, bytes]], dio_events: Optional[list[tuple[float, int]]] = None):
self.frames = frames
timeline = [(t, "frame", (aid, data)) for t, aid, data in frames]
timeline += [(t, "dio", outputs) for t, outputs in (dio_events or [])]
timeline.sort(key=lambda e: e[0])
self.timeline = timeline
self.duration = frames[-1][0] if frames else 0.0
self.index = 0
self.clock = 0.0
self.sent = 0
self.skipped = 0
self.dio_sent = 0
def reset(self) -> None:
self.index = 0
self.clock = 0.0
self.sent = self.skipped = self.dio_sent = 0
def at_end(self) -> bool:
return self.index >= len(self.timeline)
def advance(self, dt_wall: float, speed: float, store, rules, adapter=None) -> None:
self.clock += dt_wall * speed
while self.index < len(self.timeline) and self.timeline[self.index][0] <= self.clock:
t, kind, payload = self.timeline[self.index]
if kind == "frame":
aid, data = payload
allowed = rules.allows(aid)
if allowed:
if adapter is not None:
adapter.send_frame(aid, data)
self.sent += 1
store.feed(aid, data, t)
else:
self.skipped += 1
else: # "dio"
if adapter is not None:
# Direct USB IO channel, not a CAN command frame: works even
# when the EPS bus has no other powered node to ACK traffic.
for idx in range(2):
adapter.set_output(idx, bool(payload & (1 << idx)))
self.dio_sent += 1
self.index += 1
def seek(self, t: float, store) -> None:
store.reset()
self.reset()
self.clock = t
while self.index < len(self.timeline) and self.timeline[self.index][0] <= self.clock:
frame_t, kind, payload = self.timeline[self.index]
if kind == "frame":
aid, data = payload
store.feed(aid, data, frame_t)
self.sent += 1
else:
self.dio_sent += 1
self.index += 1

134
gateway/replay_to_bus.py Normal file
View file

@ -0,0 +1,134 @@
"""Replay a captured CSV onto a live CAN bus (e.g. the EPS bus via the CAN-IO
board's USB bridge) - see whether the EPS will power up/behave from a
recording alone, with no car attached. A stepping stone before writing a
synthetic frame generator: get a known-good replay working and filtered down
to the minimum frame set first, then synthesize from there.
Usage:
python gateway/replay_to_bus.py captures/capture_Start-Stop.csv \\
--port /dev/cu.usbmodemXXXX --bitrate 500000 \\
[--speed 1.0] [--loop] [--block 0x1D6 0x380] [--rules myrules.json] \\
[--dio-log captures/dio_20260829.csv] [--dry-run]
--dry-run replays the timing/filtering logic and prints a summary without
opening a serial port - useful for testing with no adapter attached.
--rules loads a plain FilterRules JSON (as saved by FilterRules.save(), i.e.
{"default_allow": ..., "overrides": {...}}) - not the combined car_to_eps/
eps_to_car file gateway_app.py saves.
--dio-log replays the CAN-IO board's own digital outputs (e.g. the 12V
signal repeat) alongside the CAN traffic, from a timeline produced by
tools/extract_dio_timeline.py. Without this, a replay only sends CAN
frames - if the EPS also needs that physical signal to enable, replaying
CAN alone won't reproduce what it saw live.
"""
import argparse
import csv
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from adapters.slcan_adapter import SlcanAdapter # noqa: E402
from decoder.replay_source import load_capture # noqa: E402
from gateway.rules import FilterRules # noqa: E402
CAN_ID_COMMAND = 0x101
CMD_SET_ALL = 0x02
def load_dio_events(path: Path) -> list[tuple[float, int]]:
"""Read a (t, inputs, outputs) timeline, keep only the times outputs changed."""
events = []
prev = None
with open(path, newline="") as f:
for row in csv.DictReader(f):
outputs = int(row["outputs"])
if outputs != prev:
events.append((float(row["t"]), outputs))
prev = outputs
return events
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("csv", help="Capture file to replay (timestamp_ms, arbitration_id, is_extended, dlc, data_hex)")
parser.add_argument("--port", help="Serial port of the target adapter (e.g. CAN-IO board bridge)")
parser.add_argument("--bitrate", type=int, default=500_000)
parser.add_argument("--speed", type=float, default=1.0, help="Playback speed multiplier")
parser.add_argument("--loop", action="store_true", help="Replay repeatedly until Ctrl+C")
parser.add_argument("--block", nargs="*", default=[], help="Arbitration IDs (hex) to withhold from the bus")
parser.add_argument("--rules", default=None, help="Load a FilterRules JSON file instead of/in addition to --block")
parser.add_argument("--dio-log", default=None, help="Replay CAN-IO board output changes from tools/extract_dio_timeline.py's output")
parser.add_argument("--dry-run", action="store_true", help="Print a summary instead of sending - no adapter needed")
args = parser.parse_args()
frames = load_capture(Path(args.csv))
if not frames:
print("No frames in capture", file=sys.stderr)
return 1
dio_events = load_dio_events(Path(args.dio_log)) if args.dio_log else []
rules = FilterRules()
if args.rules:
rules.load(Path(args.rules))
for tok in args.block:
rules.set_allow(int(tok, 16), False)
if args.dry_run:
adapter = None
else:
if not args.port:
print("--port is required unless --dry-run", file=sys.stderr)
return 1
adapter = SlcanAdapter(args.port, args.bitrate)
# Merge CAN frames and DIO output-change events into one time-ordered timeline.
timeline = [(t, "frame", (aid, data)) for t, aid, data in frames]
timeline += [(t, "dio", outputs) for t, outputs in dio_events]
timeline.sort(key=lambda e: e[0])
print(f"Replaying {len(frames)} frames + {len(dio_events)} DIO events ({frames[-1][0]:.1f}s) at {args.speed}x"
f"{' [dry run]' if args.dry_run else f' -> {args.port}'}. Ctrl+C to stop.\n")
sent = skipped = dio_sent = 0
try:
while True:
t0 = time.monotonic()
for t, kind, payload in timeline:
if kind == "frame":
aid, data = payload
allowed = rules.allows(aid)
if allowed:
if adapter is not None:
adapter.send_frame(aid, data)
sent += 1
else:
skipped += 1
else: # "dio": force OUT1/OUT2 to match the recorded state
if adapter is not None:
adapter.send_frame(CAN_ID_COMMAND, bytes([CMD_SET_ALL, 0x03, payload]))
dio_sent += 1
target = t0 + t / args.speed
delay = target - time.monotonic()
if delay > 0:
time.sleep(delay)
print(f"pass complete: {sent} sent, {skipped} skipped, {dio_sent} DIO changes ({len(frames)} frames total)")
if not args.loop:
break
sent = skipped = dio_sent = 0
except KeyboardInterrupt:
print("\nStopped.")
finally:
if adapter is not None:
adapter.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,4 @@
{
"car_to_eps": {"default_allow": true, "overrides": {}},
"eps_to_car": {"default_allow": true, "overrides": {}}
}

58
gateway/rules.py Normal file
View file

@ -0,0 +1,58 @@
"""Per-direction, per-ID pass/block filter rules for the gateway.
Thread-safe (read from the relay threads, written from the monitoring UI or
CLI) and JSON-persistable so a ruleset can be saved and reloaded between
sessions. Default is transparent: every ID passes until you explicitly
block it - that matches the "start fully transparent, then narrow down"
workflow.
"""
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Dict
class FilterRules:
def __init__(self, default_allow: bool = True):
self._lock = threading.Lock()
self._default_allow = default_allow
self._overrides: Dict[int, bool] = {} # arbitration_id -> allow?
def allows(self, arbitration_id: int) -> bool:
with self._lock:
return self._overrides.get(arbitration_id, self._default_allow)
def set_allow(self, arbitration_id: int, allow: bool) -> None:
with self._lock:
self._overrides[arbitration_id] = allow
def clear_override(self, arbitration_id: int) -> None:
with self._lock:
self._overrides.pop(arbitration_id, None)
def allow_all(self) -> None:
with self._lock:
self._default_allow = True
self._overrides.clear()
def block_all(self) -> None:
with self._lock:
self._default_allow = False
self._overrides.clear()
def snapshot(self) -> dict:
with self._lock:
return {"default_allow": self._default_allow, "overrides": dict(self._overrides)}
def load_snapshot(self, data: dict) -> None:
with self._lock:
self._default_allow = bool(data.get("default_allow", True))
self._overrides = {int(k): bool(v) for k, v in data.get("overrides", {}).items()}
def save(self, path: Path) -> None:
Path(path).write_text(json.dumps(self.snapshot(), indent=2, sort_keys=True))
def load(self, path: Path) -> None:
self.load_snapshot(json.loads(Path(path).read_text()))

View file

@ -0,0 +1,24 @@
{
"_comment": "First best-guess filter, built from files/PTCAN_protocol.md + the clean isolated-bus test on 2026-08-29 (gateway_20260829_164540.csv). Blocks IDs that are almost certainly irrelevant to EPS assist function: VIN, MFL buttons, diagnostics/ISO-TP, DME temps+fuel, cluster warning-lamp broadcast, and other modules' generic status heartbeats (not 0x4B0/0x1FB - those are the EPS's OWN transmissions, confirmed on the isolated bus, not something the car needs to send it). Everything else stays allowed until tested. See eps-comms/findings.md.",
"car_to_eps": {
"default_allow": true,
"overrides": {
"896": false,
"470": false,
"464": false,
"436": false,
"1152": false,
"1170": false,
"1175": false,
"1193": false,
"1408": false,
"1426": false,
"1449": false,
"1472": false
}
},
"eps_to_car": {
"default_allow": true,
"overrides": {}
}
}

View file

@ -0,0 +1,17 @@
{
"default_allow": true,
"overrides": {
"896": false,
"470": false,
"464": false,
"436": false,
"1152": false,
"1170": false,
"1175": false,
"1193": false,
"1408": false,
"1426": false,
"1449": false,
"1472": false
}
}

69
gateway/run_gateway.py Normal file
View file

@ -0,0 +1,69 @@
"""CLI entry point for the car<->EPS gateway (headless, no GUI).
Usage:
python gateway/run_gateway.py \\
--car-port /dev/cu.usbserial-DNBJV4F5 --car-bitrate 500000 \\
--eps-port /dev/cu.usbmodemXXXX --eps-bitrate 500000 \\
[--block 0x1D6 0x380] [--log captures/gateway_log.csv]
Starts fully transparent (every frame relayed both ways) unless --block is
given. For interactive control (toggle IDs while it's running, live
monitor), use gateway/gateway_app.py (Streamlit) instead.
"""
import argparse
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from adapters.candapter import Candapter # noqa: E402
from adapters.slcan_adapter import SlcanAdapter # noqa: E402
from gateway.gateway import Gateway, GatewayLogger # noqa: E402
from gateway.rules import FilterRules # noqa: E402
DEFAULT_CAR_PORT = "/dev/cu.usbserial-DNBJV4F5"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--car-port", default=DEFAULT_CAR_PORT)
parser.add_argument("--car-bitrate", type=int, default=500_000)
parser.add_argument("--eps-port", required=True, help="Serial port of the CAN-IO board's USB bridge")
parser.add_argument("--eps-bitrate", type=int, default=500_000)
parser.add_argument("--block", nargs="*", default=[], help="Arbitration IDs (hex) to block in BOTH directions")
parser.add_argument("--log", default=None, help="CSV path to log every frame seen (relayed or not)")
args = parser.parse_args()
car = Candapter(args.car_port, args.car_bitrate, timestamps=True)
eps = SlcanAdapter(args.eps_port, args.eps_bitrate)
car_to_eps = FilterRules()
eps_to_car = FilterRules()
for tok in args.block:
aid = int(tok, 16)
car_to_eps.set_allow(aid, False)
eps_to_car.set_allow(aid, False)
logger = GatewayLogger(Path(args.log)) if args.log else None
gw = Gateway(car, eps, car_to_eps, eps_to_car, logger=logger)
gw.start()
print(f"Gateway running: car({args.car_port}) <-> eps({args.eps_port}). Ctrl+C to stop.")
try:
while True:
time.sleep(2)
passed, blocked = gw.stats.snapshot()
print(f"passed IDs: {len(passed)} blocked IDs: {len(blocked)} "
f"total passed: {sum(passed.values())} total blocked: {sum(blocked.values())}")
except KeyboardInterrupt:
pass
finally:
gw.stop()
car.close()
eps.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,144 @@
"""Analyse an EPS bench session: when did the unit come online, and did it stay?
Reads captures/eps_bench_*.csv (t, dir, arbitration_id, dlc, data_hex - both
directions) plus the matching dio_bench_*.csv, and reports:
- when the 12V enable was applied vs. when the EPS first answered
- which EPS IDs appeared, and in what order
- dropout detection: gaps where the EPS stopped transmitting, which is
what "it becomes intermittent" looks like in the data
- transmit health: whether our own frames kept to their intended period,
since a bench that stalls looks identical to an EPS that drops out
Usage:
python tools/analyze_bench_session.py captures/eps_bench_20260829_183208.csv
"""
import argparse
import csv
from collections import defaultdict
from pathlib import Path
EPS_IDS = {0x1FB, 0x4B0, 0x5B0}
def load(path: Path):
tx, rx = [], []
wall0 = None
with open(path, newline="") as f:
for row in csv.DictReader(f):
wall = float(row["wall"]) if row.get("wall") else None
if wall is not None and wall0 is None:
wall0 = wall
rec = (float(row["t"]), wall, int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"]))
(tx if row["dir"] == "tx" else rx).append(rec)
return tx, rx, wall0
def load_dio(path: Path, wall0):
"""Returns transitions on the bench log's time base.
The two logs start their relative clocks at different moments (the IO
monitor at Connect, the bench at Start TX), so a shared wall clock is
the only honest way to line them up. Older logs without a 'wall' column
can't be aligned - flagged rather than silently mis-reported.
"""
events, aligned = [], False
if not path.exists():
return events, aligned
prev = None
with open(path, newline="") as f:
for row in csv.DictReader(f):
outs = int(row["out1"]) | (int(row["out2"]) << 1)
ins = sum(int(row[f"in{i+1}"]) << i for i in range(4))
if row.get("wall") and wall0 is not None:
t = float(row["wall"]) - wall0
aligned = True
else:
t = float(row["t"])
if (ins, outs) != prev:
events.append((t, ins, outs))
prev = (ins, outs)
return events, aligned
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("bench_log")
ap.add_argument("--gap", type=float, default=1.0, help="Silence longer than this counts as a dropout")
a = ap.parse_args()
path = Path(a.bench_log)
tx, rx, wall0 = load(path)
dio, aligned = load_dio(path.parent / path.name.replace("eps_bench_", "dio_bench_"), wall0)
if not tx and not rx:
print("Empty log.")
return 1
duration = max((tx[-1][0] if tx else 0), (rx[-1][0] if rx else 0))
print(f"session {duration:.1f}s · {len(tx)} frames sent · {len(rx)} received\n")
if dio:
note = "" if aligned else " (NOT time-aligned - log predates the shared wall clock)"
print(f"Digital IO transitions (OUT1 = 12V enable to EPS){note}:")
for t, ins, outs in dio:
print(f" t={t:7.2f}s IN1={ins & 1} OUT1={outs & 1}")
print()
eps_rx = [(t, aid, d) for t, _, aid, d in rx if aid in EPS_IDS]
if not eps_rx:
print("The EPS never transmitted in this session.")
return 0
enable_t = next((t for t, _, outs in dio if outs & 1), None)
first = eps_rx[0][0]
print(f"EPS first answered at t={first:.2f}s")
if enable_t is not None and aligned:
print(f" 12V enable applied at t={enable_t:.2f}s -> {first - enable_t:.2f}s to come online")
elif enable_t is not None:
print(f" 12V enable at t={enable_t:.2f}s on its own clock - not comparable, see note above")
print()
print("EPS IDs, in order of first appearance:")
seen = {}
for t, aid, _ in eps_rx:
seen.setdefault(aid, t)
for aid, t in sorted(seen.items(), key=lambda kv: kv[1]):
n = sum(1 for _, a, _ in eps_rx if a == aid)
print(f" t={t:7.2f}s 0x{aid:03X} ({n} frames)")
print()
# Dropouts: any window where nothing came back from the EPS at all.
print(f"EPS dropouts (silence > {a.gap}s after it was already online):")
gaps, prev = [], first
for t, _, _ in eps_rx[1:]:
if t - prev > a.gap:
gaps.append((prev, t))
prev = t
if duration - prev > a.gap:
gaps.append((prev, duration))
if gaps:
for s, e in gaps:
print(f" t={s:7.2f}s -> {e:7.2f}s ({e - s:.2f}s silent)")
else:
print(" none - it stayed online for the whole session")
print()
# Our own transmit health over the same windows: if we stalled, that's
# our bug, not the EPS dropping out.
print("Our transmit rate per 5s window (spot bench stalls vs. EPS faults):")
buckets = defaultdict(int)
for t, _, _, _ in tx:
buckets[int(t // 5)] += 1
rx_buckets = defaultdict(int)
for t, aid, _ in eps_rx:
rx_buckets[int(t // 5)] += 1
for b in sorted(buckets):
bar = "#" * min(40, buckets[b] // 25)
print(f" t={b*5:4d}-{b*5+5:<4d}s tx={buckets[b]:6d} eps_rx={rx_buckets.get(b, 0):5d} {bar}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,95 @@
"""Work out the message order that precedes the EPS coming online.
Takes a gateway session log where the EPS *did* wake up, finds the moment
it first transmits (its own IDs, not relayed car traffic), and reports what
the car sent before that - first-seen times, and how the CAS terminal
message 0x130 progressed. That progression is the thing a synthetic
transmit set has to reproduce: a static payload can't walk the EPS through
the same states the car does.
Usage:
python tools/analyze_startup_order.py captures/gateway_20260829_164540.csv
"""
import argparse
import csv
from collections import defaultdict
from pathlib import Path
# The EPS's own transmissions, per eps-comms/findings.md. 0x100 is our
# CAN-IO board's status frame, so it is deliberately not in this set.
EPS_IDS = {0x1FB, 0x4B0, 0x5B0}
TERMINAL = {0x00: "off", 0x40: "terminal_R", 0x41: "terminal_15",
0x45: "engine_running", 0x55: "cranking", 0x80: "wake_up"}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("gateway_log")
parser.add_argument("--window", type=float, default=None,
help="Only consider car frames within N seconds before the EPS wakes")
args = parser.parse_args()
rows = []
with open(args.gateway_log, newline="") as f:
for row in csv.DictReader(f):
rows.append((float(row["t"]), row["direction"], int(row["arbitration_id"], 16),
bytes.fromhex(row["data_hex"])))
rows.sort(key=lambda r: r[0])
wake = next((t for t, d, aid, _ in rows if d == "eps->car" and aid in EPS_IDS), None)
if wake is None:
print("The EPS never transmitted in this log - pick one where it came online.")
return 1
print(f"EPS first transmits at t={wake:.3f}s\n")
first_seen = {}
counts = defaultdict(int)
for t, direction, aid, _ in rows:
if direction != "car->eps":
continue
counts[aid] += 1
first_seen.setdefault(aid, t)
before = {aid: t for aid, t in first_seen.items() if t < wake}
after = {aid: t for aid, t in first_seen.items() if t >= wake}
if args.window is not None:
before = {aid: t for aid, t in before.items() if t >= wake - args.window}
print(f"Car IDs present BEFORE the EPS woke ({len(before)}):")
for aid, t in sorted(before.items(), key=lambda kv: kv[1]):
print(f" t={t:7.3f}s 0x{aid:03X} ({counts[aid]} frames total)")
if after:
print(f"\nCar IDs that only appeared AFTER ({len(after)}) - not needed to wake it:")
for aid, t in sorted(after.items(), key=lambda kv: kv[1]):
print(f" t={t:7.3f}s 0x{aid:03X}")
print("\n0x130 CAS terminal progression (the state walk to reproduce):")
prev = None
for t, direction, aid, data in rows:
if direction != "car->eps" or aid != 0x130 or not data:
continue
if data[0] != prev:
marker = " <-- EPS wakes around here" if prev is not None and t >= wake > 0 and abs(t - wake) < 1.5 else ""
print(f" t={t:7.3f}s b0=0x{data[0]:02X} {TERMINAL.get(data[0], '?'):15s}{marker}")
prev = data[0]
print("\nPayload variety in the pre-wake window (how much each ID actually moves):")
seen_payloads = defaultdict(set)
for t, direction, aid, data in rows:
if direction == "car->eps" and t < wake:
seen_payloads[aid].add(data)
static = [aid for aid, s in seen_payloads.items() if len(s) == 1]
varying = sorted(((len(s), aid) for aid, s in seen_payloads.items() if len(s) > 1), reverse=True)
print(f" {len(static)} IDs sent one fixed payload: " +
", ".join(f"0x{a:03X}" for a in sorted(static)))
print(" IDs whose payload changed (these need live counters/values, not a frozen capture):")
for n, aid in varying[:15]:
print(f" 0x{aid:03X}: {n} distinct payloads")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,59 @@
"""Extract the CAN-IO board's own digital IO timeline (IN1-4/OUT1-2) from a
gateway session log, by pulling out its status frames (arbitration ID
0x100, see can-io/PROTOCOL.md) - the board sends one immediately on any
input/output change plus a 1s heartbeat, so this recovers when each
channel was high/low over the session (at that resolution - a change that
gets undone within a couple of debounce/scheduling ticks might not get its
own frame, only the surrounding samples).
Usage:
python tools/extract_dio_timeline.py captures/gateway_20260829_164540.csv \\
--out captures/dio_20260829.csv
"""
import argparse
import csv
from pathlib import Path
CAN_ID_STATUS = 0x100
def bits(n: int, count: int) -> str:
return "".join(str((n >> i) & 1) for i in range(count))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("gateway_log")
parser.add_argument("--out", required=True, help="Output CSV path (t, inputs, outputs)")
args = parser.parse_args()
samples = []
with open(args.gateway_log, newline="") as f:
for row in csv.DictReader(f):
if int(row["arbitration_id"], 16) != CAN_ID_STATUS:
continue
data = bytes.fromhex(row["data_hex"])
if len(data) < 2:
continue
samples.append((float(row["t"]), data[0], data[1]))
with open(args.out, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["t", "inputs", "outputs"])
for t, ins, outs in samples:
w.writerow([t, ins, outs])
print(f"Wrote {len(samples)} status samples to {args.out}\n")
print("Transitions (IN1-4 / OUT1-2, bit0 first):")
prev = None
for t, ins, outs in samples:
cur = (ins, outs)
if cur != prev:
print(f" t={t:8.3f}s IN={bits(ins, 4)} OUT={bits(outs, 2)}")
prev = cur
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,56 @@
"""Convert a gateway session log (t, direction, arbitration_id, is_extended,
dlc, data_hex, relayed) into a plain capture CSV (timestamp_ms,
arbitration_id, is_extended, dlc, data_hex) that replay_source.py /
gateway/replay_to_bus.py can play back.
Usage:
python tools/gateway_log_to_capture.py captures/gateway_20260829_164540.csv \\
--direction car->eps --out captures/replay_car_to_eps.csv
Only rows that were actually relayed are kept by default (--include-blocked
to keep everything, e.g. to inspect what was withheld).
"""
import argparse
import csv
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("gateway_log", help="Gateway session CSV to convert")
parser.add_argument("--direction", default="car->eps", choices=["car->eps", "eps->car"])
parser.add_argument("--out", required=True, help="Output capture CSV path")
parser.add_argument("--include-blocked", action="store_true", help="Also keep frames that were NOT relayed")
args = parser.parse_args()
src = Path(args.gateway_log)
out = Path(args.out)
written = 0
with open(src, newline="") as fin, open(out, "w", newline="") as fout:
reader = csv.DictReader(fin)
writer = csv.writer(fout)
writer.writerow(["timestamp_ms", "arbitration_id", "is_extended", "dlc", "data_hex"])
for row in reader:
if row["direction"] != args.direction:
continue
if not args.include_blocked and row["relayed"] != "1":
continue
writer.writerow(
[
round(float(row["t"]) * 1000),
row["arbitration_id"],
row["is_extended"],
row["dlc"],
row["data_hex"],
]
)
written += 1
print(f"Wrote {written} frames ({args.direction}) to {out}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

96
tools/log_can.py Normal file
View file

@ -0,0 +1,96 @@
"""Capture CAN frames to a log file, with start/stop control.
Usage:
python log_can.py [--bitrate 500000] [--port /dev/cu.usbserial-DNBJV4F5]
[--output capture.csv] [--duration 30]
Prints each frame as it arrives and appends it to a CSV log file. Without
--duration it runs until you press Ctrl+C; with --duration it stops itself
after that many seconds. The CSV columns are:
timestamp_ms, arbitration_id (hex), is_extended, dlc, data_hex
timestamp_ms is the adapter's own millisecond counter (wraps every 60000ms),
so it's only useful for relative timing within one capture, not wall-clock
time.
"""
import argparse
import csv
import sys
import time
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from adapters.candapter import Candapter # noqa: E402
CAPTURES_DIR = Path(__file__).resolve().parents[1] / "captures"
DEFAULT_PORT = "/dev/cu.usbserial-DNBJV4F5"
DEFAULT_BITRATE = 500000
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", default=DEFAULT_PORT, help="Serial device path")
parser.add_argument("--bitrate", type=int, default=DEFAULT_BITRATE, help="CAN bus bitrate")
parser.add_argument("--output", default=None, help="CSV log file (default: capture_<timestamp>.csv)")
parser.add_argument("--duration", type=float, default=None, help="Stop automatically after N seconds")
args = parser.parse_args()
output = args.output or str(CAPTURES_DIR / time.strftime("capture_%Y%m%d_%H%M%S.csv"))
try:
adapter = Candapter(args.port, args.bitrate, timestamps=True)
except (OSError, ValueError) as exc:
print(f"Failed to open adapter on {args.port}: {exc}", file=sys.stderr)
return 1
ids = Counter()
frame_count = 0
start_monotonic = time.monotonic()
start_wall = time.time() # frame.recv_time uses time.time(), not monotonic()
deadline = None if args.duration is None else start_monotonic + args.duration
stop_msg = f"for {args.duration:.0f}s" if args.duration else "until Ctrl+C"
print(f"Capturing {stop_msg} -> {output}\n")
print(f"{'timestamp':>14} {'id':>10} {'dlc':>3} data (hex)")
try:
with adapter, open(output, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["timestamp_ms", "arbitration_id", "is_extended", "dlc", "data_hex"])
while deadline is None or time.monotonic() < deadline:
frame = adapter.read_frame(timeout=0.5)
if frame is None:
continue
frame_count += 1
ids[frame.arbitration_id] += 1
id_str = f"{frame.arbitration_id:08X}x" if frame.is_extended else f"{frame.arbitration_id:03X}"
data_hex = " ".join(f"{b:02X}" for b in frame.data)
print(f"{frame.recv_time - start_wall:14.3f} {id_str:>10} {len(frame.data):>3} {data_hex}")
writer.writerow(
[
frame.timestamp_ms if frame.timestamp_ms is not None else "",
f"{frame.arbitration_id:X}",
int(frame.is_extended),
len(frame.data),
frame.data.hex().upper(),
]
)
except KeyboardInterrupt:
pass
elapsed = time.monotonic() - start_monotonic
print(f"\nStopped after {elapsed:.1f}s: {frame_count} frames, {len(ids)} unique IDs.")
print("Frames per ID:", ", ".join(f"0x{i:X}={c}" for i, c in ids.most_common()))
print(f"Log written to {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

52
tools/probe_adapter.py Normal file
View file

@ -0,0 +1,52 @@
"""Probe the USB-CAN adapter to confirm it speaks a Lawicel-style ASCII protocol.
The adapter is a CANdapter (Ewert Energy Systems / candapter.com), which
enumerates as an FTDI FT240X USB-serial chip and shares its bring-up commands
(V/N/S/O/C) with SLCAN, though its frame format diverges - see candapter.py.
This script only exercises the shared commands (no CAN bus needs to be
connected yet) to read the firmware version and serial number.
"""
import sys
import time
import serial
PORT = "/dev/cu.usbserial-DNBJV4F5"
BAUD = 115200 # standard SLCAN control baud rate (independent of CAN bitrate)
def query(ser: serial.Serial, cmd: str, wait: float = 0.3) -> str:
ser.reset_input_buffer()
ser.write((cmd + "\r").encode("ascii"))
time.sleep(wait)
return ser.read(ser.in_waiting or 1).decode("ascii", errors="replace")
def main() -> int:
try:
ser = serial.Serial(PORT, BAUD, timeout=1)
except serial.SerialException as exc:
print(f"Could not open {PORT}: {exc}", file=sys.stderr)
return 1
with ser:
# Close channel first in case it was left open, ignore any response.
query(ser, "C")
version = query(ser, "V")
serial_no = query(ser, "N")
print(f"Port: {PORT}")
print(f"Raw version reply: {version!r}")
print(f"Raw serial# reply: {serial_no!r}")
if version.startswith("V") or serial_no.startswith("N"):
print("\nAdapter responded to SLCAN commands - looks like a Lawicel/CANUSB-compatible device.")
else:
print("\nNo recognizable SLCAN response. The adapter may use a different protocol.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

61
tools/raw_dump.py Normal file
View file

@ -0,0 +1,61 @@
"""Dump whatever raw bytes the CANdapter sends after opening a channel.
Useful when frames aren't parsing as expected - shows the literal serial
stream so we can see if it's real (but differently-shaped) CAN frames,
error/status chatter, or nothing at all.
Usage:
python raw_dump.py [--bitrate 500000] [--seconds 5]
"""
import argparse
import sys
import time
from pathlib import Path
import serial
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from adapters.candapter import BITRATE_CODES # noqa: E402
DEFAULT_PORT = "/dev/cu.usbserial-DNBJV4F5"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", default=DEFAULT_PORT)
parser.add_argument("--bitrate", type=int, default=500_000)
parser.add_argument("--seconds", type=float, default=5.0)
args = parser.parse_args()
ser = serial.Serial(args.port, 115200, timeout=0.2)
with ser:
ser.write(b"C\r")
time.sleep(0.1)
ser.read(ser.in_waiting or 1)
ser.write(f"S{BITRATE_CODES[args.bitrate]}\r".encode())
time.sleep(0.1)
print("S reply:", ser.read(ser.in_waiting or 1))
ser.write(b"O\r")
time.sleep(0.1)
print("O reply:", ser.read(ser.in_waiting or 1))
print(f"\nRaw stream for {args.seconds}s:")
deadline = time.monotonic() + args.seconds
got_any = False
while time.monotonic() < deadline:
chunk = ser.read(256)
if chunk:
got_any = True
print(repr(chunk))
if not got_any:
print("(nothing received)")
ser.write(b"C\r")
return 0
if __name__ == "__main__":
raise SystemExit(main())

66
tools/read_can.py Normal file
View file

@ -0,0 +1,66 @@
"""Read and decode CAN frames from the CANdapter (candapter.com) USB adapter.
Usage:
python read_can.py [--bitrate 500000] [--port /dev/cu.usbserial-DNBJV4F5]
Connect the adapter to the CAN bus first, then run this script. It opens the
channel at the given bitrate and prints every frame it receives: timestamp,
arbitration ID (with an 'x' suffix for 29-bit extended IDs), DLC, raw bytes
(hex), and an ASCII preview.
If you don't know the bus's bitrate, common values are 125000, 250000,
500000 (most likely - the adapter is rated for automotive use at 500 kbps),
and 1000000. Wrong bitrate usually means zero frames or garbled data --
try another value.
"""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from adapters.candapter import Candapter # noqa: E402
DEFAULT_PORT = "/dev/cu.usbserial-DNBJV4F5"
DEFAULT_BITRATE = 500000
def ascii_preview(data: bytes) -> str:
return "".join(chr(b) if 32 <= b < 127 else "." for b in data)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", default=DEFAULT_PORT, help="Serial device path")
parser.add_argument("--bitrate", type=int, default=DEFAULT_BITRATE, help="CAN bus bitrate")
args = parser.parse_args()
try:
adapter = Candapter(args.port, args.bitrate, timestamps=True)
except (OSError, ValueError) as exc:
print(f"Failed to open adapter on {args.port}: {exc}", file=sys.stderr)
return 1
print(f"Listening on {args.port} at {args.bitrate} bps (Ctrl+C to stop)...\n")
print(f"{'timestamp':>14} {'id':>10} {'dlc':>3} data (hex) ascii")
try:
with adapter:
while True:
frame = adapter.read_frame(timeout=None)
if frame is None:
continue
id_str = f"{frame.arbitration_id:08X}x" if frame.is_extended else f"{frame.arbitration_id:03X}"
data_hex = " ".join(f"{b:02X}" for b in frame.data)
ts = frame.timestamp_ms if frame.timestamp_ms is not None else frame.recv_time
print(
f"{ts:14.3f} {id_str:>10} {len(frame.data):>3} "
f"{data_hex:<24} {ascii_preview(frame.data)}"
)
except KeyboardInterrupt:
print("\nStopped.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

87
tools/scan_bitrate.py Normal file
View file

@ -0,0 +1,87 @@
"""Scan common automotive CAN bitrates to find one that yields valid frames.
Usage:
python scan_bitrate.py [--port /dev/cu.usbserial-DNBJV4F5] [--seconds 2]
Tries each candidate bitrate in turn, listens briefly, and reports how many
frames came back and whether they look well-formed (DLC 0-8, plausible IDs).
The bitrate with the most valid frames is your best bet.
Note on wiring: CAN H and L being swapped is NOT usually something a bitrate
scan can "see through" - CAN transceivers work on the differential voltage
(H minus L), so swapping the two wires inverts dominant/recessive and
corrupts every bit, at every bitrate. If this scan finds zero valid frames
at all common bitrates, that's a strong signal to double check H/L wiring
(and termination - a CAN bus needs ~120 ohm resistors at each end) rather
than trying more bitrates.
"""
import argparse
import sys
import time
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from adapters.candapter import Candapter # noqa: E402
DEFAULT_PORT = "/dev/cu.usbserial-DNBJV4F5"
# Common automotive bitrates, most-likely-first for a steering/body bus.
CANDIDATE_BITRATES = [500_000, 125_000, 250_000, 1_000_000, 100_000, 50_000, 20_000, 10_000]
def scan_one(port: str, bitrate: int, seconds: float) -> tuple[int, int, Counter]:
total = 0
valid = 0
ids = Counter()
with Candapter(port, bitrate) as adapter:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
frame = adapter.read_frame(timeout=0.2)
if frame is None:
continue
total += 1
if 0 <= len(frame.data) <= 8:
valid += 1
ids[frame.arbitration_id] += 1
return total, valid, ids
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", default=DEFAULT_PORT, help="Serial device path")
parser.add_argument("--seconds", type=float, default=2.0, help="Listen time per bitrate")
args = parser.parse_args()
print(f"Scanning {args.port} across {len(CANDIDATE_BITRATES)} bitrates ({args.seconds}s each)...\n")
print(f"{'bitrate':>10} {'frames':>7} {'valid':>6} {'unique ids':>10}")
results = []
for bitrate in CANDIDATE_BITRATES:
try:
total, valid, ids = scan_one(args.port, bitrate, args.seconds)
except OSError as exc:
print(f"{bitrate:>10} error opening port: {exc}")
continue
print(f"{bitrate:>10} {total:>7} {valid:>6} {len(ids):>10}")
results.append((bitrate, valid, ids))
results.sort(key=lambda r: r[1], reverse=True)
best = results[0] if results else None
if best and best[1] > 0:
bitrate, valid, ids = best
print(f"\nBest match: {bitrate} bps ({valid} valid frames, {len(ids)} unique IDs).")
print("Top IDs seen:", ", ".join(f"0x{i:X}x{c}" for i, c in ids.most_common(10)))
else:
print(
"\nNo valid frames at any bitrate. Since this affects every bitrate equally, "
"check the physical connection first: CAN H/L polarity and bus termination "
"(~120 ohm at each end) before trying more bitrates."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

100
tools/solve_checksum.py Normal file
View file

@ -0,0 +1,100 @@
"""Try to solve the checksum/counter scheme for one arbitration ID.
Needed before we can synthesise a message rather than replay it: change a
signal (say road speed in 0x1A0) and the checksum has to be recomputed or
the receiver rejects the frame. files/PTCAN_protocol.md notes that the
one's-complement scheme that works for several IDs only matches 0x1A0
60-75% of the time, so this brute-forces the remaining variants.
Usage:
python tools/solve_checksum.py captures/replay_car_to_eps_20260829.csv 0x1A0
"""
import argparse
import csv
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "files"))
from decode_ptcan import ocsum # noqa: E402
def load_frames(path: Path, target: int) -> list[bytes]:
out = []
with open(path, newline="") as f:
for row in csv.DictReader(f):
if int(row["arbitration_id"], 16) == target:
out.append(bytes.fromhex(row["data_hex"]))
return out
def try_scheme(frames, cs_index, fn) -> float:
ok = 0
for d in frames:
if cs_index >= len(d):
continue
rest = [d[i] for i in range(len(d)) if i != cs_index]
if fn(rest, d) == d[cs_index]:
ok += 1
return ok / len(frames) if frames else 0.0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("capture")
ap.add_argument("arbitration_id")
a = ap.parse_args()
target = int(a.arbitration_id, 16)
frames = load_frames(Path(a.capture), target)
if not frames:
print(f"No frames for 0x{target:03X}")
return 1
print(f"0x{target:03X}: {len(frames)} frames, {len(set(frames))} distinct, dlc={len(frames[0])}\n")
# Which byte positions actually move? A checksum byte should look random.
n = len(frames[0])
print("byte distinct values (a checksum looks high-entropy, a counter cycles)")
for i in range(n):
vals = Counter(d[i] for d in frames if len(d) > i)
sample = " ".join(f"{v:02X}" for v, _ in vals.most_common(6))
print(f" b{i} {len(vals):3d} {sample}")
print()
best = []
for cs in range(n):
# Plain sums / xors, with and without a per-ID constant.
for name, base in (
("ocsum", lambda r, d: ocsum(r)),
("sum", lambda r, d: sum(r) & 0xFF),
("xor", lambda r, d: __import__("functools").reduce(lambda x, y: x ^ y, r, 0)),
):
for const in range(256):
fn = (lambda b, c: (lambda r, d: (b(r, d) + c) & 0xFF))(base, const)
score = try_scheme(frames, cs, fn)
if score > 0.97:
best.append((score, cs, f"{name} + 0x{const:02X}"))
if best:
best.sort(reverse=True)
print("Schemes matching >97% of frames:")
for score, cs, desc in best[:10]:
print(f" byte {cs} = {desc} ({score*100:.1f}%)")
else:
print("No simple sum/xor scheme fits >97%.")
print("Best partial matches:")
partial = []
for cs in range(n):
for name, base in (("ocsum", lambda r, d: ocsum(r)), ("sum", lambda r, d: sum(r) & 0xFF)):
for const in range(256):
fn = (lambda b, c: (lambda r, d: (b(r, d) + c) & 0xFF))(base, const)
partial.append((try_scheme(frames, cs, fn), cs, f"{name} + 0x{const:02X}"))
partial.sort(reverse=True)
for score, cs, desc in partial[:5]:
print(f" byte {cs} = {desc} ({score*100:.1f}%)")
return 0
if __name__ == "__main__":
raise SystemExit(main())