BMW_E8x_EPS/tools/log_can.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

96 lines
3.7 KiB
Python

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