# can.py — SocketCAN OBD-II Responder + Link-Control from __future__ import annotations import logging import threading import time import os import sys import subprocess from typing import Callable, Dict, Optional, List from pyroute2 import IPRoute, NetlinkError import json import can OBD_REQ_ID = 0x7DF PID_SPEED = 0x0D PID_RPM = 0x0C CAP_NET_ADMIN_BIT = 12 # Linux CAP_NET_ADMIN def have_cap_netadmin() -> bool: if os.geteuid() == 0: return True try: with open("/proc/self/status", "r", encoding="utf-8") as f: for line in f: if line.startswith("CapEff:"): mask = int(line.split()[1], 16) return bool(mask & (1 << CAP_NET_ADMIN_BIT)) except Exception: pass return False def need_caps_message() -> str: exe = os.path.realpath(sys.executable) return ( "Keine Berechtigung für 'ip link'.\n\n" "Option A) Als root starten (sudo)\n" "Option B) Capabilities auf das laufende Python setzen:\n" f" sudo setcap cap_net_admin,cap_net_raw=eip \"{exe}\"\n" f" getcap \"{exe}\"\n" ) def list_can_ifaces() -> List[str]: """Listet verfügbare CAN/vCAN-Interfaces via `ip -json link`.""" try: out = subprocess.check_output(["ip", "-json", "link"], text=True) import json items = json.loads(out) names = [i["ifname"] for i in items if i.get("link_type") in ("can", None) and (i["ifname"].startswith("can") or i["ifname"].startswith("vcan"))] return sorted(set(names)) except Exception: # Fallback: /sys/class/net try: import os names = [n for n in os.listdir("/sys/class/net") if n.startswith(("can", "vcan"))] return sorted(set(names)) except Exception: return [] def _link_info(ipr: IPRoute, iface: str): idxs = ipr.link_lookup(ifname=iface) if not idxs: raise RuntimeError(f"Interface '{iface}' nicht gefunden") info = ipr.get_links(idxs[0])[0] kind = "none" for k, v in info.get("attrs", []): if k == "IFLA_LINKINFO": for kk, vv in v.get("attrs", []): if kk == "IFLA_INFO_KIND": kind = str(vv) break return idxs[0], kind, info def link_state(iface: str) -> str: try: with IPRoute() as ipr: idx, _, info = _link_info(ipr, iface) st = info.get("state") if isinstance(st, str): return st.upper() for k, v in info.get("attrs", []): if k == "IFLA_OPERSTATE": return str(v).upper() except Exception: pass return "UNKNOWN" def link_up(iface: str, bitrate: int = 500000, fd: bool = False, set_params: bool = True) -> None: with IPRoute() as ipr: idx, kind, _ = _link_info(ipr, iface) # Wenn bereits UP: nichts tun. try: cur = ipr.get_links(idx)[0] if (cur.get("state") or "").upper() == "UP": return except Exception: pass # 1) down (ignoriere "invalid argument" / already down) try: ipr.link("set", index=idx, state="down") except NetlinkError as e: if e.code not in (0, 22): raise RuntimeError(f"Netlink error bei '{iface}' (down): {e.code} {e}") from e # 2) optional: Parameter nur bei 'can' – EINVAL ignorieren if set_params and kind == "can": try: ipr.link("set", index=idx, kind="can", data={"bitrate": int(bitrate)}) except NetlinkError as e: if e.code != 22: # alles außer EINVAL weiterreichen raise RuntimeError(f"Netlink error bei '{iface}' (bitrate): {e.code} {e}") from e # EINVAL -> Treiber mag Param-Change jetzt nicht -> einfach weitermachen # 3) up (hier darf es notfalls knallen) try: ipr.link("set", index=idx, state="up") except NetlinkError as e: raise RuntimeError(f"Netlink error bei '{iface}' (up): {e.code} {e}") from e def link_down(iface: str) -> None: with IPRoute() as ipr: idx, _, _ = _link_info(ipr, iface) try: ipr.link("set", index=idx, state="down") except NetlinkError as e: if e.code not in (0, 22): # ignore EINVAL/OK raise RuntimeError(f"Netlink error bei '{iface}' (down): {e.code} {e}") from e def link_kind(iface: str) -> str: """liefert nur das INFO_KIND (z.B. 'can', 'slcan', 'vcan', 'none')""" try: with IPRoute() as ipr: _, kind, _ = _link_info(ipr, iface) return kind except Exception: return "unknown" class ObdResponder: """ OBD-II Mode-01 PID-Responder über SocketCAN (11-bit). Non-blocking, threadsicher, mit Rebind-Funktion und robustem Reopen, falls das Interface DOWN ist. """ def __init__( self, interface: str, resp_id: int, timeout_ms: int = 200, logger: Optional[logging.Logger] = None, ): self.interface = interface self.resp_id = resp_id self.timeout_ms = timeout_ms self.log = logger or logging.getLogger("obdcan") # PID-Provider: pid -> callable() -> 8-Byte-Payload self.providers: Dict[int, Callable[[], bytes]] = {} # Laufzustand / CAN-Ressourcen self._run = threading.Event() self._run.set() self.bus: Optional[can.BusABC] = None self.reader: Optional[can.BufferedReader] = None self.notifier: Optional[can.Notifier] = None # Service-Thread, der bei IF=UP den Bus öffnet und RX abwickelt self._thread = threading.Thread( target=self._service_loop, name="OBD-SVC", daemon=True ) self._thread.start() # ---------- Lifecycle ---------- def _open_bus(self) -> None: # ggf. alte Ressourcen schließen, dann neu öffnen self._close_bus() self._close_bus() self.bus = can.interface.Bus(channel=self.interface, interface="socketcan") self.log.info("OBD responder started on %s (resp_id=0x%03X)", self.interface, self.resp_id) self.log.info( "OBD responder started on %s (resp_id=0x%03X)", self.interface, self.resp_id ) def _close_bus(self) -> None: try: if self.notifier: self.notifier.stop() except Exception: pass try: if self.bus: self.bus.shutdown() except Exception: pass self.bus = None def stop(self) -> None: self._run.clear() try: self._thread.join(timeout=1.0) except RuntimeError: pass self._close_bus() def rebind(self, interface: Optional[str] = None, resp_id: Optional[int] = None) -> None: if interface is not None: self.interface = interface if resp_id is not None: self.resp_id = resp_id # Bus schließen; Service-Loop öffnet ihn wieder, sobald IF=UP ist self._close_bus() self.log.info("Rebind requested: %s, resp=0x%03X", self.interface, self.resp_id) # ---------- Öffentliche API ---------- def register_pid(self, pid: int, provider: Callable[[], bytes]) -> None: self.providers[pid] = provider # ---------- Service-Loop (robust gegen 'Network is down') ---------- def _service_loop(self) -> None: backoff = 0.5 while self._run.is_set(): # Bus öffnen, wenn IF up ist if self.bus is None: if link_state(self.interface) == "UP": try: self._open_bus() backoff = 0.5 except Exception as e: self.log.warning("Bus open failed: %s", e) time.sleep(backoff) backoff = min(5.0, backoff * 1.7) continue else: time.sleep(0.5) continue # RX: eigene Poll‑Schleife statt Notifier try: msg = self.bus.recv(0.05) # blocking short timeout if msg is not None: self._handle(msg) except (can.CanOperationError, OSError): # IF ging down -> Bus schließen und später neu öffnen self.log.info("CAN went DOWN — closing bus, will retry…") self._close_bus() time.sleep(0.5) except Exception as e: self.log.warning("CAN recv error: %s", e) time.sleep(0.1) # ---------- Message-Handler ---------- def _handle(self, msg: can.Message) -> None: if msg.is_extended_id or msg.arbitration_id != OBD_REQ_ID: return data = bytes(msg.data) if len(data) < 3: return # tolerant: 02 01 ... oder 01 ... if data[0] == 0x02 and len(data) >= 3: mode, pid = data[1], data[2] else: mode, pid = data[0], (data[1] if len(data) >= 2 else None) if mode != 0x01 or pid is None: return provider = self.providers.get(pid) if not provider: return try: payload = provider() if not isinstance(payload, (bytes, bytearray)) or len(payload) != 8: return out = can.Message( arbitration_id=self.resp_id, is_extended_id=False, data=payload, dlc=8 ) if self.bus: self.bus.send(out) except can.CanError: self.log.warning("CAN send failed (bus off?)") except Exception as e: self.log.exception("Provider error: %s", e) # --- Ende Patch ObdResponder ----------------------------------------------- # Helfer fürs Formatieren def make_speed_response(speed_kmh: int) -> bytes: A = max(0, min(255, int(speed_kmh))) return bytes([0x03, 0x41, PID_SPEED, A, 0x00, 0x00, 0x00, 0x00]) def make_rpm_response(rpm: int) -> bytes: raw = max(0, int(rpm)) * 4 A = (raw >> 8) & 0xFF B = raw & 0xFF return bytes([0x04, 0x41, PID_RPM, A, B, 0x00, 0x00, 0x00])