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:
commit
c32c4645b5
64 changed files with 128998 additions and 0 deletions
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal 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
0
adapters/__init__.py
Normal file
146
adapters/candapter.py
Normal file
146
adapters/candapter.py
Normal 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
177
adapters/slcan_adapter.py
Normal 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
67
can-io/PROTOCOL.md
Normal 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
147
can-io/README.md
Normal 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
5
can-io/firmware/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
165
can-io/firmware/include/config.h
Normal file
165
can-io/firmware/include/config.h
Normal 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
|
||||||
24
can-io/firmware/platformio.ini
Normal file
24
can-io/firmware/platformio.ini
Normal 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
|
||||||
209
can-io/firmware/src/can_bus.cpp
Normal file
209
can-io/firmware/src/can_bus.cpp
Normal 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;
|
||||||
|
}
|
||||||
49
can-io/firmware/src/can_bus.h
Normal file
49
can-io/firmware/src/can_bus.h
Normal 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);
|
||||||
118
can-io/firmware/src/io_input.cpp
Normal file
118
can-io/firmware/src/io_input.cpp
Normal 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);
|
||||||
|
}
|
||||||
21
can-io/firmware/src/io_input.h
Normal file
21
can-io/firmware/src/io_input.h
Normal 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);
|
||||||
82
can-io/firmware/src/io_output.cpp
Normal file
82
can-io/firmware/src/io_output.cpp
Normal 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;
|
||||||
|
}
|
||||||
34
can-io/firmware/src/io_output.h
Normal file
34
can-io/firmware/src/io_output.h
Normal 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);
|
||||||
75
can-io/firmware/src/io_state.cpp
Normal file
75
can-io/firmware/src/io_state.cpp
Normal 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);
|
||||||
|
}
|
||||||
41
can-io/firmware/src/io_state.h
Normal file
41
can-io/firmware/src/io_state.h
Normal 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);
|
||||||
28
can-io/firmware/src/logic.cpp
Normal file
28
can-io/firmware/src/logic.cpp
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
can-io/firmware/src/logic.h
Normal file
17
can-io/firmware/src/logic.h
Normal 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);
|
||||||
93
can-io/firmware/src/main.cpp
Normal file
93
can-io/firmware/src/main.cpp
Normal 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));
|
||||||
|
}
|
||||||
103
can-io/firmware/src/status.cpp
Normal file
103
can-io/firmware/src/status.cpp
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
can-io/firmware/src/status.h
Normal file
22
can-io/firmware/src/status.h
Normal 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);
|
||||||
199
can-io/firmware/src/usb_bridge.cpp
Normal file
199
can-io/firmware/src/usb_bridge.cpp
Normal 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
|
||||||
31
can-io/firmware/src/usb_bridge.h
Normal file
31
can-io/firmware/src/usb_bridge.h
Normal 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
17
can-io/gui/README.md
Normal 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
391
can-io/gui/can_io_gui.py
Normal 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()
|
||||||
2
can-io/gui/requirements.txt
Normal file
2
can-io/gui/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
python-can>=4.2
|
||||||
|
pyserial>=3.5
|
||||||
60681
captures/gateway_20260829_164540.csv
Normal file
60681
captures/gateway_20260829_164540.csv
Normal file
File diff suppressed because it is too large
Load diff
60644
captures/replay_car_to_eps_20260829.csv
Normal file
60644
captures/replay_car_to_eps_20260829.csv
Normal file
File diff suppressed because it is too large
Load diff
248
dashboard/app.py
Normal file
248
dashboard/app.py
Normal 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
0
decoder/__init__.py
Normal file
89
decoder/live_source.py
Normal file
89
decoder/live_source.py
Normal 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
85
decoder/ptcan_decoder.py
Normal 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
57
decoder/replay_source.py
Normal 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
338
eps-comms/EPS_PROTOCOL.md
Normal 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
101
eps-comms/README.md
Normal 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
192
eps-comms/findings.md
Normal 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
278
files/PTCAN_protocol.md
Normal 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
146
files/bmw_e8x_ptcan.dbc
Normal 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
191
files/decode_ptcan.py
Normal 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
470
files/ptcan_replay.html
Normal file
File diff suppressed because one or more lines are too long
0
gateway/__init__.py
Normal file
0
gateway/__init__.py
Normal file
152
gateway/dio_monitor.py
Normal file
152
gateway/dio_monitor.py
Normal 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
331
gateway/eps_bench.py
Normal 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
283
gateway/eps_control.py
Normal 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
138
gateway/gateway.py
Normal 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
1004
gateway/gateway_app.py
Normal file
File diff suppressed because it is too large
Load diff
154
gateway/replay_runner.py
Normal file
154
gateway/replay_runner.py
Normal 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
134
gateway/replay_to_bus.py
Normal 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())
|
||||||
4
gateway/rules.default.json
Normal file
4
gateway/rules.default.json
Normal 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
58
gateway/rules.py
Normal 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()))
|
||||||
24
gateway/rules.suggested.json
Normal file
24
gateway/rules.suggested.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
gateway/rules.suggested_car_to_eps.json
Normal file
17
gateway/rules.suggested_car_to_eps.json
Normal 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
69
gateway/run_gateway.py
Normal 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())
|
||||||
144
tools/analyze_bench_session.py
Normal file
144
tools/analyze_bench_session.py
Normal 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())
|
||||||
95
tools/analyze_startup_order.py
Normal file
95
tools/analyze_startup_order.py
Normal 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())
|
||||||
59
tools/extract_dio_timeline.py
Normal file
59
tools/extract_dio_timeline.py
Normal 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())
|
||||||
56
tools/gateway_log_to_capture.py
Normal file
56
tools/gateway_log_to_capture.py
Normal 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
96
tools/log_can.py
Normal 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
52
tools/probe_adapter.py
Normal 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
61
tools/raw_dump.py
Normal 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
66
tools/read_can.py
Normal 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
87
tools/scan_bitrate.py
Normal 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
100
tools/solve_checksum.py
Normal 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())
|
||||||
Loading…
Reference in a new issue