BMW_E8x_EPS/decoder/ptcan_decoder.py
Luca c32c4645b5 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
2026-08-29 19:34:43 +02:00

85 lines
3.2 KiB
Python

"""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