From 2334df399b5da466f6d1d4e8a7c1c4d554a82ce4 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:28:08 -0400 Subject: [PATCH] OBDyssey --- pyproject.toml | 2 + selfdrive/ui/tests/test_bluetooth_big_ui.py | 13 + selfdrive/ui/tests/test_obdyssey_ui.py | 254 ++++ starpilot/system/bluetooth/bluez.py | 366 +++++- starpilot/system/bluetooth/daemon.py | 1 + starpilot/system/bluetooth/protocol.py | 22 +- .../system/bluetooth/tests/test_bluetooth.py | 89 +- starpilot/system/obdyssey/__init__.py | 66 ++ .../curated_profiles/Chevrolet-Bolt-EV.json | 173 +++ starpilot/system/obdyssey/daemon.py | 1043 +++++++++++++++++ starpilot/system/obdyssey/diagnostics.py | 493 ++++++++ starpilot/system/obdyssey/elm327.py | 1030 ++++++++++++++++ starpilot/system/obdyssey/obdb.py | 914 +++++++++++++++ starpilot/system/obdyssey/obdb_provider.py | 207 ++++ starpilot/system/obdyssey/profiles.py | 715 +++++++++++ starpilot/system/obdyssey/protocol.py | 291 +++++ starpilot/system/obdyssey/tests/__init__.py | 1 + .../system/obdyssey/tests/test_daemon.py | 547 +++++++++ .../system/obdyssey/tests/test_diagnostics.py | 249 ++++ .../system/obdyssey/tests/test_elm327.py | 456 +++++++ starpilot/system/obdyssey/tests/test_obdb.py | 151 +++ .../system/obdyssey/tests/test_profiles.py | 220 ++++ .../system/obdyssey/tests/test_protocol.py | 30 + .../obdyssey/tests/test_stabilization.py | 936 +++++++++++++++ .../system/obdyssey/tests/test_transport.py | 101 ++ .../29bit_different_response_priority.txt | 6 + .../transcripts/29bit_standard_priority.txt | 6 + .../obdyssey/tests/transcripts/README.md | 15 + .../obdyssey/tests/transcripts/atd_reset.txt | 3 + .../obdyssey/tests/transcripts/can_error.txt | 3 + .../tests/transcripts/fcm0_singleframe.txt | 6 + .../tests/transcripts/fcm1_multiframe.txt | 11 + .../tests/transcripts/generic_clone_init.txt | 12 + .../transcripts/gm_mode22_multiframe.txt | 8 + .../transcripts/incomplete_multiframe.txt | 3 + .../transcripts/known_good_adapter_init.txt | 12 + .../obdyssey/tests/transcripts/no_data.txt | 3 + .../tests/transcripts/response_pending.txt | 7 + .../obdyssey/tests/transcripts/stopped.txt | 3 + .../tests/transcripts/uds_dtc_019.txt | 3 + starpilot/system/obdyssey/transport.py | 239 ++++ system/manager/process_config.py | 1 + system/ui/widgets/bluetooth.py | 24 +- system/ui/widgets/obdyssey.py | 492 ++++++++ third_party/obdb_saej1979/LICENSE | 15 + third_party/obdb_saej1979/profile.json | 761 ++++++++++++ 46 files changed, 9916 insertions(+), 87 deletions(-) create mode 100644 selfdrive/ui/tests/test_obdyssey_ui.py create mode 100644 starpilot/system/obdyssey/__init__.py create mode 100644 starpilot/system/obdyssey/curated_profiles/Chevrolet-Bolt-EV.json create mode 100644 starpilot/system/obdyssey/daemon.py create mode 100644 starpilot/system/obdyssey/diagnostics.py create mode 100644 starpilot/system/obdyssey/elm327.py create mode 100644 starpilot/system/obdyssey/obdb.py create mode 100644 starpilot/system/obdyssey/obdb_provider.py create mode 100644 starpilot/system/obdyssey/profiles.py create mode 100644 starpilot/system/obdyssey/protocol.py create mode 100644 starpilot/system/obdyssey/tests/__init__.py create mode 100644 starpilot/system/obdyssey/tests/test_daemon.py create mode 100644 starpilot/system/obdyssey/tests/test_diagnostics.py create mode 100644 starpilot/system/obdyssey/tests/test_elm327.py create mode 100644 starpilot/system/obdyssey/tests/test_obdb.py create mode 100644 starpilot/system/obdyssey/tests/test_profiles.py create mode 100644 starpilot/system/obdyssey/tests/test_protocol.py create mode 100644 starpilot/system/obdyssey/tests/test_stabilization.py create mode 100644 starpilot/system/obdyssey/tests/test_transport.py create mode 100644 starpilot/system/obdyssey/tests/transcripts/29bit_different_response_priority.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/29bit_standard_priority.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/README.md create mode 100644 starpilot/system/obdyssey/tests/transcripts/atd_reset.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/can_error.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/fcm0_singleframe.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/fcm1_multiframe.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/generic_clone_init.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/gm_mode22_multiframe.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/incomplete_multiframe.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/known_good_adapter_init.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/no_data.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/response_pending.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/stopped.txt create mode 100644 starpilot/system/obdyssey/tests/transcripts/uds_dtc_019.txt create mode 100644 starpilot/system/obdyssey/transport.py create mode 100644 system/ui/widgets/obdyssey.py create mode 100644 third_party/obdb_saej1979/LICENSE create mode 100644 third_party/obdb_saej1979/profile.json diff --git a/pyproject.toml b/pyproject.toml index ec6813d98..e7dd66bcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -183,6 +183,8 @@ testpaths = [ "tools/replay", "tools/cabana", "cereal/messaging/tests", + "starpilot/system/obdyssey/tests", + "starpilot/system/bluetooth/tests", ] [tool.codespell] diff --git a/selfdrive/ui/tests/test_bluetooth_big_ui.py b/selfdrive/ui/tests/test_bluetooth_big_ui.py index ce2fbea05..b10317bae 100644 --- a/selfdrive/ui/tests/test_bluetooth_big_ui.py +++ b/selfdrive/ui/tests/test_bluetooth_big_ui.py @@ -139,6 +139,19 @@ def test_primary_device_action_is_pair_then_connect_then_manage(): assert managed == [ADDRESS] +def test_obd_device_action_opens_obdyssey_directly_when_paired(): + obd_device = BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDLink MX+", paired=True, serial=True) + manager = FakeBluetoothManager(BluetoothStatus(offroad=True, devices=(obd_device,))) + ui = make_ui(manager) + + opened = [] + ui._open_obdyssey = lambda address: opened.append(address) + ui._select_device("AA:BB:CC:DD:EE:FF") + + assert opened == ["AA:BB:CC:DD:EE:FF"] + assert manager.calls == [] + + def test_scan_is_only_requested_when_the_existing_daemon_policy_allows_it(): manager = FakeBluetoothManager(BluetoothStatus(enabled=True, offroad=True)) ui = make_ui(manager) diff --git a/selfdrive/ui/tests/test_obdyssey_ui.py b/selfdrive/ui/tests/test_obdyssey_ui.py new file mode 100644 index 000000000..311317fda --- /dev/null +++ b/selfdrive/ui/tests/test_obdyssey_ui.py @@ -0,0 +1,254 @@ +import os +import threading +import time +from types import SimpleNamespace + +os.environ.setdefault("SP_HEADLESS_TEST", "1") + +from openpilot.starpilot.system.bluetooth.protocol import BluetoothDevice, BluetoothStatus, looks_like_obd_device +from openpilot.starpilot.system.bluetooth.tests.test_bluetooth import FakeParams +from openpilot.starpilot.system.obdyssey.protocol import OBDysseyStatus +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets.bluetooth import device_status_text +from openpilot.system.ui.widgets.obdyssey import OBDysseyScreen +from openpilot.system.ui.widgets.obdyssey import DTC_STATE_CLEAN, DTC_STATE_FAULTS, DTC_STATE_IN_PROGRESS, DTC_STATE_UNAVAILABLE + + +class FakeOBDysseyClient: + def __init__(self, connected: bool = True): + self.is_connected = connected + self.cleared = False + self.connect_calls = 0 + self.dtcs = [{"code": "P0133", "source": "OBD_STORED", "description": "O2 Sensor Slow Response"}] + self.signals = { + "SAE_ENGINE_RPM": 2100, + "SAE_VEHICLE_SPEED": 65, + "SAE_ENGINE_COOLANT_TEMP": 90, + "BOLT_HVBAT_SOC": 78.5, + } + + def status(self) -> OBDysseyStatus: + return OBDysseyStatus( + connected=self.is_connected, + state="ready" if self.is_connected else "disconnected", + adapter_name="OBDLink MX+", + elm_identity="ELM327 v1.5", + adapter_voltage=13.9, + profile="Chevrolet-Bolt-EV", + ) + + def connect(self) -> dict: + self.connect_calls += 1 + self.is_connected = True + return {"ok": True} + + def list_signals(self) -> list[dict]: + return [{"id": k, "name": k} for k in self.signals.keys()] + + def read_signals(self, ids: list[str]) -> dict: + return {k: self.signals[k] for k in ids if k in self.signals} + + def read_dtcs(self) -> list[dict]: + return self.dtcs + + def clear_dtcs(self) -> dict: + self.cleared = True + self.dtcs = [] + return {"ok": True} + + +def make_obdyssey_screen(client: FakeOBDysseyClient, params: FakeParams) -> OBDysseyScreen: + screen = object.__new__(OBDysseyScreen) + screen._client = client + screen.params = params + screen._adapter_address = "" + screen._stop_event = threading.Event() + screen._poller_thread = None + screen._status = None + screen._available_signals = [] + screen._live_telemetry = {} + screen._dtcs = [] + screen._dtc_state = DTC_STATE_UNAVAILABLE + screen._dtc_scan_in_progress = False + screen._clear_in_progress = False + screen._retry_in_progress = False + screen._last_error = "" + return screen + + +def test_device_status_text_includes_obd_capability(): + dev_obd = BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDII", connected=True, serial=True) + status_str = device_status_text(dev_obd, "", "") + assert "OBD-II" in status_str + assert "Connected" in status_str + + dev_obd_paired = BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDII", paired=True, serial=True) + status_paired_str = device_status_text(dev_obd_paired, "", "") + assert "Paired - tap to open OBDyssey" in status_paired_str + + +def test_obd_device_detection_for_bluetooth_header(): + # Unpaired, not connected OBD device + status_unpaired = BluetoothStatus( + enabled=True, + offroad=True, + devices=( + BluetoothDevice("11:22:33:44:55:66", "Headphones", connected=True, audio=True), + BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDII", connected=False, paired=False, serial=True), + ) + ) + has_obd_unpaired = any((d.connected or d.paired) and (d.serial or looks_like_obd_device(d.name)) for d in status_unpaired.devices) + assert has_obd_unpaired is False + + # Paired OBD device + status_paired = BluetoothStatus( + enabled=True, + offroad=True, + devices=( + BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDII", connected=False, paired=True, serial=True), + ) + ) + has_obd_paired = any((d.connected or d.paired) and (d.serial or looks_like_obd_device(d.name)) for d in status_paired.devices) + assert has_obd_paired is True + + # Connected OBD device + status_connected = BluetoothStatus( + enabled=True, + offroad=True, + devices=( + BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDII", connected=True, paired=True, serial=True), + ) + ) + has_obd_connected = any((d.connected or d.paired) and (d.serial or looks_like_obd_device(d.name)) for d in status_connected.devices) + assert has_obd_connected is True + + +def test_obdyssey_screen_polling_logic(): + fake_client = FakeOBDysseyClient(connected=True) + params = FakeParams(IsOffroad=True) + screen = make_obdyssey_screen(fake_client, params) + + # Fetch initial state + screen._status = fake_client.status() + screen._available_signals = fake_client.list_signals() + screen._dtcs = fake_client.read_dtcs() + readings = fake_client.read_signals([s["id"] for s in screen._available_signals]) + screen._live_telemetry.update(readings) + + assert screen._status.connected is True + assert screen._live_telemetry["SAE_ENGINE_RPM"] == 2100 + assert screen._live_telemetry["BOLT_HVBAT_SOC"] == 78.5 + assert len(screen._dtcs) == 1 + assert screen._dtcs[0]["code"] == "P0133" + + +def test_obdyssey_ready_refresh_preserves_telemetry_and_dtcs(): + class StopAfterOneRefresh: + def __init__(self): + self.stopped = False + + def clear(self): + self.stopped = False + + def is_set(self): + return self.stopped + + def wait(self, _timeout): + self.stopped = True + return True + + fake_client = FakeOBDysseyClient(connected=True) + params = FakeParams(IsOffroad=True) + screen = make_obdyssey_screen(fake_client, params) + screen._stop_event = StopAfterOneRefresh() + screen._live_telemetry = {"SAE_ENGINE_RPM": 2100} + screen._dtcs = [{"code": "P0133"}] + screen._dtc_state = DTC_STATE_FAULTS + + screen._worker_loop() + + assert screen._live_telemetry["SAE_ENGINE_RPM"] == 2100 + assert screen._dtcs == [{"code": "P0133"}] + assert screen._dtc_state == DTC_STATE_FAULTS + + +def test_obdyssey_screen_clear_codes_safety_gating(monkeypatch): + fake_client = FakeOBDysseyClient(connected=True) + params = FakeParams(IsOffroad=False) + screen = make_obdyssey_screen(fake_client, params) + + pushed_widgets = [] + monkeypatch.setattr(gui_app, "push_widget", lambda w: pushed_widgets.append(w)) + monkeypatch.setattr("openpilot.system.ui.widgets.obdyssey.alert_dialog", lambda msg: SimpleNamespace(message=msg)) + monkeypatch.setattr( + "openpilot.system.ui.widgets.obdyssey.ConfirmDialog", + lambda msg, text, callback=None: SimpleNamespace(message=msg, text=text, callback=callback), + ) + + # 1. Onroad attempt -> Rejected with alert + screen._confirm_clear_dtcs() + assert len(pushed_widgets) == 1 + assert "offroad" in str(pushed_widgets[0].message).lower() + assert not fake_client.cleared + + # 2. Offroad attempt -> Shows confirmation dialog + params.values["IsOffroad"] = True + pushed_widgets.clear() + screen._confirm_clear_dtcs() + assert len(pushed_widgets) == 1 + assert "Clear Codes" in str(pushed_widgets[0].text) + + # Trigger clear worker directly + screen._clear_dtcs_worker() + # Wait for worker thread + deadline = threading.Event() + deadline.wait(0.6) + assert fake_client.cleared is True + assert len(screen._dtcs) == 0 + + +def test_obdyssey_dtc_state_requires_successful_scan(): + fake_client = FakeOBDysseyClient(connected=True) + params = FakeParams(IsOffroad=True) + screen = make_obdyssey_screen(fake_client, params) + screen._status = fake_client.status() + + assert screen._dtc_state == DTC_STATE_UNAVAILABLE + + screen._dtc_state = DTC_STATE_IN_PROGRESS + fake_client.dtcs = [] + screen._scan_dtcs_worker() + assert screen._dtc_state == DTC_STATE_CLEAN + + fake_client.dtcs = [{"code": "P0133"}] + screen._dtc_state = DTC_STATE_IN_PROGRESS + screen._scan_dtcs_worker() + assert screen._dtc_state == DTC_STATE_FAULTS + + +def test_obdyssey_failed_dtc_scan_is_unavailable_not_clean(): + fake_client = FakeOBDysseyClient(connected=True) + params = FakeParams(IsOffroad=True) + screen = make_obdyssey_screen(fake_client, params) + fake_client.read_dtcs = lambda: (_ for _ in ()).throw(RuntimeError("adapter unavailable")) + screen._dtc_state = DTC_STATE_IN_PROGRESS + + screen._scan_dtcs_worker() + + assert screen._dtc_state == DTC_STATE_UNAVAILABLE + assert screen._dtcs == [] + assert "adapter unavailable" in screen._last_error + + +def test_obdyssey_retry_uses_explicit_daemon_connect(): + fake_client = FakeOBDysseyClient(connected=False) + params = FakeParams(IsOffroad=True) + screen = make_obdyssey_screen(fake_client, params) + + screen._retry_connection() + deadline = time.monotonic() + 1.0 + while fake_client.connect_calls == 0 and time.monotonic() < deadline: + time.sleep(0.01) + + assert fake_client.connect_calls == 1 + assert screen._retry_in_progress is False diff --git a/starpilot/system/bluetooth/bluez.py b/starpilot/system/bluetooth/bluez.py index f897beb09..b3a6b93a1 100644 --- a/starpilot/system/bluetooth/bluez.py +++ b/starpilot/system/bluetooth/bluez.py @@ -1,3 +1,5 @@ +import os +import socket import threading import time import uuid @@ -5,11 +7,13 @@ import uuid from typing import Any from jeepney import DBusAddress, MatchRule, new_error, new_method_call, new_method_return +from jeepney.fds import FileDescriptor from jeepney.io.threading import DBusRouter, open_dbus_connection from jeepney.low_level import HeaderFields, MessageType from jeepney.wrappers import Properties -from openpilot.starpilot.system.bluetooth.protocol import device_capabilities, show_pairing_device +from openpilot.common.swaglog import cloudlog +from openpilot.starpilot.system.bluetooth.protocol import SPP_UUID, device_capabilities, show_pairing_device BLUEZ = "org.bluez" @@ -19,6 +23,50 @@ DEVICE_IFACE = "org.bluez.Device1" AGENT_MANAGER_IFACE = "org.bluez.AgentManager1" AGENT_IFACE = "org.bluez.Agent1" AGENT_PATH = "/link/firestar/starpilot/agent" +PROFILE_MANAGER_IFACE = "org.bluez.ProfileManager1" +PROFILE_IFACE = "org.bluez.Profile1" +OBDYSSEY_PROFILE_PATH = "/link/firestar/starpilot/obdyssey" + + +class BlueZError(RuntimeError): + """A BlueZ D-Bus error with both its stable name and human detail.""" + + def __init__(self, error_name: str, detail: str, *, path: str = "", interface: str = "", member: str = ""): + self.error_name = error_name + self.detail = detail + self.path = path + self.interface = interface + self.member = member + message = error_name if not detail or detail == error_name else f"{error_name}: {detail}" + super().__init__(message) + + @property + def method(self) -> str: + return f"{self.interface}.{self.member}" if self.interface and self.member else self.member + + +def _socket_from_dbus_fd(fd: Any) -> socket.socket: + """Take ownership of a Profile1 NewConnection file descriptor.""" + if isinstance(fd, FileDescriptor): + try: + return fd.to_socket() + except Exception: + try: + fd.close() + except Exception: + pass + raise + if isinstance(fd, int): + # Raw integer FDs are not owned by the message wrapper, so duplicate them + # before handing ownership to the socket object. + return socket.socket(fileno=os.dup(fd)) + raise TypeError(f"Unsupported D-Bus file descriptor: {type(fd).__name__}") + + +def _dbus_text(value: Any) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value) def unwrap_variant(value: Any) -> Any: @@ -78,9 +126,73 @@ class PairingAgent: return True -class BlueZClient: +class _BlueZConnection: + def __init__(self, enable_fds: bool = False): + self.router = DBusRouter(open_dbus_connection(bus="SYSTEM", enable_fds=enable_fds)) + + def close(self) -> None: + self.router.close() + + def _call(self, path: str, interface: str, member: str, signature: str | None = None, body: tuple = (), timeout: float = 15.0): + address = DBusAddress(path, bus_name=BLUEZ, interface=interface) + message = new_method_call(address, member, signature, body) if signature is not None else new_method_call(address, member) + reply = self.router.send_and_get_reply(message, timeout=timeout) + if reply.header.message_type == MessageType.error: + error_name = _dbus_text(reply.header.fields.get(HeaderFields.error_name, "org.bluez.Error.Failed")) + detail = _dbus_text(reply.body[0]) if reply.body else error_name + raise BlueZError(error_name, detail, path=path, interface=interface, member=member) + return reply.body + + def managed_objects(self) -> dict[str, dict[str, dict[str, Any]]]: + body = self._call("/", OBJECT_MANAGER, "GetManagedObjects") + return unwrap_variant(body[0]) if body else {} + + def adapter(self, objects: dict[str, Any] | None = None) -> tuple[str, dict[str, Any]]: + objects = self.managed_objects() if objects is None else objects + for path, interfaces in objects.items(): + if ADAPTER_IFACE in interfaces: + return path, interfaces[ADAPTER_IFACE] + raise RuntimeError("Bluetooth adapter is not available") + + def devices(self, objects: dict[str, Any] | None = None) -> list[dict[str, Any]]: + objects = self.managed_objects() if objects is None else objects + devices = [] + for path, interfaces in objects.items(): + if DEVICE_IFACE not in interfaces: + continue + props = interfaces[DEVICE_IFACE] + uuids = [str(value).lower() for value in props.get("UUIDs", [])] + audio, controller, serial = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", ""))) + device = { + "path": path, + "address": str(props.get("Address", "")), + "name": str(props.get("Alias") or props.get("Name") or props.get("Address") or "Unknown device"), + "paired": bool(props.get("Paired", False)), + "trusted": bool(props.get("Trusted", False)), + "connected": bool(props.get("Connected", False)), + "blocked": bool(props.get("Blocked", False)), + "rssi": int(props["RSSI"]) if "RSSI" in props else None, + "uuids": uuids, + "audio": audio, + "controller": controller, + "serial": serial, + } + if show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"], + device["blocked"], audio, controller, serial): + devices.append(device) + return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower())) + + def device_for_address(self, address: str) -> dict[str, Any]: + normalized = address.upper() + for device in self.devices(): + if device["address"].upper() == normalized: + return device + raise RuntimeError(f"Bluetooth device {address} was not found") + + +class BlueZClient(_BlueZConnection): def __init__(self): - self.router = DBusRouter(open_dbus_connection(bus="SYSTEM")) + super().__init__(enable_fds=False) self.agent = PairingAgent() self._agent_filter = self.router.filter(MatchRule(type="method_call", interface=AGENT_IFACE, path=AGENT_PATH), bufsize=20) self._agent_queue = self._agent_filter.__enter__() @@ -94,17 +206,7 @@ class BlueZClient: except Exception: pass self._agent_filter.__exit__(None, None, None) - self.router.close() - - def _call(self, path: str, interface: str, member: str, signature: str | None = None, body: tuple = (), timeout: float = 15.0): - address = DBusAddress(path, bus_name=BLUEZ, interface=interface) - message = new_method_call(address, member, signature, body) if signature is not None else new_method_call(address, member) - reply = self.router.send_and_get_reply(message, timeout=timeout) - if reply.header.message_type == MessageType.error: - error_name = reply.header.fields.get(HeaderFields.error_name, "org.bluez.Error.Failed") - detail = reply.body[0] if reply.body else error_name - raise RuntimeError(str(detail)) - return reply.body + super().close() def _register_agent(self) -> None: self._call("/org/bluez", AGENT_MANAGER_IFACE, "RegisterAgent", "os", (AGENT_PATH, "KeyboardDisplay")) @@ -152,44 +254,6 @@ class BlueZClient: except Exception as error: self.router.send(new_error(message, "org.bluez.Error.Canceled", "s", (str(error),))) - def managed_objects(self) -> dict[str, dict[str, dict[str, Any]]]: - body = self._call("/", OBJECT_MANAGER, "GetManagedObjects") - return unwrap_variant(body[0]) if body else {} - - def adapter(self, objects: dict[str, Any] | None = None) -> tuple[str, dict[str, Any]]: - objects = self.managed_objects() if objects is None else objects - for path, interfaces in objects.items(): - if ADAPTER_IFACE in interfaces: - return path, interfaces[ADAPTER_IFACE] - raise RuntimeError("Bluetooth adapter is not available") - - def devices(self, objects: dict[str, Any] | None = None) -> list[dict[str, Any]]: - objects = self.managed_objects() if objects is None else objects - devices = [] - for path, interfaces in objects.items(): - if DEVICE_IFACE not in interfaces: - continue - props = interfaces[DEVICE_IFACE] - uuids = [str(value).lower() for value in props.get("UUIDs", [])] - audio, controller = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", ""))) - device = { - "path": path, - "address": str(props.get("Address", "")), - "name": str(props.get("Alias") or props.get("Name") or props.get("Address") or "Unknown device"), - "paired": bool(props.get("Paired", False)), - "trusted": bool(props.get("Trusted", False)), - "connected": bool(props.get("Connected", False)), - "blocked": bool(props.get("Blocked", False)), - "rssi": int(props["RSSI"]) if "RSSI" in props else None, - "uuids": uuids, - "audio": audio, - "controller": controller, - } - if show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"], - device["blocked"], audio, controller): - devices.append(device) - return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower())) - def status(self) -> dict[str, Any]: objects = self.managed_objects() _, adapter = self.adapter(objects) @@ -216,13 +280,6 @@ class BlueZClient: if props.get("Discovering", False): self._call(path, ADAPTER_IFACE, "StopDiscovery") - def device_for_address(self, address: str) -> dict[str, Any]: - normalized = address.upper() - for device in self.devices(): - if device["address"].upper() == normalized: - return device - raise RuntimeError(f"Bluetooth device {address} was not found") - def set_device_property(self, address: str, name: str, signature: str, value: Any) -> None: device = self.device_for_address(address) dbus_address = DBusAddress(device["path"], bus_name=BLUEZ, interface=DEVICE_IFACE) @@ -248,3 +305,194 @@ class BlueZClient: adapter_path, _ = self.adapter() device = self.device_for_address(address) self._call(adapter_path, ADAPTER_IFACE, "RemoveDevice", "o", (device["path"],)) + + +class BlueZProfileClient(_BlueZConnection): + def __init__(self, profile_path: str = OBDYSSEY_PROFILE_PATH, profile_uuid: str = SPP_UUID): + super().__init__(enable_fds=True) + self.profile_path = profile_path + self.profile_uuid = profile_uuid + self._lock = threading.Lock() + self._new_conn_event = threading.Event() + self._closed = threading.Event() + self._last_conn_path = "" + self._last_conn_sock: socket.socket | None = None + self._active_conn_path = "" + self._active_conn_sock: socket.socket | None = None + self._profile_filter = None + self._profile_queue = None + self._profile_registered = False + self._close_lock = threading.Lock() + self._profile_thread: threading.Thread | None = None + + try: + self._profile_filter = self.router.filter(MatchRule(type="method_call", interface=PROFILE_IFACE, path=self.profile_path), bufsize=20) + self._profile_queue = self._profile_filter.__enter__() + self._profile_thread = threading.Thread(target=self._profile_loop, daemon=True) + self._profile_thread.start() + self._register_profile() + except Exception: + self.close() + raise + + def close(self) -> None: + with self._close_lock: + if self._closed.is_set(): + return + if self._profile_registered: + self._unregister_profile() + self._profile_registered = False + self._closed.set() + profile_queue = self._profile_queue + if profile_queue is not None: + try: + profile_queue.put(None, timeout=1.0) + except Exception: + pass + try: + if self._profile_filter is not None: + self._profile_filter.__exit__(None, None, None) + self._profile_filter = None + self._profile_queue = None + finally: + if self._profile_thread is not None and self._profile_thread is not threading.current_thread(): + self._profile_thread.join(timeout=1.0) + self._close_connection_sockets() + super().close() + + def _register_profile(self) -> None: + options = {"Role": ("s", "client"), "Name": ("s", "OBDyssey")} + # Keep cleanup armed before the remote call. A timeout can happen after + # BlueZ has registered the profile but before its reply reaches us. + self._profile_registered = True + self._call("/org/bluez", PROFILE_MANAGER_IFACE, "RegisterProfile", "osa{sv}", (self.profile_path, self.profile_uuid, options)) + + def _unregister_profile(self) -> None: + try: + self._call("/org/bluez", PROFILE_MANAGER_IFACE, "UnregisterProfile", "o", (self.profile_path,)) + except Exception: + pass + + def _profile_loop(self) -> None: + profile_queue = self._profile_queue + while profile_queue is not None: + message = profile_queue.get() + if message is None: + break + member = message.header.fields.get(HeaderFields.member, "") + if self._closed.is_set(): + if member == "NewConnection" and len(message.body) > 1: + try: + _socket_from_dbus_fd(message.body[1]).close() + except Exception: + pass + try: + self.router.send(new_error(message, "org.bluez.Error.Canceled", "s", ("Profile is closed",))) + except Exception: + pass + continue + sock: socket.socket | None = None + try: + if member == "NewConnection": + device_path = str(message.body[0]) if len(message.body) > 0 else "" + fd = message.body[1] if len(message.body) > 1 else None + if fd is None: + raise RuntimeError("Profile1 NewConnection did not include a file descriptor") + sock = _socket_from_dbus_fd(fd) + self._store_connection_socket(device_path, sock) + sock = None + self.router.send(new_method_return(message)) + elif member == "RequestDisconnection": + device_path = str(message.body[0]) if message.body else "" + self._close_connection_sockets(device_path) + self.router.send(new_method_return(message)) + elif member == "Release": + self._close_connection_sockets() + self.router.send(new_method_return(message)) + else: + self.router.send(new_method_return(message)) + except Exception as error: + if sock is not None: + try: + sock.close() + except Exception: + pass + elif member == "NewConnection": + self._close_connection_sockets(device_path) + self.router.send(new_error(message, "org.bluez.Error.Failed", "s", (str(error),))) + + def _store_connection_socket(self, device_path: str, sock: socket.socket) -> None: + old_socks: list[socket.socket] = [] + with self._lock: + if self._last_conn_sock is not None: + old_socks.append(self._last_conn_sock) + if self._active_conn_sock is not None: + old_socks.append(self._active_conn_sock) + self._last_conn_path = device_path + self._last_conn_sock = sock + self._active_conn_path = "" + self._active_conn_sock = None + self._new_conn_event.set() + for old_sock in old_socks: + try: + old_sock.close() + except Exception: + pass + + def _close_connection_sockets(self, device_path: str | None = None) -> None: + sockets: list[socket.socket] = [] + with self._lock: + if self._last_conn_sock is not None and (device_path is None or self._last_conn_path == device_path): + sockets.append(self._last_conn_sock) + self._last_conn_sock = None + self._last_conn_path = "" + if self._active_conn_sock is not None and (device_path is None or self._active_conn_path == device_path): + if self._active_conn_sock not in sockets: + sockets.append(self._active_conn_sock) + self._active_conn_sock = None + self._active_conn_path = "" + self._new_conn_event.set() + for sock in sockets: + try: + sock.close() + except Exception: + pass + + def connect_profile(self, address: str, timeout: float = 30.0) -> socket.socket: + device = self.device_for_address(address) + device_path = device["path"] + self._close_connection_sockets(device_path) + with self._lock: + self._new_conn_event.clear() + + try: + self._call(device_path, DEVICE_IFACE, "ConnectProfile", "s", (self.profile_uuid,), timeout=timeout) + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and not self._closed.is_set(): + remaining = deadline - time.monotonic() + if self._new_conn_event.wait(timeout=min(0.2, max(0.01, remaining))): + with self._lock: + if self._last_conn_path == device_path and self._last_conn_sock is not None: + sock = self._last_conn_sock + self._last_conn_sock = None + self._last_conn_path = "" + self._active_conn_path = device_path + self._active_conn_sock = sock + return sock + self._new_conn_event.clear() + raise TimeoutError(f"Timed out waiting for SPP connection to {address}") + except Exception as err: + method = err.method if isinstance(err, BlueZError) else f"{DEVICE_IFACE}.ConnectProfile" + error_name = err.error_name if isinstance(err, BlueZError) else type(err).__name__ + detail = err.detail if isinstance(err, BlueZError) else str(err) + cloudlog.error( + f"OBDyssey SPP connection failed method={method} address={address} device_path={device_path} " + + f"device_uuids={device.get('uuids', [])} error_name={error_name} detail={detail}" + ) + self._close_connection_sockets() + raise + + def disconnect_profile(self, address: str, timeout: float = 15.0) -> None: + device = self.device_for_address(address) + self._call(device["path"], DEVICE_IFACE, "DisconnectProfile", "s", (self.profile_uuid,), timeout=timeout) diff --git a/starpilot/system/bluetooth/daemon.py b/starpilot/system/bluetooth/daemon.py index 6ea8d5ae6..d5970133c 100644 --- a/starpilot/system/bluetooth/daemon.py +++ b/starpilot/system/bluetooth/daemon.py @@ -167,6 +167,7 @@ class BluetoothController: finally: self._reset_client() self._radio.stop() + self.params.remove("BluetoothAudioAddress") self.params.put_bool("BluetoothEnabled", False) self._scan_deadline = 0.0 elif command == "start_scan": diff --git a/starpilot/system/bluetooth/protocol.py b/starpilot/system/bluetooth/protocol.py index a7ce359f9..d7be7301f 100644 --- a/starpilot/system/bluetooth/protocol.py +++ b/starpilot/system/bluetooth/protocol.py @@ -14,6 +14,7 @@ from openpilot.common.params import Params BLUETOOTH_SOCKET_PATH = "/tmp/starpilot-bluetooth.sock" BLUETOOTH_RADIO_HELPER = "/usr/comma/bluetooth-radio" A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb" +SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb" HID_UUID = "00001124-0000-1000-8000-00805f9b34fb" HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb" COMMAND_TIMEOUTS = { @@ -26,6 +27,7 @@ COMMAND_TIMEOUTS = { "test_audio": 10.0, } TRUE_VALUES = {"1", "true", "yes", "on"} +OBD_DEVICE_PATTERNS = ("OBD", "OBDII", "ELM", "V-LINK", "VGATE", "OBDLINK", "VLINKER", "VIEOCAR") @dataclass(frozen=True) @@ -40,6 +42,7 @@ class BluetoothDevice: uuids: tuple[str, ...] = () audio: bool = False controller: bool = False + serial: bool = False @classmethod def from_dict(cls, value: dict[str, Any]) -> "BluetoothDevice": @@ -54,6 +57,7 @@ class BluetoothDevice: uuids=tuple(str(uuid).lower() for uuid in value.get("uuids", ())), audio=bool(value.get("audio", False)), controller=bool(value.get("controller", False)), + serial=bool(value.get("serial", False)), ) @@ -86,19 +90,25 @@ class BluetoothStatus: ) -def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool]: +def looks_like_obd_device(name: str) -> bool: + upper = name.upper() + return any(pattern in upper for pattern in OBD_DEVICE_PATTERNS) + + +def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool, bool]: normalized = {str(uuid).lower() for uuid in uuids} major_class = (int(bluetooth_class) >> 8) & 0x1F audio = A2DP_SINK_UUID in normalized or major_class == 0x04 or icon in {"audio-card", "audio-headphones", "audio-headset"} controller = HID_UUID in normalized or HOG_UUID in normalized or major_class == 0x05 or icon in {"input-gaming", "input-mouse", "input-keyboard"} - return audio, controller + serial = SPP_UUID in normalized + return audio, controller, serial def show_pairing_device(address: str, name: str, paired: bool, trusted: bool, connected: bool, blocked: bool, - audio: bool, controller: bool) -> bool: + audio: bool, controller: bool, serial: bool = False) -> bool: known = paired or trusted or connected named = bool(name) and name not in {address, "Unknown device"} - return known or (named and not blocked and (audio or controller)) + return known or (named and not blocked and (audio or controller or serial or looks_like_obd_device(name))) class _DesktopFakeBluetooth: @@ -205,8 +215,8 @@ class BluetoothClient: os.getenv("NOBOARD", "0").lower() in TRUE_VALUES and not os.path.exists(self.socket_path)): return None - from openpilot.system.hardware import PC - if not PC: + from openpilot.system import hardware + if not hardware.PC: return None if self._desktop_fake is None: self._desktop_fake = _DesktopFakeBluetooth() diff --git a/starpilot/system/bluetooth/tests/test_bluetooth.py b/starpilot/system/bluetooth/tests/test_bluetooth.py index 0e61937e7..a68b6bdbb 100644 --- a/starpilot/system/bluetooth/tests/test_bluetooth.py +++ b/starpilot/system/bluetooth/tests/test_bluetooth.py @@ -1,4 +1,6 @@ import io +import os +import socket import threading import time @@ -6,12 +8,14 @@ import numpy as np import pytest from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink -from openpilot.starpilot.system.bluetooth.bluez import PairingAgent +from openpilot.starpilot.system.bluetooth.bluez import BlueZError, PairingAgent, _BlueZConnection, _socket_from_dbus_fd from openpilot.starpilot.system.bluetooth.daemon import BluetoothController -from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus, - device_capabilities, show_pairing_device) +from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, SPP_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus, + device_capabilities, looks_like_obd_device, show_pairing_device) from openpilot.system import hardware from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager +from jeepney.fds import FileDescriptor +from jeepney.low_level import HeaderFields, MessageType class FakeParams: @@ -59,6 +63,7 @@ class FakeBlueZ: "connected": False, "audio": True, "controller": False, + "serial": False, } def close(self): @@ -155,21 +160,35 @@ class FakeProcess: def test_protocol_round_trip_and_capabilities(): - audio, controller = device_capabilities([A2DP_SINK_UUID, HID_UUID]) - assert audio and controller + audio, controller, serial = device_capabilities([A2DP_SINK_UUID, HID_UUID, SPP_UUID]) + assert audio and controller and serial status = BluetoothStatus.from_dict({ "available": True, "enabled": True, - "devices": [{"address": "00:11:22:33:44:55", "name": "Combo", "uuids": [A2DP_SINK_UUID, HID_UUID], "audio": True, "controller": True}], + "devices": [ + {"address": "00:11:22:33:44:55", "name": "Combo", "uuids": [A2DP_SINK_UUID, HID_UUID], "audio": True, "controller": True}, + {"address": "AA:BB:CC:DD:EE:FF", "name": "OBDLink MX+", "uuids": [SPP_UUID], "serial": True}, + ], }) - assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True),) + assert status.devices == ( + BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True, serial=False), + BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDLink MX+", uuids=(SPP_UUID,), audio=False, controller=False, serial=True), + ) def test_pairing_list_filters_anonymous_and_irrelevant_advertisements(): - assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False) - assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False) - assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True) - assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False) + assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False, False) + assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, False) + assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, False) + assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False, False) + assert show_pairing_device("00:11:22:33:44:55", "OBDII Adapter", False, False, False, False, False, False, False) + assert show_pairing_device("00:11:22:33:44:55", "vLinker MC", False, False, False, False, False, False, False) + assert show_pairing_device("00:11:22:33:44:55", "Vgate iCar Pro", False, False, False, False, False, False, False) + assert show_pairing_device("00:11:22:33:44:55", "Serial Dongle", False, False, False, False, False, False, True) + assert looks_like_obd_device("OBDII") + assert looks_like_obd_device("VGATE iCar") + assert looks_like_obd_device("vLinker MC+") + assert not looks_like_obd_device("Sony WH-1000XM4") def test_desktop_fake_bluetooth_is_stateful_and_interactive(monkeypatch, tmp_path): @@ -235,6 +254,44 @@ def test_pairing_agent_accept_reject_and_timeout(): assert agent.request("pin", "/device", timeout=0.01) == (False, "") +def test_profile_file_descriptor_is_converted_to_owned_socket(): + peer, source = socket.socketpair() + wrapped = FileDescriptor(os.dup(source.fileno())) + converted = _socket_from_dbus_fd(wrapped) + + peer.sendall(b"hello") + assert converted.recv(5) == b"hello" + + converted.close() + peer.close() + source.close() + + +def test_bluez_dbus_error_preserves_name_and_detail(): + class FakeRouter: + def send_and_get_reply(self, _message, timeout): + assert timeout == 3.0 + return type("Reply", (), { + "header": type("Header", (), { + "message_type": MessageType.error, + "fields": {HeaderFields.error_name: "org.bluez.Error.NotSupported"}, + })(), + "body": ("br-connection-profile-unavailable",), + })() + + connection = object.__new__(_BlueZConnection) + connection.router = FakeRouter() + + with pytest.raises(BlueZError) as exc_info: + connection._call("/org/bluez/hci0/dev_test", "org.bluez.Device1", "ConnectProfile", "s", ("uuid",), timeout=3.0) + + assert exc_info.value.error_name == "org.bluez.Error.NotSupported" + assert exc_info.value.detail == "br-connection-profile-unavailable" + assert exc_info.value.method == "org.bluez.Device1.ConnectProfile" + assert exc_info.value.path == "/org/bluez/hci0/dev_test" + assert str(exc_info.value) == "org.bluez.Error.NotSupported: br-connection-profile-unavailable" + + def test_disabled_status_does_not_start_radio_or_bluez(): params = FakeParams(IsOffroad=True, BluetoothEnabled=False) radio = FakeRadio() @@ -267,16 +324,6 @@ def test_power_pair_audio_and_offroad_enforcement(): assert not params.get_bool("BluetoothEnabled") and radio.stops == 1 and clients[0].closed -def test_power_off_preserves_saved_audio_selection(): - params = FakeParams(IsOffroad=True, BluetoothEnabled=False, BluetoothAudioAddress="00:11:22:33:44:55") - controller = BluetoothController(params, FakeBlueZ, FakeRadio()) - - controller.handle({"command": "set_power", "enabled": True}) - controller.handle({"command": "set_power", "enabled": False}) - - assert params.get("BluetoothAudioAddress") == "00:11:22:33:44:55" - - def test_status_does_not_restart_radio_during_disable(): params = FakeParams(IsOffroad=True, BluetoothEnabled=True) radio = BlockingStopRadio() diff --git a/starpilot/system/obdyssey/__init__.py b/starpilot/system/obdyssey/__init__.py new file mode 100644 index 000000000..60a7c324b --- /dev/null +++ b/starpilot/system/obdyssey/__init__.py @@ -0,0 +1,66 @@ +from openpilot.starpilot.system.obdyssey.elm327 import ( + AdapterInfo, + DiagnosticResponse, + Elm327, + ElmBusError, + ElmCommandError, + ElmContext, + ElmDisconnectedError, + ElmError, + ElmIncompleteResponseError, + ElmIsoTpError, + ElmNoDataError, + ElmPendingTimeoutError, + ElmResponseTooLargeError, + ElmStoppedError, + ElmTimeoutError, + ElmUnexpectedResponseError, + ElmUnsupportedError, + ProtocolRequirement, +) +from openpilot.starpilot.system.obdyssey.diagnostics import DiagnosticTroubleCode +from openpilot.starpilot.system.obdyssey.obdb import ( + DiagnosticCommand, + SignalDefinition, + SignalFormat, + SyntheticSignalDefinition, + VehicleProfile, +) +from openpilot.starpilot.system.obdyssey.protocol import OBDYSSEY_SOCKET_PATH, OBDysseyClient, OBDysseyStatus +from openpilot.starpilot.system.obdyssey.obdb_provider import OBDbProvider, OBDbProviderError +from openpilot.starpilot.system.obdyssey.transport import BluezSppTransport, ElmTransport, FakeElmTransport + +__all__ = [ + "AdapterInfo", + "BluezSppTransport", + "DiagnosticCommand", + "DiagnosticResponse", + "DiagnosticTroubleCode", + "Elm327", + "ElmBusError", + "ElmCommandError", + "ElmContext", + "ElmDisconnectedError", + "ElmError", + "ElmIncompleteResponseError", + "ElmIsoTpError", + "ElmNoDataError", + "ElmPendingTimeoutError", + "ElmResponseTooLargeError", + "ElmStoppedError", + "ElmTimeoutError", + "ElmUnexpectedResponseError", + "ElmTransport", + "ElmUnsupportedError", + "FakeElmTransport", + "OBDYSSEY_SOCKET_PATH", + "OBDysseyClient", + "OBDysseyStatus", + "OBDbProvider", + "OBDbProviderError", + "SignalDefinition", + "SignalFormat", + "SyntheticSignalDefinition", + "ProtocolRequirement", + "VehicleProfile", +] diff --git a/starpilot/system/obdyssey/curated_profiles/Chevrolet-Bolt-EV.json b/starpilot/system/obdyssey/curated_profiles/Chevrolet-Bolt-EV.json new file mode 100644 index 000000000..12fe2d397 --- /dev/null +++ b/starpilot/system/obdyssey/curated_profiles/Chevrolet-Bolt-EV.json @@ -0,0 +1,173 @@ +{ + "metadata": { + "id": "Chevrolet-Bolt-EV", + "name": "Chevrolet Bolt EV / EUV", + "provider": "starpilot-curated", + "revision": "v3.2.0", + "protocol": "ISO 15765-4 (CAN 11/500)", + "description": "OEM Mode 22 High-Voltage Battery & Inverter definitions for Chevrolet Bolt EV", + "override": { + "reason": "Keep validated Bolt signals on their confirmed individual request PIDs instead of the earlier grouped request assumption.", + "source": "StarPilot OBDyssey hardware validation", + "validated_vehicle_year": "Chevrolet Bolt EV/EUV, 2017-2023", + "upstream_obdb_revision": "v3.2.0", + "replaces": "Upstream or prototype grouped Bolt Mode 22 definitions where the request PID was not independently confirmed." + } + }, + "commands": [ + { + "id": "CMD_BOLT_SOC", + "hdr": "7E4", + "rax": "7EC", + "service": 34, + "pid": "8334", + "freq": 2.0, + "signals": [ + { + "id": "BOLT_HVBAT_SOC", + "name": "HV Battery State of Charge", + "path": "Battery", + "suggested_metric": "stateOfCharge", + "fmt": { + "bix": 0, + "len": 8, + "mul": 100.0, + "div": 255.0, + "unit": "%" + } + } + ] + }, + { + "id": "CMD_BOLT_VOLTAGE", + "hdr": "7E4", + "rax": "7EC", + "service": 34, + "pid": "41A3", + "freq": 2.0, + "signals": [ + { + "id": "BOLT_HVBAT_VOLTAGE", + "name": "HV Battery Pack Voltage", + "path": "Battery", + "suggested_metric": "batteryVoltage", + "fmt": { + "bix": 0, + "len": 16, + "mul": 0.01, + "div": 1.0, + "unit": "V" + } + } + ] + }, + { + "id": "CMD_BOLT_CURRENT", + "hdr": "7E4", + "rax": "7EC", + "service": 34, + "pid": "2409", + "freq": 2.0, + "signals": [ + { + "id": "BOLT_HVBAT_CURRENT", + "name": "HV Battery Pack Current", + "path": "Battery", + "suggested_metric": "batteryCurrent", + "fmt": { + "bix": 0, + "len": 16, + "sign": true, + "mul": 0.05, + "div": 1.0, + "unit": "A" + } + } + ] + }, + { + "id": "CMD_BOLT_TEMP", + "hdr": "7E4", + "rax": "7EC", + "service": 34, + "pid": "41A6", + "freq": 1.0, + "signals": [ + { + "id": "BOLT_HVBAT_TEMP", + "name": "HV Battery Average Temperature", + "path": "Battery", + "fmt": { + "bix": 0, + "len": 8, + "mul": 1.0, + "div": 1.0, + "add": -40.0, + "unit": "°C" + } + } + ] + }, + { + "id": "CMD_BOLT_MOTOR_SPEED", + "hdr": "7E2", + "rax": "7EA", + "service": 34, + "pid": "0038", + "freq": 5.0, + "signals": [ + { + "id": "BOLT_MOTOR_RPM", + "name": "Traction Motor RPM", + "path": "Motor", + "fmt": { + "bix": 0, + "len": 16, + "sign": true, + "mul": 1.0, + "div": 1.0, + "unit": "rpm" + } + } + ] + }, + { + "id": "CMD_BOLT_MOTOR_TEMP", + "hdr": "7E2", + "rax": "7EA", + "service": 34, + "pid": "4084", + "freq": 2.0, + "signals": [ + { + "id": "BOLT_MOTOR_TEMP", + "name": "Motor Temperature", + "path": "Motor", + "fmt": { + "bix": 0, + "len": 8, + "mul": 1.0, + "div": 1.0, + "add": -40.0, + "unit": "°C" + } + } + ] + } + ], + "signals": [ + { + "id": "BOLT_HVBAT_POWER", + "name": "HV Battery Power", + "path": "Battery", + "synthetic": { + "operation": "multiply", + "signals": ["BOLT_HVBAT_VOLTAGE", "BOLT_HVBAT_CURRENT"] + }, + "fmt": { + "mul": 0.001, + "unit": "kW" + } + } + ] +} diff --git a/starpilot/system/obdyssey/daemon.py b/starpilot/system/obdyssey/daemon.py new file mode 100644 index 000000000..c78a5a2c1 --- /dev/null +++ b/starpilot/system/obdyssey/daemon.py @@ -0,0 +1,1043 @@ +from __future__ import annotations + +import json +import os +import re +import socketserver +import threading +import time +from typing import Any + +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.starpilot.system.bluetooth.protocol import BluetoothClient, looks_like_obd_device +from openpilot.starpilot.system.obdyssey.diagnostics import ( + clear_dtcs, + is_mutating_service, + is_read_only_payload, + read_freeze_frame, + read_pending_dtcs, + read_permanent_dtcs, + read_stored_dtcs, + read_vin, + uds_clear_diagnostic_information, + uds_diagnostic_session_control, + uds_read_dtc_information, +) +from openpilot.starpilot.system.obdyssey.elm327 import ( + Elm327, + ElmCommandError, + ElmContext, + ElmDisconnectedError, + ElmNoDataError, + ElmUnsupportedError, + debug_logging_enabled, +) +from openpilot.starpilot.system.obdyssey.obdb import ( + calculate_synthetic_signal, + decode_signal, +) +from openpilot.starpilot.system.obdyssey.profiles import ProfileManager, parse_model_year +from openpilot.starpilot.system.obdyssey.protocol import ( + API_VERSION, + MAX_REQUEST_BYTES, + MAX_RESPONSE_BYTES, + OBDYSSEY_SOCKET_PATH, + OBDysseyStatus, +) +from openpilot.starpilot.system.obdyssey.transport import BluezSppTransport, ElmTransport + + + +def _parse_hex_value(value: Any, *, name: str, default: int | None = None, + maximum: int = 0x1FFFFFFF, required: bool = True) -> int | None: + """Parse an API hexadecimal value and reject malformed CAN addresses.""" + if value is None or (isinstance(value, str) and not value.strip()): + if default is not None or not required: + return default + raise ValueError(f"{name} is required") + if isinstance(value, bool): + raise ValueError(f"{name} must be hexadecimal") + + if isinstance(value, int): + parsed = value + else: + text = str(value).strip() + if text.lower().startswith("0x"): + text = text[2:] + if not text or re.fullmatch(r"[0-9A-Fa-f]+", text) is None: + raise ValueError(f"{name} must be hexadecimal") + parsed = int(text, 16) + + if not 0 <= parsed <= maximum: + raise ValueError(f"{name} is outside the supported CAN address range") + return parsed + + +def _parse_hex_payload(value: Any, *, name: str = "payload") -> bytes: + if isinstance(value, (bytes, bytearray)): + payload = bytes(value) + else: + try: + payload = bytes.fromhex(str(value or "")) + except ValueError as err: + raise ValueError(f"{name} must contain an even number of hexadecimal digits") from err + if not payload: + raise ValueError(f"{name} must not be empty") + return payload + + +def _parse_request_timeout(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, bool): + raise ValueError("timeout must be a positive number of seconds") + try: + timeout = float(value) + except (TypeError, ValueError) as err: + raise ValueError("timeout must be a positive number of seconds") from err + if not 0 < timeout <= 120: + raise ValueError("timeout must be between 0 and 120 seconds") + return timeout + + +MAX_SIGNAL_IDS = 4096 +MAX_SIGNAL_ID_BYTES = 256 +_BLUETOOTH_ADDRESS_RE = re.compile(r"^[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}$") + + +def _parse_signal_ids(value: Any) -> list[str]: + if not isinstance(value, list): + raise ValueError("ids must be an array") + if len(value) > MAX_SIGNAL_IDS: + raise ValueError(f"ids contains more than {MAX_SIGNAL_IDS} signals") + signal_ids: list[str] = [] + for item in value: + if not isinstance(item, str): + raise ValueError("signal ids must be strings") + signal_id = item.strip() + if not signal_id or len(signal_id.encode("utf-8")) > MAX_SIGNAL_ID_BYTES: + raise ValueError("signal ids must be non-empty and reasonably sized") + signal_ids.append(signal_id) + return signal_ids + + +def _valid_bluetooth_address(value: str) -> bool: + return _BLUETOOTH_ADDRESS_RE.fullmatch(value.strip()) is not None + + +def _mask_bluetooth_address(address: str) -> str: + """Keep adapter identity useful in logs without emitting the full MAC.""" + parts = address.strip().split(":") + if len(parts) != 6: + return "" + return ":".join((*parts[:2], "**", "**", "**", parts[-1])) + + +def _redact_adapter_error(error: Exception, address: str) -> str: + """Avoid leaking a configured MAC when a transport includes it in errors.""" + if not address: + return str(error) + masked = _mask_bluetooth_address(address) + return re.sub(re.escape(address), masked, str(error), flags=re.IGNORECASE) + + +def _parse_json_bool(value: Any, *, name: str) -> bool: + if not isinstance(value, bool): + raise ValueError(f"{name} must be a JSON boolean") + return value + + +class OBDysseyController: + def __init__(self, params: Params | None = None, transport_factory=None, profile_manager: ProfileManager | None = None, + bluetooth_client: BluetoothClient | None = None, sleep=time.sleep): + self.params = params or Params() + self._transport_factory = transport_factory + self._profile_manager = profile_manager or ProfileManager(params=self.params) + self._bluetooth_client = bluetooth_client or BluetoothClient() + self._sleep = sleep + + self._lock = threading.RLock() + self._state = "idle" + # Adapter selection is deliberately process-local. A new daemon starts + # idle and must receive an explicit UI selection (or discover one uniquely + # paired adapter) before it can connect. + self._session_adapter_address = "" + self._reconnect_suppressed = False + self._next_reconnect_at = 0.0 + self._reconnect_delay = 2.0 + self._active_address = "" + self._active_name = "" + self._transport: ElmTransport | None = None + self._elm: Elm327 | None = None + self._last_error = "" + self._last_warning = "" + self._session_state_uncertain = False + self._last_request_ms = 0 + self._requests_count = 0 + self._errors_count = 0 + self._reconnects_count = 0 + self._status_snapshot: dict[str, Any] = OBDysseyStatus( + api_version=API_VERSION, + state=self._state, + enabled=self._session_active(), + ).to_dict() + + def _session_active(self) -> bool: + """Whether this daemon session has an adapter selected.""" + return bool(self._session_adapter_address) + + def _transport_is_connected(self) -> bool | None: + """Return a transport's observable link state, if it exposes one.""" + if self._transport is None: + return False + connected = getattr(self._transport, "connected", None) + if connected is not None: + return bool(connected) + try: + return self._transport._sock is not None + except AttributeError: + # Third-party transports may not expose state; an operation will still + # surface a typed disconnect if the link is actually gone. + return None + + def _offroad(self) -> bool: + return bool(self.params.get_bool("IsOffroad")) + + def _require_offroad(self, action_name: str) -> None: + if not self._offroad(): + raise RuntimeError(f"Operation '{action_name}' is mutating and only allowed when offroad") + + def _require_profile_command_safety(self, command_id: str, service: int, session_in: int | None, session_out: int | None) -> None: + """Keep profile-defined session changes and mutating services offroad.""" + if session_in is not None or session_out is not None or is_mutating_service(service): + self._require_offroad(f"profile command '{command_id}'") + + def _record_session_restore_failure(self, command_id: str, error: Exception) -> None: + """Expose cleanup failures without replacing the original result/error.""" + self._session_state_uncertain = True + address = self._session_adapter_address or self._active_address + self._last_warning = f"Failed to restore diagnostic session for {command_id}: {_redact_adapter_error(error, address)}" + cloudlog.warning(self._last_warning) + if isinstance(error, ElmDisconnectedError): + # A dropped link cannot be restored in-place. Retain the warning, release + # the stale transport, and let the next request reconnect/reinitialize. + self._disconnect_transport() + self._session_state_uncertain = True + self._state = "reconnecting" + self._publish_status_snapshot_unlocked() + + def _build_status_unlocked(self) -> dict[str, Any]: + bt_enabled = self.params.get_bool("BluetoothEnabled") + adapter_voltage = self._elm.adapter_info.voltage if (self._elm and self._elm.adapter_info) else None + elm_identity = self._elm.adapter_info.identity if (self._elm and self._elm.adapter_info) else "" + active_protocol = self._elm.adapter_info.active_protocol if (self._elm and self._elm.adapter_info) else "" + active_prof = self._profile_manager.resolve_active_profile() + enabled = self._session_active() + diagnostic_ready = self._state == "ready" + configured_address = self._session_adapter_address + model_year = self._profile_manager.model_year() or 0 + + return OBDysseyStatus( + api_version=API_VERSION, + state=self._state, + enabled=enabled, + bluetooth_enabled=bt_enabled, + adapter_address=configured_address, + adapter_name=self._active_name, + connected=diagnostic_ready, + # Keep the published link bit honest for transports that expose a + # closed/connected property; ``None`` means the transport cannot report + # its state and is therefore still represented as present. + link_connected=self._transport_is_connected() is not False, + diagnostic_ready=diagnostic_ready, + elm_identity=elm_identity, + adapter_voltage=adapter_voltage, + protocol=active_protocol, + profile=active_prof.id, + signal_count=len(active_prof.signals), + configured_adapter=bool(configured_address), + profile_provider=active_prof.provider, + profile_id=active_prof.id, + profile_revision=active_prof.revision, + model_year=model_year, + generic_signal_count=len(self._profile_manager.load_bundled_saej1979().signals), + oem_signal_count=len(active_prof.signals) if active_prof.provider == "obdb" and active_prof.id != "saej1979" else 0, + enhanced_profile_available=self._profile_manager.enhanced_profile_available(), + last_error=self._last_error, + last_warning=self._last_warning, + last_request_ms=self._last_request_ms, + requests=self._requests_count, + errors=self._errors_count, + reconnects=self._reconnects_count, + ).to_dict() + + def _publish_status_snapshot_unlocked(self) -> None: + self._status_snapshot = self._build_status_unlocked() + + def status(self) -> dict[str, Any]: + # A connection attempt can wait on BlueZ for tens of seconds while holding + # the controller lock. Return the last published state instead of making + # status callers wait behind that I/O. + if not self._lock.acquire(blocking=False): + return dict(self._status_snapshot) + try: + if not self.params.get_bool("BluetoothEnabled") and (self._transport is not None or self._state not in {"idle", "disabled"}): + self._disconnect_transport() + self._state = "idle" + self._active_address = "" + self._active_name = "" + elif self._transport is not None and self._transport_is_connected() is False: + self._disconnect_transport() + self._state = "reconnecting" + self._active_address = "" + self._active_name = "" + self._publish_status_snapshot_unlocked() + return dict(self._status_snapshot) + finally: + self._lock.release() + + def find_candidate_devices(self) -> list[dict[str, Any]]: + """Inspect paired devices for ELM327 / OBD candidates.""" + try: + bt_status = self._bluetooth_client.status() + if not bt_status.enabled: + return [] + candidates = [] + for dev in bt_status.devices: + if dev.paired and dev.trusted and _valid_bluetooth_address(dev.address): + if dev.serial or looks_like_obd_device(dev.name): + candidates.append({ + "address": dev.address, + "name": dev.name, + "last_known": (dev.address.upper() == self._session_adapter_address.upper()), + }) + # Sort last known validated adapter first + candidates.sort(key=lambda d: not d["last_known"]) + return candidates + except Exception as err: + cloudlog.warning( + "OBDyssey error querying bluetooth status: " + + _redact_adapter_error(err, self._session_adapter_address) + ) + return [] + + def connect_adapter(self, target_address: str = "", target_name: str = "", *, allow_discovery: bool = True) -> bool: + with self._lock: + target_address = str(target_address or "").strip() + target_name = str(target_name or "").strip() + if not self.params.get_bool("BluetoothEnabled"): + self._disconnect_transport() + self._active_address = "" + self._active_name = "" + self._state = "idle" + self._last_error = "Bluetooth is disabled" + self._publish_status_snapshot_unlocked() + return False + self._state = "connecting" + self._publish_status_snapshot_unlocked() + self._disconnect_transport() + + if not target_address: + target_address = self._session_adapter_address + if not target_address: + if not allow_discovery: + self._state = "error" + self._active_address = "" + self._active_name = "" + self._last_error = "No selected OBD adapter" + self._publish_status_snapshot_unlocked() + return False + candidates = self.find_candidate_devices() + if len(candidates) == 1: + target_address = str(candidates[0]["address"]) + target_name = str(candidates[0]["name"]) + else: + self._state = "error" + self._active_address = "" + self._active_name = "" + self._last_error = "No paired OBD adapter is available" if not candidates else "Select an OBD adapter from Bluetooth settings" + self._publish_status_snapshot_unlocked() + return False + if not target_address: + self._state = "error" + self._active_address = "" + self._active_name = "" + self._last_error = "Select a paired OBD adapter before connecting" + self._publish_status_snapshot_unlocked() + return False + if not _valid_bluetooth_address(target_address): + self._state = "error" + self._active_address = "" + self._active_name = "" + self._last_error = "OBD adapter address must be a Bluetooth MAC address" + self._publish_status_snapshot_unlocked() + return False + elif not target_name: + target_name = next((candidate["name"] for candidate in self.find_candidate_devices() + if candidate["address"].upper() == target_address.upper()), "") + + self._session_adapter_address = target_address + self._reconnect_suppressed = False + self._active_address = target_address + self._active_name = target_name + self._publish_status_snapshot_unlocked() + + transport: ElmTransport | None = None + try: + if self._transport_factory is not None: + transport = self._transport_factory(target_address) + else: + transport = BluezSppTransport(target_address) + + transport.connect() + self._transport = transport + self._state = "initializing" + self._publish_status_snapshot_unlocked() + + elm = Elm327(transport) + info = elm.initialize() + self._elm = elm + self._session_state_uncertain = False + self._session_adapter_address = target_address + self._reconnect_suppressed = False + self._next_reconnect_at = 0.0 + self._reconnect_delay = 2.0 + self._state = "ready" + self._last_error = "" + self._last_warning = "" + self._publish_status_snapshot_unlocked() + cloudlog.info(f"OBDyssey connected to {_mask_bluetooth_address(target_address)} ({target_name}): {info.identity}") + return True + except Exception as err: + self._state = "error" + self._last_error = _redact_adapter_error(err, target_address) + attached_transport = transport is not None and transport is self._transport + self._disconnect_transport() + self._active_address = "" + self._active_name = "" + if transport is not None and not attached_transport: + try: + transport.close() + except Exception: + pass + self._publish_status_snapshot_unlocked() + cloudlog.error(f"OBDyssey failed connecting to {_mask_bluetooth_address(target_address)}: {self._last_error}") + return False + + def _disconnect_transport(self) -> None: + if self._transport is not None: + try: + self._transport.close() + except Exception: + pass + self._transport = None + self._elm = None + self._session_state_uncertain = False + + def disconnect(self) -> None: + with self._lock: + self._disconnect_transport() + # A deliberate disconnect ends the current session, including its + # reconnect target. The next connection is user initiated. + self._session_adapter_address = "" + self._reconnect_suppressed = True + self._state = "idle" + self._active_address = "" + self._active_name = "" + self._publish_status_snapshot_unlocked() + + def clear_adapter(self) -> None: + self._require_offroad("clear_adapter") + with self._lock: + self._disconnect_transport() + self._session_adapter_address = "" + self._reconnect_suppressed = True + self._active_address = "" + self._active_name = "" + self._state = "idle" + self._publish_status_snapshot_unlocked() + + def set_enabled(self, enabled: bool) -> None: + """Keep the legacy IPC command without restoring a persistent feature flag. + + Disabling ends the current in-memory session. Re-enabling only permits a + future explicit connection (or unique adapter discovery); it cannot revive + an address that was deliberately cleared. + """ + if not isinstance(enabled, bool): + raise TypeError("enabled must be a bool") + self._require_offroad("set_enabled") + with self._lock: + if not enabled: + self._disconnect_transport() + self._session_adapter_address = "" + self._reconnect_suppressed = True + self._state = "idle" + self._active_address = "" + self._active_name = "" + else: + self._reconnect_suppressed = False + self._state = "reconnecting" if self._session_adapter_address and self._transport is None else "idle" + self._next_reconnect_at = 0.0 + self._publish_status_snapshot_unlocked() + + def reconnect_step(self) -> None: + """Maintain only the selected adapter with bounded reconnect backoff.""" + with self._lock: + if not self.params.get_bool("BluetoothEnabled"): + if self._transport is not None or self._state not in {"idle", "disabled"}: + self._disconnect_transport() + self._state = "idle" + self._active_address = "" + self._active_name = "" + self._publish_status_snapshot_unlocked() + return + + configured = self._session_adapter_address + if self._transport is not None: + # BlueZ/socket transports do not necessarily raise until the next + # operation after a dongle disappears. Drop an observably closed + # link here so the normal bounded reconnect path can recover it. + transport_connected = self._transport_is_connected() + if transport_connected is False: + self._disconnect_transport() + self._state = "reconnecting" + self._active_address = "" + self._active_name = "" + self._publish_status_snapshot_unlocked() + else: + return + if not configured or self._reconnect_suppressed or self._transport is not None: + return + now = time.monotonic() + if now < self._next_reconnect_at: + return + self._next_reconnect_at = now + self._reconnect_delay + + # connect_adapter acquires the same reentrant lock and performs BlueZ I/O; + # call it outside the short state-update section above. + if debug_logging_enabled(): + cloudlog.event("obdyssey.reconnect_attempt", debug=True, reason="link_lost") + if self.connect_adapter(configured, allow_discovery=False): + with self._lock: + self._reconnect_delay = 2.0 + self._next_reconnect_at = 0.0 + else: + with self._lock: + # Keep the retry schedule explicit and bounded: 2s, 5s, 15s, then + # 30s indefinitely. This is easier to reason about than a floating + # multiplier and matches the user-facing lifecycle contract. + self._reconnect_delay = { + 2.0: 5.0, + 5.0: 15.0, + 15.0: 30.0, + }.get(self._reconnect_delay, 30.0) + + def maintain_loop(self) -> None: + while True: + self._sleep(2.0) + self.reconnect_step() + + def _execute_diagnostic(self, func, *args, read_only: bool = True, **kwargs) -> Any: + """Run one serialized transaction, retrying reads after one reconnect.""" + with self._lock: + if self._elm is None or self._state != "ready": + # Requests never probe arbitrary paired serial devices. A demand- + # driven retry uses only the validated address selected in this + # daemon session. + target_address = self._session_adapter_address + if not target_address: + raise RuntimeError("No active OBD adapter; select an adapter from Bluetooth settings") + if not self.connect_adapter(target_address): + raise RuntimeError(self._last_error or "OBD adapter is not connected") + + start_t = time.monotonic() + self._requests_count += 1 + reconnect_attempted = False + while True: + try: + result = func(self._elm, *args, **kwargs) + self._last_request_ms = int((time.monotonic() - start_t) * 1000) + self._last_error = "" + return result + except ElmDisconnectedError as err: + self._errors_count += 1 + self._last_error = _redact_adapter_error(err, self._session_adapter_address or self._active_address) + self._state = "reconnecting" + self._disconnect_transport() + self._publish_status_snapshot_unlocked() + if debug_logging_enabled(): + cloudlog.event( + "obdyssey.reconnect", + debug=True, + reason=type(err).__name__, + retry=not reconnect_attempted, + ) + + if read_only and not reconnect_attempted: + reconnect_attempted = True + self._reconnects_count += 1 + target_address = self._session_adapter_address + if target_address and self.connect_adapter(target_address): + continue + # A mutating request may have reached the vehicle before the link + # dropped. Report the indeterminate failure and never replay it. + raise + except Exception as err: + self._errors_count += 1 + self._last_error = _redact_adapter_error(err, self._session_adapter_address or self._active_address) + self._publish_status_snapshot_unlocked() + raise + + def diagnostic_request(self, payload: bytes, context: ElmContext | None = None, *, + read_only: bool = True, timeout: float | None = None): + """Shared request primitive for raw and structured diagnostic operations.""" + transaction_read_only = read_only and is_read_only_payload(payload) + if not transaction_read_only: + self._require_offroad("diagnostic_request (mutating)") + return self._execute_diagnostic( + lambda elm: self._diagnostic_request_unlocked(elm, payload, context, timeout=timeout), + read_only=transaction_read_only, + ) + + @staticmethod + def _diagnostic_request_unlocked(elm: Elm327, payload: bytes, context: ElmContext | None = None, + *, timeout: float | None = None): + """Issue one ELM diagnostic request while the controller lock is held.""" + response = elm.request(payload, context, timeout=timeout) + return response + + @staticmethod + def _profile_response(response, expected_prefix: bytes) -> bytes: + """Select the response matching a profile command's declared prefix.""" + if expected_prefix: + for payload in response.payloads: + if payload.startswith(expected_prefix): + return payload + elif response.payload: + return response.payload + expected = expected_prefix.hex().upper() if expected_prefix else "a diagnostic response" + raise ValueError(f"Profile command returned no response beginning with {expected}") + + @staticmethod + def _request_context(request: dict[str, Any], *, require_explicit_addresses: bool = False) -> ElmContext: + if require_explicit_addresses: + for name in ("tx_addr", "rx_addr"): + if request.get(name) is None or not str(request.get(name)).strip(): + raise ValueError(f"{name} is required for this operation") + tx_addr = _parse_hex_value(request.get("tx_addr"), name="tx_addr", default=0x7E0) + rx_addr = _parse_hex_value(request.get("rx_addr"), name="rx_addr", default=0x7E8) + proto = request.get("protocol") + return ElmContext( + protocol=str(proto) if proto else None, + tx_header=tx_addr, + rx_filter=rx_addr, + priority=_parse_hex_value(request.get("priority"), name="priority", maximum=0xFF, required=False), + response_priority=_parse_hex_value(request.get("response_priority"), name="response_priority", maximum=0xFF, required=False), + flow_control=True, + can_auto_format=True, + ) + + @staticmethod + def _response_result(response) -> dict[str, Any]: + result: dict[str, Any] = { + "response": response.payload.hex().upper(), + "lines": list(response.lines), + } + responders: list[dict[str, Any]] = [] + for response_id, payloads in response.response_groups: + for payload in payloads: + responders.append({ + "can_id": (f"{response_id:08X}" if response_id is not None and response_id > 0x7FF + else f"{response_id:03X}" if response_id is not None else None), + "payload": payload.hex().upper(), + }) + if responders: + result["responses"] = responders + return result + + def handle(self, request: dict[str, Any]) -> dict[str, Any]: + cmd = str(request.get("command", "")) + if cmd == "status": + return {"ok": True, "status": self.status()} + elif cmd == "connect": + addr = str(request.get("address", "")) + success = self.connect_adapter(addr) + result: dict[str, Any] = {"ok": success, "status": self.status()} + if not success: + result["error"] = self._last_error or "OBD adapter is not connected" + return result + elif cmd == "disconnect": + self.disconnect() + return {"ok": True} + elif cmd == "clear_adapter": + self.clear_adapter() + return {"ok": True} + elif cmd == "set_enabled": + if "enabled" not in request: + raise ValueError("enabled is required") + self.set_enabled(_parse_json_bool(request["enabled"], name="enabled")) + return {"ok": True, "status": self.status()} + elif cmd == "test_adapter": + return {"ok": True, "result": self._execute_diagnostic(lambda elm: elm.command("ATI"))} + elif cmd == "adapter_info": + with self._lock: + if self._elm and self._elm.adapter_info: + info = self._elm.adapter_info + return {"ok": True, "info": { + "identity": info.identity, + "version": info.reported_version, + "voltage": info.voltage, + "protocol": info.active_protocol, + }} + return {"ok": False, "error": "Adapter not connected"} + elif cmd == "vehicle_info": + vin = self._execute_diagnostic(read_vin) + return {"ok": True, "vin": vin} + elif cmd == "list_signals": + profile = self._profile_manager.resolve_active_profile() + sig_list = [ + { + "id": s.id, + "name": s.name, + "path": s.path, + "suggested_metric": s.suggested_metric, + "unit": s.format.unit, + "hidden": s.hidden, + "description": s.description, + } + for s in profile.signals.values() + ] + sig_list.extend({ + "id": s.id, + "name": s.name, + "path": s.path, + "suggested_metric": s.suggested_metric, + "unit": s.unit if hasattr(s, "unit") else s.format.unit, + } for s in profile.synthetic_signals.values()) + return {"ok": True, "signals": sig_list} + elif cmd == "read_signal": + sig_id = str(request.get("id", "")) + if not sig_id.strip(): + raise ValueError("id must be a non-empty signal id") + profile = self._profile_manager.resolve_active_profile() + sig_def = profile.signals.get(sig_id) + if not sig_def: + synthetic = profile.synthetic_signals.get(sig_id) + if synthetic is None: + raise KeyError(f"Signal {sig_id} not found in profile {profile.id}") + source_ids = list(getattr(synthetic, "sources", ())) + if not source_ids and isinstance(getattr(synthetic, "synthetic", None), dict): + source_ids = [str(source) for source in synthetic.synthetic.get("signals", [])] + batch = self.handle({"command": "read_signals", "ids": source_ids}) + value = calculate_synthetic_signal(synthetic, batch.get("signals", {}), strict=True) + return {"ok": True, "signal": {"id": sig_id, "value": value, + "unit": synthetic.unit if hasattr(synthetic, "unit") else synthetic.format.unit}} + + groups = ProfileManager.group_signals_by_command(profile, [sig_id], self._profile_manager.model_year()) + if not groups: + raise RuntimeError(f"No command mapped for signal {sig_id}") + + diag_cmd, sigs = groups[0] + self._require_profile_command_safety( + diag_cmd.id, + diag_cmd.service, + diag_cmd.diagnostic_session_in, + diag_cmd.diagnostic_session_out, + ) + + def _query(elm: Elm327): + if self._session_state_uncertain and diag_cmd.diagnostic_session_in is None: + raise RuntimeError("Diagnostic session state is uncertain; reconnect before this profile command") + if diag_cmd.diagnostic_session_in is not None: + uds_diagnostic_session_control(elm, diag_cmd.diagnostic_session_in, diag_cmd.context) + self._session_state_uncertain = False + try: + full_payload = bytes([diag_cmd.service]) + diag_cmd.parameter + res = self._diagnostic_request_unlocked(elm, full_payload, diag_cmd.context) + payload = self._profile_response(res, diag_cmd.expected_prefix) + val = decode_signal(payload, sig_def, strip_prefix=diag_cmd.expected_prefix) + return val + finally: + if diag_cmd.diagnostic_session_out is not None: + try: + uds_diagnostic_session_control(elm, diag_cmd.diagnostic_session_out, diag_cmd.context) + except Exception as restore_err: + self._record_session_restore_failure(diag_cmd.id, restore_err) + + command_read_only = diag_cmd.diagnostic_session_in is None and diag_cmd.diagnostic_session_out is None and not is_mutating_service(diag_cmd.service) + val = self._execute_diagnostic(_query, read_only=command_read_only) + return {"ok": True, "signal": {"id": sig_id, "value": val, "unit": sig_def.format.unit}} + elif cmd == "read_signals": + sig_ids = _parse_signal_ids(request.get("ids", [])) + profile = self._profile_manager.resolve_active_profile() + command_signal_ids = list(sig_ids) + for sig_id in sig_ids: + synthetic = profile.synthetic_signals.get(sig_id) + if synthetic is not None: + sources = getattr(synthetic, "sources", ()) + if not sources and isinstance(getattr(synthetic, "synthetic", None), dict): + sources = tuple(str(source) for source in synthetic.synthetic.get("signals", [])) + command_signal_ids.extend(str(source) for source in sources) + groups = ProfileManager.group_signals_by_command(profile, command_signal_ids, self._profile_manager.model_year()) + # Keep source values private when a synthetic signal is requested. They + # are needed to calculate the result but are not part of the caller's + # requested signal set or its API response. + decoded: dict[str, Any] = {} + errors: dict[str, dict[str, str]] = {} + + physical_ids = {signal.id for _command, signals in groups for signal in signals} + for sig_id in sig_ids: + if sig_id not in profile.signals and sig_id not in profile.synthetic_signals: + errors[sig_id] = {"type": "KeyError", "message": f"Signal {sig_id} not found in profile {profile.id}"} + elif sig_id in profile.signals and sig_id not in physical_ids: + errors[sig_id] = {"type": "Unavailable", "message": "No applicable diagnostic command for this model year"} + + # Execute each unique command through its own reconnect/retry boundary. + # A failure in command B must not replay a successful command A. + for diag_cmd, sigs in groups: + try: + self._require_profile_command_safety( + diag_cmd.id, diag_cmd.service, diag_cmd.diagnostic_session_in, diag_cmd.diagnostic_session_out, + ) + except Exception as err: + # A batch remains useful when one requested command is not permitted + # in the current safety state. Report those signals individually + # and continue with independent read-only commands. + for signal in sigs: + errors[signal.id] = {"type": type(err).__name__, "message": str(err)} + continue + + def _query_command(elm: Elm327, command=diag_cmd, command_signals=sigs): + if self._session_state_uncertain and command.diagnostic_session_in is None: + raise RuntimeError("Diagnostic session state is uncertain; reconnect before this profile command") + if command.diagnostic_session_in is not None: + uds_diagnostic_session_control(elm, command.diagnostic_session_in, command.context) + self._session_state_uncertain = False + try: + full_payload = bytes([command.service]) + command.parameter + res = self._diagnostic_request_unlocked(elm, full_payload, command.context) + payload = self._profile_response(res, command.expected_prefix) + for signal in command_signals: + try: + decoded[signal.id] = decode_signal(payload, signal, strip_prefix=command.expected_prefix) + except Exception as err: + errors[signal.id] = {"type": type(err).__name__, "message": str(err)} + finally: + if command.diagnostic_session_out is not None: + try: + uds_diagnostic_session_control(elm, command.diagnostic_session_out, command.context) + except Exception as restore_err: + self._record_session_restore_failure(command.id, restore_err) + + command_read_only = ( + diag_cmd.diagnostic_session_in is None and diag_cmd.diagnostic_session_out is None + and not is_mutating_service(diag_cmd.service) + ) + try: + self._execute_diagnostic(_query_command, read_only=command_read_only) + except Exception as err: + for signal in sigs: + if signal.id not in decoded: + errors[signal.id] = {"type": type(err).__name__, "message": str(err)} + + # Synthetic calculations never issue vehicle traffic and can depend only + # on values already obtained in this batch. + for sid in sig_ids: + if sid in profile.synthetic_signals: + try: + value = calculate_synthetic_signal(profile.synthetic_signals[sid], decoded, strict=True) + decoded[sid] = value + except Exception as err: + errors[sid] = {"type": type(err).__name__, "message": str(err)} + + results = {signal_id: decoded[signal_id] for signal_id in sig_ids if signal_id in decoded} + return {"ok": True, "signals": results, "errors": errors} + elif cmd == "read_dtcs": + dtcs = self._execute_diagnostic(read_stored_dtcs) + return {"ok": True, "dtcs": [d.to_dict() for d in dtcs]} + elif cmd == "read_pending_dtcs": + dtcs = self._execute_diagnostic(read_pending_dtcs) + return {"ok": True, "dtcs": [d.to_dict() for d in dtcs]} + elif cmd == "read_permanent_dtcs": + dtcs = self._execute_diagnostic(read_permanent_dtcs) + return {"ok": True, "dtcs": [d.to_dict() for d in dtcs]} + elif cmd == "read_all_dtcs": + # Keep each underlying mode in its own reconnect boundary. If Mode 03 + # succeeded before a link drop during Mode 07, the successful request is + # not replayed while retrying the failed mode. + dtcs = [] + for reader in (read_stored_dtcs, read_pending_dtcs, read_permanent_dtcs): + try: + dtcs.extend(self._execute_diagnostic(reader)) + except (ElmCommandError, ElmNoDataError, ElmUnsupportedError): + continue + return {"ok": True, "dtcs": [d.to_dict() for d in dtcs]} + elif cmd == "read_freeze_frame": + pid = _parse_hex_value(request.get("pid"), name="pid", default=0, maximum=0xFF) + frame = _parse_hex_value(request.get("frame"), name="frame", default=0, maximum=0xFF) + res = self._execute_diagnostic(lambda elm: read_freeze_frame(elm, pid, frame)) + return {"ok": True, **self._response_result(res)} + elif cmd == "read_uds_dtcs": + ctx = self._request_context(request, require_explicit_addresses=True) + tx_addr = ctx.tx_header if ctx.tx_header is not None else 0x7E0 + dtcs = self._execute_diagnostic(lambda elm: uds_read_dtc_information(elm, context=ctx, ecu=f"{tx_addr:03X}")) + return {"ok": True, "dtcs": [d.to_dict() for d in dtcs]} + elif cmd == "clear_dtcs": + self._require_offroad("clear_dtcs") + self._execute_diagnostic(clear_dtcs, read_only=False) + return {"ok": True} + elif cmd == "clear_uds_dtcs": + self._require_offroad("clear_uds_dtcs") + ctx = self._request_context(request, require_explicit_addresses=True) + self._execute_diagnostic(lambda elm: uds_clear_diagnostic_information(elm, context=ctx), read_only=False) + return {"ok": True} + elif cmd == "uds_request": + payload_bytes = _parse_hex_payload(request.get("payload")) + ctx = self._request_context(request) + timeout = _parse_request_timeout(request.get("timeout")) + + if not is_read_only_payload(payload_bytes): + self._require_offroad("uds_request (mutating)") + + read_only = is_read_only_payload(payload_bytes) + res = self.diagnostic_request(payload_bytes, ctx, read_only=read_only, timeout=timeout) + return {"ok": True, **self._response_result(res)} + elif cmd == "raw_request": + payload_bytes = _parse_hex_payload(request.get("payload")) + ctx = self._request_context(request) + + if not is_read_only_payload(payload_bytes): + self._require_offroad("raw_request (mutating)") + + read_only = is_read_only_payload(payload_bytes) + res = self.diagnostic_request(payload_bytes, ctx, read_only=read_only) + return {"ok": True, **self._response_result(res)} + elif cmd == "debug_at_command": + self._require_offroad("debug_at_command") + at_cmd = str(request.get("command_str") or request.get("cmd") or request.get("at_command", "")) + # An arbitrary AT command may change adapter state; never replay it + # after an ambiguous disconnect. + lines = self._execute_diagnostic(lambda elm: elm.debug_at_command(at_cmd), read_only=False) + return {"ok": True, "lines": lines} + elif cmd == "profile_status": + prof = self._profile_manager.resolve_active_profile() + return {"ok": True, "profile": prof.id, "profiles": self._profile_manager.list_profiles()} + elif cmd == "install_profile": + self._require_offroad("install_profile") + provider = str(request.get("provider", "")) + profile_id = str(request.get("profile_id", "")) + metadata = request.get("metadata") + if metadata is not None and not isinstance(metadata, dict): + raise ValueError("profile metadata must be an object") + + data = request.get("data") + if isinstance(data, dict): + raw_meta = data.get("metadata") + if not profile_id and isinstance(raw_meta, dict): + profile_id = str(raw_meta.get("id", "")) + if not profile_id: + raise ValueError("profile_id is required when profile data has no metadata id") + prof = self._profile_manager.install_profile_data(profile_id, data, metadata) + else: + source = str(request.get("repository") or request.get("source") or "") + if provider.lower() == "obdb" and source and request.get("revision"): + model_year = parse_model_year(request.get("model_year")) + prof = self._profile_manager.install_obdb_profile(source, str(request["revision"]), model_year) + else: + prof = self._profile_manager.install_profile_source( + source, profile_id=profile_id, provider=provider, metadata=metadata, + ) + return {"ok": True, "profile": prof.id, "revision": prof.revision} + elif cmd == "update_profile": + self._require_offroad("update_profile") + profile_id = str(request.get("profile_id") or self._profile_manager.resolve_active_profile().id) + if not profile_id: + raise RuntimeError("No active profile is available") + prof = self._profile_manager.update_profile(profile_id) + return {"ok": True, "profile": prof.id, "revision": prof.revision} + elif cmd == "select_profile": + self._require_offroad("select_profile") + prof_id = str(request.get("profile_id", "")) + model_year = parse_model_year(request.get("model_year")) + # A supplied year applies only to this daemon session. It is never + # copied into the global Params store. + prof = self._profile_manager.select_profile(prof_id, model_year=model_year) + return {"ok": True, "active_profile": prof.id} + elif cmd == "remove_profile": + self._require_offroad("remove_profile") + prof_id = str(request.get("profile_id", "")) + res = self._profile_manager.remove_profile(prof_id) + return {"ok": res} + else: + raise RuntimeError(f"Unknown OBDyssey command: {cmd}") + + +class OBDysseyServer(socketserver.ThreadingUnixStreamServer): + daemon_threads = True + + def __init__(self, socket_path: str, controller: OBDysseyController): + if os.path.exists(socket_path): + try: + os.unlink(socket_path) + except Exception: + pass + self.controller = controller + super().__init__(socket_path, OBDysseyHandler) + try: + os.chmod(socket_path, 0o660) + except OSError as err: + cloudlog.warning(f"Could not set OBDyssey socket permissions: {err}") + + +class OBDysseyHandler(socketserver.StreamRequestHandler): + def handle(self) -> None: + while True: + line = self.rfile.readline(MAX_REQUEST_BYTES + 1) + if not line: + break + if len(line) > MAX_REQUEST_BYTES: + res = {"ok": False, "error": "OBDyssey request is too large", "error_type": "RequestTooLarge"} + self.wfile.write(json.dumps(res, separators=(",", ":")).encode("utf-8") + b"\n") + self.wfile.flush() + break + try: + request_obj = json.loads(line.decode("utf-8")) + res = self.server.controller.handle(request_obj) + except Exception as err: + err_type = type(err).__name__ + res = {"ok": False, "error": str(err), "error_type": err_type} + + resp_bytes = json.dumps(res, separators=(",", ":")).encode("utf-8") + b"\n" + if len(resp_bytes) > MAX_RESPONSE_BYTES: + resp_bytes = json.dumps({ + "ok": False, + "error": "OBDyssey response is too large", + "error_type": "ResponseTooLarge", + }, separators=(",", ":")).encode("utf-8") + b"\n" + self.wfile.write(resp_bytes) + self.wfile.flush() + + +def main(): + cloudlog.info("Starting OBDyssey daemon (obdysseyd)...") + controller = OBDysseyController() + threading.Thread(target=controller.maintain_loop, daemon=True).start() + + server = OBDysseyServer(OBDYSSEY_SOCKET_PATH, controller) + try: + server.serve_forever() + except (KeyboardInterrupt, SystemExit): + pass + finally: + server.server_close() + if os.path.exists(OBDYSSEY_SOCKET_PATH): + try: + os.unlink(OBDYSSEY_SOCKET_PATH) + except Exception: + pass + + +if __name__ == "__main__": + main() diff --git a/starpilot/system/obdyssey/diagnostics.py b/starpilot/system/obdyssey/diagnostics.py new file mode 100644 index 000000000..03ab7ee3b --- /dev/null +++ b/starpilot/system/obdyssey/diagnostics.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +# Re-export the openDBC service vocabularies from this module. Existing +# callers use these enums when constructing helper requests, and keeping the +# names here avoids coupling the IPC/diagnostic API to openDBC import details. +from opendbc.car.uds import ( + ACCESS_TYPE, + DTC_REPORT_TYPE, + RESET_TYPE, + ROUTINE_CONTROL_TYPE, + SERVICE_TYPE, + SESSION_TYPE, +) + +if TYPE_CHECKING: + from openpilot.starpilot.system.obdyssey.elm327 import Elm327, ElmContext, DiagnosticResponse + + +UDS_NRC_DESCRIPTIONS: dict[int, str] = { + 0x10: "General Reject", + 0x11: "Service Not Supported", + 0x12: "Sub-function Not Supported", + 0x13: "Incorrect Message Length Or Invalid Format", + 0x14: "Response Too Long", + 0x21: "Busy Repeat Request", + 0x22: "Conditions Not Correct", + 0x24: "Request Sequence Error", + 0x25: "No Response From Subnet Component", + 0x26: "Failure Prevents Execution Of Requested Action", + 0x31: "Request Out Of Range", + 0x33: "Security Access Denied", + 0x35: "Invalid Key", + 0x36: "Exceed Number Of Attempts", + 0x37: "Required Time Delay Not Expired", + 0x70: "Upload Download Not Accepted", + 0x71: "Transfer Data Suspended", + 0x72: "General Programming Failure", + 0x73: "Wrong Block Sequence Counter", + 0x78: "Response Pending", + 0x7E: "Sub-function Not Supported In Active Session", + 0x7F: "Service Not Supported In Active Session", +} + + +class UdsNegativeResponseError(Exception): + def __init__(self, service_id: int, nrc: int): + self.service_id = service_id + self.nrc = nrc + desc = UDS_NRC_DESCRIPTIONS.get(nrc, f"NRC 0x{nrc:02X}") + super().__init__(f"UDS Negative Response: Service 0x{service_id:02X} failed with {desc} (0x{nrc:02X})") + + +class UdsResponsePendingError(Exception): + """The ECU asked the tester to wait before returning the final response.""" + + def __init__(self, service_id: int): + self.service_id = service_id + super().__init__(f"UDS response pending for service 0x{service_id:02X}") + + +class UdsDtcParseError(ValueError): + """A ReadDTCInformation response has an unsupported or incomplete layout.""" + + +@dataclass(frozen=True) +class DiagnosticTroubleCode: + code: str + ecu: str | None = None + status: int | None = None + source: str = "OBD" + description: str | None = None + raw: bytes = b"" + raw_code: int | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "ecu": self.ecu, + "status": self.status, + "source": self.source, + "description": self.description, + "raw": self.raw.hex().upper() if self.raw else "", + "raw_code": self.raw_code, + } + + +def parse_standard_dtcs(data: bytes, source: str = "OBD") -> list[DiagnosticTroubleCode]: + """Parse 2-byte standard SAE J1979 DTCs (e.g. from Mode 03, 07, 0A).""" + dtcs: list[DiagnosticTroubleCode] = [] + prefix_map = {0: "P", 1: "C", 2: "B", 3: "U"} + + # Payload starts after response service byte (e.g. 0x43, 0x47, 0x4A) + payload = data[1:] if len(data) > 0 and data[0] in (0x43, 0x47, 0x4A) else data + + for i in range(0, len(payload) - 1, 2): + b0, b1 = payload[i], payload[i + 1] + if b0 == 0 and b1 == 0: + continue # padding / no DTC + prefix = prefix_map.get((b0 >> 6) & 0x03, "P") + d1 = (b0 >> 4) & 0x03 + d2 = b0 & 0x0F + d3 = (b1 >> 4) & 0x0F + d4 = b1 & 0x0F + code = f"{prefix}{d1}{d2:X}{d3:X}{d4:X}" + dtcs.append(DiagnosticTroubleCode( + code=code, + source=source, + raw=bytes([b0, b1]), + )) + return dtcs + + +def parse_uds_dtcs(data: bytes, ecu: str | None = None) -> list[DiagnosticTroubleCode]: + """Parse UDS 0x19/0x02 records (24-bit DTC identifier + status byte).""" + dtcs: list[DiagnosticTroubleCode] = [] + if len(data) < 3 or data[0] != 0x59: + raise UdsDtcParseError("UDS DTC response must begin with positive service 0x59") + if data[1] != int(DTC_REPORT_TYPE.DTC_BY_STATUS_MASK): + raise UdsDtcParseError(f"Unsupported UDS DTC report subfunction 0x{data[1]:02X}") + + records = data[3:] # 59, reportType, statusAvailabilityMask + if len(records) % 4: + raise UdsDtcParseError( + f"UDS reportDTCByStatusMask has {len(records)} trailing bytes; records are four bytes" + ) + for i in range(0, len(records), 4): + dtc_bytes = records[i:i + 3] + status = records[i + 3] + raw_code = int.from_bytes(dtc_bytes, "big") + if raw_code == 0: + continue + # UDS identifiers are not SAE P/C/B/U codes. Preserve the exact 24-bit + # identifier and expose a stable six-hex-digit display string. + code = f"{raw_code:06X}" + dtcs.append(DiagnosticTroubleCode( + code=code, + ecu=ecu, + status=status, + source="uds", + raw=bytes(dtc_bytes + bytes([status])), + raw_code=raw_code, + )) + return dtcs + + +# Standard OBD-II Functions + +from openpilot.starpilot.system.obdyssey.elm327 import ElmCommandError, ElmContext, ElmDisconnectedError, ElmNoDataError, ElmUnsupportedError + +__all__ = [ + "ACCESS_TYPE", + "DTC_REPORT_TYPE", + "RESET_TYPE", + "ROUTINE_CONTROL_TYPE", + "SERVICE_TYPE", + "SESSION_TYPE", + "DiagnosticTroubleCode", + "UdsDtcParseError", + "UdsNegativeResponseError", + "UdsResponsePendingError", + "parse_standard_dtcs", + "parse_uds_dtcs", +] + +# 0x7DF is a functional request. Leave CRA at the adapter default so every +# standard OBD responder (7E8..7EF) remains eligible to answer. +DEFAULT_OBD_CONTEXT = ElmContext(tx_header=0x7DF, flow_control=True, can_auto_format=True) + + +def _obd_payloads(response: DiagnosticResponse, service: int, pid: int | None = None) -> tuple[bytes, ...]: + expected_service = (service + 0x40) & 0xFF + payloads = tuple( + payload for payload in response.payloads + if payload and payload[0] == expected_service and (pid is None or len(payload) > 1 and payload[1] == pid) + ) + if not payloads: + if response.pending: + raise UdsResponsePendingError(service) + received = response.payload[0] if response.payload else None + expected = f"0x{expected_service:02X}" + (f" PID 0x{pid:02X}" if pid is not None else "") + raise ValueError(f"Unexpected OBD response {received!r}; expected {expected}") + return payloads + + +def read_current_data(elm: Elm327, pid: int, context: ElmContext | None = None) -> DiagnosticResponse: + """Mode 01: Read current powertrain diagnostic data.""" + ctx = context if context is not None else DEFAULT_OBD_CONTEXT + response = elm.request(bytes([0x01, pid & 0xFF]), ctx, retry=True) + _obd_payloads(response, 0x01, pid & 0xFF) + return response + + +def read_freeze_frame(elm: Elm327, pid: int, frame: int = 0, context: ElmContext | None = None) -> DiagnosticResponse: + """Mode 02: Read freeze frame data.""" + ctx = context if context is not None else DEFAULT_OBD_CONTEXT + response = elm.request(bytes([0x02, pid & 0xFF, frame & 0xFF]), ctx, retry=True) + _obd_payloads(response, 0x02, pid & 0xFF) + return response + + +def read_stored_dtcs(elm: Elm327, context: ElmContext | None = None) -> list[DiagnosticTroubleCode]: + """Mode 03: Read confirmed/stored emission-related DTCs.""" + ctx = context if context is not None else DEFAULT_OBD_CONTEXT + res = elm.request(bytes([0x03]), ctx, retry=True) + return [dtc for payload in _obd_payloads(res, 0x03) for dtc in parse_standard_dtcs(payload, source="OBD_STORED")] + + +def read_pending_dtcs(elm: Elm327, context: ElmContext | None = None) -> list[DiagnosticTroubleCode]: + """Mode 07: Read pending DTCs detected during current/last drive cycle.""" + ctx = context if context is not None else DEFAULT_OBD_CONTEXT + res = elm.request(bytes([0x07]), ctx, retry=True) + return [dtc for payload in _obd_payloads(res, 0x07) for dtc in parse_standard_dtcs(payload, source="OBD_PENDING")] + + +def read_permanent_dtcs(elm: Elm327, context: ElmContext | None = None) -> list[DiagnosticTroubleCode]: + """Mode 0A: Read permanent DTCs.""" + ctx = context if context is not None else DEFAULT_OBD_CONTEXT + res = elm.request(bytes([0x0A]), ctx, retry=True) + return [dtc for payload in _obd_payloads(res, 0x0A) for dtc in parse_standard_dtcs(payload, source="OBD_PERMANENT")] + + +def read_all_dtcs(elm: Elm327, context: ElmContext | None = None) -> list[DiagnosticTroubleCode]: + """Read confirmed (03), pending (07), and permanent (0A) DTCs.""" + results: list[DiagnosticTroubleCode] = [] + for func in (read_stored_dtcs, read_pending_dtcs, read_permanent_dtcs): + try: + results.extend(func(elm, context)) + except (ElmCommandError, ElmNoDataError, ElmUnsupportedError): + pass + except ElmDisconnectedError: + # The controller owns reconnects. Do not turn a dropped link into an + # apparently successful empty scan by swallowing this exception. + raise + return results + + +def clear_dtcs(elm: Elm327, context: ElmContext | None = None) -> DiagnosticResponse: + """Mode 04: Clear diagnostic trouble codes and reset MIL (Check Engine Light). Mutating!""" + ctx = context if context is not None else DEFAULT_OBD_CONTEXT + response = elm.request(bytes([0x04]), ctx, retry=False) + _obd_payloads(response, 0x04) + return response + + +def read_vin(elm: Elm327, context: ElmContext | None = None) -> str: + """Mode 09 PID 02: Read Vehicle Identification Number (VIN).""" + ctx = context if context is not None else DEFAULT_OBD_CONTEXT + res = elm.request(bytes([0x09, 0x02]), ctx, retry=True) + payloads = _obd_payloads(res, 0x09, 0x02) + + def fragment(payload: bytes) -> tuple[int | None, str]: + # Response format: 49 02 [data item/line number] followed by ASCII VIN. + if len(payload) >= 3 and payload[:2] == bytes([0x49, 0x02]): + return payload[2], "".join(chr(b) for b in payload[3:] if 32 <= b <= 126) + return None, "".join(chr(b) for b in payload if 32 <= b <= 126) + + def complete_vin(group: tuple[bytes, ...]) -> str | None: + fragments = [fragment(payload) for payload in group] + for _sequence, text in fragments: + vin_match = re.search(r"([A-HJ-NPR-Z0-9]{17})", text) + if vin_match: + return vin_match.group(1) + + indexed = [(sequence, text) for sequence, text in fragments if sequence is not None] + if indexed and len({sequence for sequence, _text in indexed}) == len(indexed): + joined = "".join(text for _sequence, text in sorted(indexed)) + vin_match = re.search(r"([A-HJ-NPR-Z0-9]{17})", joined) + if vin_match: + return vin_match.group(1) + elif len(group) > 1 and not indexed: + # Multiple frames from one identified responder can omit line indexes. + joined = "".join(text for _sequence, text in fragments) + vin_match = re.search(r"([A-HJ-NPR-Z0-9]{17})", joined) + if vin_match: + return vin_match.group(1) + return None + + # Prefer a complete VIN from one responder. Headered functional responses + # are grouped by CAN ID by Elm327, so fragments from unrelated ECUs cannot + # be combined into a fabricated VIN. + groups = res.response_groups or ((None, payloads),) + for _response_id, group in groups: + group_payloads = tuple(payload for payload in group if payload and payload[0] == 0x49 and + len(payload) > 1 and payload[1] == 0x02) + if not group_payloads: + continue + vin = complete_vin(group_payloads) + if vin: + return vin + + # Headerless adapters do not expose responder IDs. Only combine their + # indexed VIN fragments when every sequence number is unique; duplicate + # indexes indicate interleaved responders and are intentionally rejected. + headerless = [payload for response_id, group in groups if response_id is None for payload in group + if payload and payload[:2] == bytes([0x49, 0x02])] + if headerless: + indexed = [fragment(payload) for payload in headerless] + if all(sequence is not None for sequence, _text in indexed) and \ + len({sequence for sequence, _text in indexed}) == len(indexed): + joined = "".join(text for _sequence, text in sorted(indexed)) + vin_match = re.search(r"([A-HJ-NPR-Z0-9]{17})", joined) + if vin_match: + return vin_match.group(1) + return "" + + +# UDS (ISO 14229) Service Functions + +def _uds_payloads(response: DiagnosticResponse, service: int, *, minimum_length: int = 1) -> tuple[bytes, ...]: + """Return matching positive UDS responses while preserving responders.""" + expected_service = (service + 0x40) & 0xFF + payloads = tuple( + payload for payload in response.payloads + if len(payload) >= minimum_length and payload[0] == expected_service + ) + if not payloads: + if response.pending: + raise UdsResponsePendingError(service) + received = response.payload[0] if response.payload else None + raise ValueError(f"Unexpected UDS response service {received!r}; expected 0x{expected_service:02X}") + return payloads + +def uds_read_data_by_identifier(elm: Elm327, did: int, context: ElmContext | None = None) -> bytes: + """UDS Service 0x22: ReadDataByIdentifier.""" + payload = bytes([SERVICE_TYPE.READ_DATA_BY_IDENTIFIER, (did >> 8) & 0xFF, did & 0xFF]) + res = elm.request(payload, context, retry=True) + data = _uds_payloads(res, SERVICE_TYPE.READ_DATA_BY_IDENTIFIER, minimum_length=3)[0] + + # Positive response: 0x62 + resp_did = (data[1] << 8) | data[2] + if resp_did != did: + raise ValueError(f"Unexpected UDS DID 0x{resp_did:04X}; expected 0x{did:04X}") + return data[3:] + + +def uds_write_data_by_identifier(elm: Elm327, did: int, data: bytes, context: ElmContext | None = None) -> bytes: + """UDS Service 0x2E: WriteDataByIdentifier. Mutating!""" + payload = bytes([SERVICE_TYPE.WRITE_DATA_BY_IDENTIFIER, (did >> 8) & 0xFF, did & 0xFF]) + data + res = elm.request(payload, context, retry=False) + response = _uds_payloads(res, SERVICE_TYPE.WRITE_DATA_BY_IDENTIFIER, minimum_length=3)[0] + resp_did = (response[1] << 8) | response[2] + if resp_did != did: + raise ValueError(f"Unexpected UDS DID 0x{resp_did:04X}; expected 0x{did:04X}") + return response + + +def uds_diagnostic_session_control(elm: Elm327, session_type: int, context: ElmContext | None = None) -> bytes: + """UDS Service 0x10: DiagnosticSessionControl.""" + payload = bytes([SERVICE_TYPE.DIAGNOSTIC_SESSION_CONTROL, session_type & 0xFF]) + res = elm.request(payload, context, retry=False) + response = _uds_payloads(res, SERVICE_TYPE.DIAGNOSTIC_SESSION_CONTROL, minimum_length=2)[0] + if response[1] != (session_type & 0xFF): + raise ValueError(f"Unexpected UDS session 0x{response[1]:02X}; expected 0x{session_type & 0xFF:02X}") + return response + + +def uds_ecu_reset(elm: Elm327, reset_type: int, context: ElmContext | None = None) -> bytes: + """UDS Service 0x11: ECUReset. Mutating!""" + payload = bytes([SERVICE_TYPE.ECU_RESET, reset_type & 0xFF]) + res = elm.request(payload, context, retry=False) + response = _uds_payloads(res, SERVICE_TYPE.ECU_RESET, minimum_length=2)[0] + if response[1] != (reset_type & 0xFF): + raise ValueError(f"Unexpected UDS reset type 0x{response[1]:02X}; expected 0x{reset_type & 0xFF:02X}") + return response + + +def uds_read_dtc_information(elm: Elm327, report_type: int = DTC_REPORT_TYPE.DTC_BY_STATUS_MASK, + status_mask: int = 0xFF, context: ElmContext | None = None, + ecu: str | None = None) -> list[DiagnosticTroubleCode]: + """UDS Service 0x19: ReadDTCInformation.""" + if int(report_type) != int(DTC_REPORT_TYPE.DTC_BY_STATUS_MASK): + raise UdsDtcParseError(f"Unsupported UDS DTC report subfunction 0x{int(report_type):02X}") + payload = bytes([SERVICE_TYPE.READ_DTC_INFORMATION, report_type & 0xFF, status_mask & 0xFF]) + res = elm.request(payload, context, retry=True) + return [dtc for payload in _uds_payloads(res, SERVICE_TYPE.READ_DTC_INFORMATION, minimum_length=3) + for dtc in parse_uds_dtcs(payload, ecu=ecu)] + + +def uds_clear_diagnostic_information(elm: Elm327, group: int = 0xFFFFFF, context: ElmContext | None = None) -> bytes: + """UDS Service 0x14: ClearDiagnosticInformation. Mutating!""" + payload = bytes([ + SERVICE_TYPE.CLEAR_DIAGNOSTIC_INFORMATION, + (group >> 16) & 0xFF, + (group >> 8) & 0xFF, + group & 0xFF, + ]) + res = elm.request(payload, context, retry=False) + return _uds_payloads(res, SERVICE_TYPE.CLEAR_DIAGNOSTIC_INFORMATION)[0] + + +def uds_security_access(elm: Elm327, access_type: int, key_data: bytes = b"", context: ElmContext | None = None) -> bytes: + """UDS Service 0x27: SecurityAccess (Request Seed or Send Key).""" + payload = bytes([SERVICE_TYPE.SECURITY_ACCESS, access_type & 0xFF]) + key_data + # Both seed requests and key submissions are stateful. The controller's + # safety boundary classifies every 0x27 request as offroad-only, and this + # helper must not opt a seed request into replay behavior either. + res = elm.request(payload, context, retry=False) + response = _uds_payloads(res, SERVICE_TYPE.SECURITY_ACCESS, minimum_length=2)[0] + if response[1] != (access_type & 0xFF): + raise ValueError(f"Unexpected UDS access type 0x{response[1]:02X}; expected 0x{access_type & 0xFF:02X}") + return response + + +def uds_routine_control(elm: Elm327, routine_type: int, routine_id: int, option_record: bytes = b"", context: ElmContext | None = None) -> bytes: + """UDS Service 0x31: RoutineControl. Mutating!""" + payload = bytes([ + SERVICE_TYPE.ROUTINE_CONTROL, + routine_type & 0xFF, + (routine_id >> 8) & 0xFF, + routine_id & 0xFF, + ]) + option_record + res = elm.request(payload, context, retry=False) + response = _uds_payloads(res, SERVICE_TYPE.ROUTINE_CONTROL, minimum_length=4)[0] + if response[1:4] != bytes([routine_type & 0xFF, (routine_id >> 8) & 0xFF, routine_id & 0xFF]): + raise ValueError("UDS routine response does not match the requested routine") + return response + + +def uds_input_output_control(elm: Elm327, did: int, control_option: int, control_state: bytes = b"", context: ElmContext | None = None) -> bytes: + """UDS Service 0x2F: InputOutputControlByIdentifier. Mutating!""" + payload = bytes([ + SERVICE_TYPE.INPUT_OUTPUT_CONTROL_BY_IDENTIFIER, + (did >> 8) & 0xFF, + did & 0xFF, + control_option & 0xFF, + ]) + control_state + res = elm.request(payload, context, retry=False) + response = _uds_payloads(res, SERVICE_TYPE.INPUT_OUTPUT_CONTROL_BY_IDENTIFIER, minimum_length=4)[0] + if response[1:3] != bytes([(did >> 8) & 0xFF, did & 0xFF]): + raise ValueError(f"Unexpected UDS DID in IO response for 0x{did:04X}") + return response + + +def uds_tester_present(elm: Elm327, subfunction: int = 0x00, context: ElmContext | None = None) -> bytes: + """UDS Service 0x3E: TesterPresent.""" + payload = bytes([SERVICE_TYPE.TESTER_PRESENT, subfunction & 0xFF]) + # TesterPresent keeps the ECU diagnostic session alive and is therefore + # stateful/offroad, even though it does not write a DID. + res = elm.request(payload, context, retry=False) + response = _uds_payloads(res, SERVICE_TYPE.TESTER_PRESENT, minimum_length=2)[0] + if response[1] != (subfunction & 0xFF): + raise ValueError(f"Unexpected UDS tester-present subfunction 0x{response[1]:02X}") + return response + + +# Safety Classification Helpers + +READ_ONLY_SERVICES = { + 0x01, 0x02, 0x03, 0x07, 0x09, 0x0A, 0x21, + SERVICE_TYPE.READ_DTC_INFORMATION, + SERVICE_TYPE.READ_DATA_BY_IDENTIFIER, + SERVICE_TYPE.READ_MEMORY_BY_ADDRESS, + SERVICE_TYPE.READ_SCALING_DATA_BY_IDENTIFIER, +} + +MUTATING_SERVICES = { + 0x04, + SERVICE_TYPE.DIAGNOSTIC_SESSION_CONTROL, + SERVICE_TYPE.ECU_RESET, + SERVICE_TYPE.CLEAR_DIAGNOSTIC_INFORMATION, + SERVICE_TYPE.SECURITY_ACCESS, + SERVICE_TYPE.WRITE_DATA_BY_IDENTIFIER, + SERVICE_TYPE.INPUT_OUTPUT_CONTROL_BY_IDENTIFIER, + SERVICE_TYPE.ROUTINE_CONTROL, + SERVICE_TYPE.REQUEST_DOWNLOAD, + SERVICE_TYPE.REQUEST_UPLOAD, + SERVICE_TYPE.TRANSFER_DATA, + SERVICE_TYPE.REQUEST_TRANSFER_EXIT, + SERVICE_TYPE.WRITE_MEMORY_BY_ADDRESS, + SERVICE_TYPE.TESTER_PRESENT, +} + + +def is_read_only_service(service: int) -> bool: + return service in READ_ONLY_SERVICES + + +def is_mutating_service(service: int) -> bool: + # Unknown services are deliberately treated as mutating at the safety + # boundary. A raw request must opt into a known read-only service. + return service not in READ_ONLY_SERVICES + + +def is_read_only_payload(payload: bytes) -> bool: + if not payload: + return False + # SecurityAccess is intentionally absent from READ_ONLY_SERVICES: even a + # seed request changes the ECU's security state and must stay offroad. + return is_read_only_service(payload[0]) diff --git a/starpilot/system/obdyssey/elm327.py b/starpilot/system/obdyssey/elm327.py new file mode 100644 index 000000000..682f10e06 --- /dev/null +++ b/starpilot/system/obdyssey/elm327.py @@ -0,0 +1,1030 @@ +from __future__ import annotations + +import os +import re +import time +from dataclasses import dataclass +from enum import Enum +from openpilot.common.swaglog import cloudlog +from openpilot.starpilot.system.obdyssey.transport import ElmTransport + + +class ElmError(Exception): + """Base exception for ELM327 operations.""" + + +class ElmTimeoutError(ElmError): + """ELM327 command timed out waiting for prompt.""" + + +class ElmDisconnectedError(ElmError): + """ELM327 transport connection lost.""" + + +class ElmCommandError(ElmError): + """ELM327 returned unrecognized command or syntax error.""" + + +class ElmNoDataError(ElmError): + """Vehicle or ECU returned NO DATA.""" + + +class ElmBusError(ElmError): + """CAN error, bus initialization error, or unable to connect.""" + + +class ElmUnsupportedError(ElmError): + """Requested protocol or feature is not supported by adapter.""" + + +class ElmResponseTooLargeError(ElmError): + """The adapter returned more data than the bounded transaction buffer.""" + + +class ElmStoppedError(ElmError): + """The adapter stopped an operation before a complete response was received.""" + + +class ElmIncompleteResponseError(ElmError): + """A response ended before the declared diagnostic payload was complete.""" + + +class ElmIsoTpError(ElmError): + """An ISO-TP response was malformed, out of order, or inconsistent.""" + + +class ElmUnexpectedResponseError(ElmError): + """The adapter returned a complete frame for a different service.""" + + +class ElmPendingTimeoutError(ElmTimeoutError): + """A UDS response remained pending until its P2* deadline expired.""" + + +class ProtocolRequirement(str, Enum): + """Protocol information OBDb can express without guessing an ELM variant.""" + + AUTO = "AUTO" + ISO9141_2 = "ISO9141_2" + KWP14230 = "KWP14230" + CAN_11BIT = "CAN_11BIT" + CAN_29BIT = "CAN_29BIT" + + +@dataclass(frozen=True) +class AdapterInfo: + identity: str = "" + reported_version: str = "" + voltage: float | None = None + active_protocol: str = "" + capabilities: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ElmContext: + # ``str`` remains accepted for developer/raw callers and old integrations; + # native OBDb commands use ProtocolRequirement so an upstream label can + # never accidentally be concatenated into ATSP. + protocol: ProtocolRequirement | str | None = None + exact_protocol: str | None = None + tx_header: int | None = None + rx_filter: int | None = None + priority: int | None = None + response_priority: int | None = None + extended_address: int | None = None + tester_address: int | None = None + timeout: int | None = None + # ``flow_control`` is retained as an explicit developer/raw override for + # the ELM CFC switch. Native OBDb must use ``flow_control_mode1`` instead: + # fcm1 selects the adapter's custom ISO-TP flow-control mode and must never + # disable CFC by implication. + flow_control: bool | None = None + can_auto_format: bool | None = None + flow_control_mode1: bool = False + + +@dataclass(frozen=True) +class DiagnosticResponse: + raw: bytes + payload: bytes + lines: tuple[str, ...] = () + service: int | None = None + responses: tuple[bytes, ...] = () + response_groups: tuple[tuple[int | None, tuple[bytes, ...]], ...] = () + pending: bool = False + + @property + def payloads(self) -> tuple[bytes, ...]: + """Return parsed responder payloads, including compatibility instances.""" + return self.responses or ((self.payload,) if self.payload else ()) + + +PROTOCOL_MAP: dict[str, str] = { + "AUTO": "0", + "0": "0", + "SAE J1850 PWM": "1", + "1": "1", + "SAE J1850 VPW": "2", + "2": "2", + "ISO 9141-2": "3", + "ISO9141_2": "3", + "3": "3", + "ISO 14230-4 (KWP 5BAUD)": "4", + "4": "4", + "ISO 14230-4 (KWP FAST)": "5", + "5": "5", + "KWP14230": "0", + "CAN_11BIT": "0", + "CAN_29BIT": "0", + # Native OBDb labels are a defensive compatibility path for raw callers. + # They still resolve to real ELM protocol codes and are never concatenated + # into ATSP. + "9141-2": "3", + "14230": "0", + "15765-4-11BIT": "0", + "15765-4-29BIT": "0", + "ISO 15765-4 (CAN 11/500)": "6", + "CAN 11/500": "6", + "CAN_11_500": "6", + "15765-4-11BIT-500K": "6", + "6": "6", + "ISO 15765-4 (CAN 29/500)": "7", + "CAN 29/500": "7", + "CAN_29_500": "7", + "15765-4-29BIT-500K": "7", + "7": "7", + "ISO 15765-4 (CAN 11/250)": "8", + "CAN 11/250": "8", + "CAN_11_250": "8", + "15765-4-11BIT-250K": "8", + "8": "8", + "ISO 15765-4 (CAN 29/250)": "9", + "CAN 29/250": "9", + "CAN_29_250": "9", + "15765-4-29BIT-250K": "9", + "9": "9", + "SAE J1939 (CAN 29/250)": "A", + "A": "A", +} + +IGNORE_PATTERNS = { + "SEARCHING...", + "BUS INIT...", + "BUS INIT: OK", + "...", +} + +ERROR_RESPONSES = { + "CAN ERROR": ElmBusError, + "BUS ERROR": ElmBusError, + "UNABLE TO CONNECT": ElmBusError, + "BUS INIT: ERROR": ElmBusError, + "FB ERROR": ElmBusError, + "BUFFER FULL": ElmBusError, + "?": ElmCommandError, + "NO DATA": ElmNoDataError, +} + +DEFAULT_PROTOCOL = "0" +DEFAULT_TIMEOUT = 0x32 +DEFAULT_PENDING_TIMEOUT = 5.0 +DEFAULT_FLOW_CONTROL = True +DEFAULT_CAN_AUTO_FORMAT = True + + +def _env_flag(name: str) -> bool: + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def debug_logging_enabled() -> bool: + """Return whether explicit OBDyssey field logging was requested.""" + return _env_flag("OBDYSSEY_DEBUG") + + +class Elm327: + def __init__(self, transport: ElmTransport, default_timeout: float = 3.0, + max_response_size: int = 64 * 1024, *, debug: bool | None = None, + debug_vin: bool | None = None): + if max_response_size <= 0: + raise ValueError("max_response_size must be positive") + self.transport = transport + self.default_timeout = default_timeout + self.adapter_info: AdapterInfo | None = None + self.active_context: ElmContext | None = None + # Capability discovery is intentionally session-local. ``None`` means + # that the adapter has not answered the optional flow-control probe yet. + self.adapter_capabilities: dict[str, bool | None] = {"fc_mode1": None} + # Until initialize() or an explicit baseline pass succeeds, adapter state + # is unknown even when the Python cache is empty. + self._context_unknown = True + self._max_buffer_size = max_response_size + self.debug = debug_logging_enabled() if debug is None else debug + self.debug_vin = _env_flag("OBDYSSEY_DEBUG_VIN") if debug_vin is None else debug_vin + + def _debug_event(self, event: str, **fields: object) -> None: + if self.debug: + cloudlog.event(f"obdyssey.{event}", debug=True, **fields) + + def _debug_raw_text(self, raw: bytes, command: str) -> str: + if command.replace(" ", "").upper() in {"0902", "22F190"} and not self.debug_vin: + return "" + return repr(raw.decode("ascii", errors="replace")) + + def _debug_context_fields(self, context: ElmContext) -> dict[str, object]: + tx_is_29, tx_priority, tx_lower = self._effective_tx_address(context) + effective_tx = None + if tx_lower is not None: + effective_tx = f"{((tx_priority << 24) | tx_lower):08X}" if tx_is_29 else f"{tx_lower:03X}" + effective_rx = self._effective_rx_address(context, tx_priority if tx_is_29 else None) + return { + "effective_tx": effective_tx, + "effective_rx_filter": ( + None if effective_rx is None else f"{effective_rx:08X}" if tx_is_29 else f"{effective_rx:03X}" + ), + "protocol": self._protocol_code(context), + "flow_control_mode": int(context.flow_control_mode1), + } + + def initialize(self) -> AdapterInfo: + self.reset_context() + self.adapter_capabilities["fc_mode1"] = None + + # Base compatibility initialization sequence + self.command("ATZ", timeout=3.0) + time.sleep(0.05) + # Establish the same documented baseline used whenever state becomes + # uncertain. ATZ is an adapter reset; ATD is the documented ELM command + # for restoring custom headers, filters, timers, and protocol settings. + # Keeping this explicit makes the cache honest across real adapters and + # clones instead of relying on whichever defaults ATZ happened to leave. + self._restore_baseline() + + # Identify and get voltage + identity_lines = self.command("ATI", timeout=2.0) + identity = " ".join(identity_lines).strip() + if not identity: + raise ElmUnsupportedError("Adapter did not return an identity for ATI") + + try: + voltage_lines = self.command("ATRV", timeout=2.0) + except (ElmCommandError, ElmNoDataError, ElmTimeoutError): + voltage_lines = [] + voltage_str = " ".join(voltage_lines).strip() + voltage = None + volt_match = re.search(r"(\d+(?:\.\d+)?)V?", voltage_str, re.IGNORECASE) + if volt_match: + try: + voltage = float(volt_match.group(1)) + except ValueError: + pass + + # Discover active protocol + protocol = "" + try: + proto_lines = self.command("ATDP", timeout=2.0) + protocol = " ".join(proto_lines).strip() + except (ElmCommandError, ElmNoDataError, ElmTimeoutError): + pass + + self.adapter_info = AdapterInfo( + identity=identity, + reported_version=identity, + voltage=voltage, + active_protocol=protocol, + ) + # The baseline is known, but no diagnostic context has been selected yet. + self.active_context = None + self._context_unknown = False + return self.adapter_info + + def reset_context(self) -> None: + self.active_context = None + self._context_unknown = True + + @staticmethod + def _stopped_in_buffer(buffer: bytearray) -> bool: + """Detect a STOPPED terminal before waiting for a prompt. + + Some adapters append ``>`` after STOPPED while others close the line + without a prompt. Looking for a complete terminal line here keeps both + variants typed as ``ElmStoppedError`` instead of misreporting a timeout. + """ + return re.search(rb"(?:^|[\r\n])\s*STOPPED\s*(?:[\r\n]|$)", bytes(buffer), re.IGNORECASE) is not None + + def _read_until_prompt(self, timeout: float, cmd_clean: str, *, require_prompt: bool = True) -> bytes: + deadline = time.monotonic() + timeout + buffer = bytearray() + try: + while time.monotonic() < deadline: + try: + chunk = self.transport.read(min(4096, self._max_buffer_size)) + if not chunk: + self.reset_context() + raise ElmDisconnectedError(f"Connection closed reading {cmd_clean}") + if len(buffer) + len(chunk) > self._max_buffer_size: + self.reset_context() + raise ElmResponseTooLargeError( + f"ELM response for '{cmd_clean}' exceeds {self._max_buffer_size} bytes" + ) + buffer.extend(chunk) + if self._stopped_in_buffer(buffer): + self.reset_context() + raise ElmStoppedError(f"ELM stopped command '{cmd_clean}' before a complete response") + if b">" in buffer: + break + except TimeoutError: + continue + except (ConnectionResetError, OSError) as err: + self.reset_context() + raise ElmDisconnectedError(f"Connection lost reading {cmd_clean}: {err}") from err + except ElmError: + # Preserve typed transaction failures (oversize, STOPPED-adjacent + # transport errors, and future ElmError subclasses) instead of + # obscuring them as a generic read failure. + raise + except Exception as err: + raise ElmError(f"Read failed for {cmd_clean}: {err}") from err + + if require_prompt and b">" not in buffer: + self.reset_context() + raise ElmTimeoutError(f"Timeout waiting for ELM prompt for '{cmd_clean}' (received: {bytes(buffer)!r})") + return bytes(buffer) + finally: + # Keep the raw adapter exchange available even when a read fails before + # command() can receive a buffer, while still applying VIN redaction. + self._debug_event("elm_rx", command=cmd_clean, raw=self._debug_raw_text(bytes(buffer), cmd_clean)) + + @staticmethod + def _clean_lines(raw_text: str, cmd_clean: str = "") -> list[str]: + prompt_idx = raw_text.rfind(">") + if prompt_idx != -1: + raw_text = raw_text[:prompt_idx] + + raw_lines = [line.strip() for line in re.split(r"[\r\n]+", raw_text) if line.strip()] + cleaned_lines: list[str] = [] + cmd_norm = cmd_clean.replace(" ", "").upper() + + for line in raw_lines: + normalized = line.replace(" ", "").upper() + + if normalized == "STOPPED": + raise ElmStoppedError(f"ELM stopped command '{cmd_clean}' before a complete response") + + # Strip command echo if clone still echoed it + if cmd_norm and (normalized == cmd_norm or normalized.startswith(cmd_norm)): + continue + + # Filter out noise messages + if normalized in IGNORE_PATTERNS or any(normalized.startswith(p) for p in IGNORE_PATTERNS if p != "STOPPED"): + continue + + # Check for known errors + for err_key, err_cls in ERROR_RESPONSES.items(): + if normalized == err_key.replace(" ", "").upper(): + raise err_cls(f"ELM returned {line} for command '{cmd_clean}'") + + cleaned_lines.append(line) + + return cleaned_lines + + def command(self, cmd: str, timeout: float | None = None) -> list[str]: + cmd_clean = cmd.strip() + if not cmd_clean: + raise ValueError("ELM command must not be empty") + if "\r" in cmd_clean or "\n" in cmd_clean: + raise ValueError("ELM command must contain one line") + write_data = (cmd_clean + "\r").encode("ascii") + self._debug_event("at_tx", command=cmd_clean) + try: + self.transport.write(write_data) + except TimeoutError as err: + # A timed-out write may have reached the adapter, so the request stream + # and cached context are no longer trustworthy for the next transaction. + self.reset_context() + raise ElmTimeoutError(f"Timeout writing {cmd_clean}: {err}") from err + except (ConnectionResetError, BrokenPipeError, OSError) as err: + self.reset_context() + raise ElmDisconnectedError(f"Connection lost writing {cmd_clean}: {err}") from err + except Exception as err: + raise ElmError(f"Write failed for {cmd_clean}: {err}") from err + + timeout_val = timeout if timeout is not None else self.default_timeout + buffer = self._read_until_prompt(timeout_val, cmd_clean) + try: + return self._clean_lines(buffer.decode("ascii", errors="ignore"), cmd_clean) + except ElmStoppedError: + # STOPPED may have interrupted an ISO-TP exchange even when the adapter + # appended a prompt, so the cached context cannot be trusted. + self.reset_context() + raise + + def debug_at_command(self, cmd: str, timeout: float | None = None) -> list[str]: + """Run an offroad AT command and invalidate cached ELM state afterward.""" + try: + return self.command(cmd, timeout=timeout) + finally: + # Arbitrary AT commands may change ATSH/ATCRA/ATSP/CAF/CFC and clone- + # specific settings we cannot reliably infer. The next normal request + # will restore a complete context. + self.reset_context() + + @staticmethod + def _protocol_code(context: ElmContext) -> str: + """Resolve typed/developer protocol configuration to a safe ELM code.""" + if context.exact_protocol is not None: + candidate = str(context.exact_protocol).strip().upper() + if candidate in PROTOCOL_MAP: + return PROTOCOL_MAP[candidate] + if re.fullmatch(r"[0-9A-F]", candidate): + return candidate + raise ElmUnsupportedError(f"Unknown exact ELM protocol {context.exact_protocol!r}") + + protocol = context.protocol + if protocol is None: + return DEFAULT_PROTOCOL + if isinstance(protocol, ProtocolRequirement): + # OBDb's family labels do not include bitrate or initialization details. + # Automatic detection is safer than selecting a guessed ELM variant. + return { + ProtocolRequirement.AUTO: "0", + ProtocolRequirement.ISO9141_2: "3", + ProtocolRequirement.KWP14230: "0", + ProtocolRequirement.CAN_11BIT: "0", + ProtocolRequirement.CAN_29BIT: "0", + }[protocol] + candidate = str(protocol).strip().upper() + if candidate in PROTOCOL_MAP: + return PROTOCOL_MAP[candidate] + if re.fullmatch(r"[0-9A-F]", candidate): + return candidate + raise ElmUnsupportedError(f"Unknown ELM protocol requirement {protocol!r}") + + def apply_context(self, context: ElmContext) -> None: + if self.active_context == context and not self._context_unknown: + return + try: + if self._context_unknown: + # Settings may survive an interrupted command or arbitrary debug AT. + # Establish a documented baseline before applying the desired + # context; header display mode remains untouched. + self._restore_baseline() + self._context_unknown = False + elif self.active_context is not None and self._needs_baseline_reset(self.active_context, context): + # ELM documents CRA-without-an-argument as a reset, but SH and TA + # reset behavior is not portable across genuine ELMs and clones. A + # complete baseline is the only honest way to clear those settings. + self.reset_context() + self._restore_baseline() + self._context_unknown = False + self._apply_context_unchecked(context) + except Exception: + # A context is a cache, not a claim about adapter state. If a command + # fails halfway through applying it, force the next request to restore + # every relevant setting from a known baseline. + self.reset_context() + raise + + @staticmethod + def _needs_baseline_reset(previous: ElmContext, desired: ElmContext) -> bool: + """Whether a context transition needs ATD rather than an empty AT command.""" + return ( + (previous.tx_header is not None and desired.tx_header is None) + or (previous.tester_address is not None and desired.tester_address is None) + ) + + def _restore_baseline(self) -> None: + """Restore the documented ELM baseline before applying a context.""" + self.command("ATD") + # These commands are part of the core terminal/diagnostic contract. A + # clone that rejects one of them cannot be safely treated as initialized. + for command in ("ATE0", "ATL0", "ATS0", "ATR1", "ATSP0", "ATCAF1", "ATCFC1"): + self.command(command) + + # ATD restores automatic/default flow control. FCSM0 is still useful to + # reaffirm that state on capable adapters, but it is not required for + # ordinary requests. Discover this capability lazily and only make it a + # hard requirement when a profile actually asks for custom FCM1. + if self.adapter_capabilities.get("fc_mode1") is not False: + try: + self.command("ATFCSM0") + except ElmCommandError: + self.adapter_capabilities["fc_mode1"] = False + else: + self.adapter_capabilities["fc_mode1"] = True + + self.command(f"ATST{DEFAULT_TIMEOUT:02X}") + + def _apply_context_unchecked(self, context: ElmContext) -> None: + prev = self.active_context or ElmContext() + + # 1. Protocol change. ATSP0 restores automatic protocol selection. + # Compare the effective ELM code rather than only the source spelling. + # Native family requirements such as CAN_11BIT and AUTO both intentionally + # resolve to automatic detection, so a baseline pass must not emit a + # redundant ATSP0 command. + if self._protocol_code(context) != self._protocol_code(prev): + self.command(f"ATSP{self._protocol_code(context)}") + + # 2. CAN addressing. ELM's 29-bit model is split between CP (priority) + # and SH/CRA (the lower 24 bits/full receive address). Keep TX and RX + # calculations symmetric while allowing a response-priority override. + tx_is_29, tx_priority, tx_lower = self._effective_tx_address(context) + prev_is_29, prev_priority, prev_lower = self._effective_tx_address(prev) + tx_changed = (tx_is_29, tx_priority, tx_lower) != (prev_is_29, prev_priority, prev_lower) + if tx_is_29: + if context.tx_header is None: + raise ElmUnsupportedError("29-bit context requires a transmit header") + if not prev_is_29 or tx_priority != prev_priority: + self.command(f"ATCP{tx_priority:02X}") + if not prev_is_29 or tx_lower != prev_lower: + self.command(f"ATSH{tx_lower:06X}") + else: + # CP only affects 29-bit CAN IDs. Restoring the documented default when + # leaving a 29-bit context prevents a later 29-bit request from + # inheriting a custom priority, while remaining harmless for 11-bit. + if prev_is_29: + self.command("ATCP18") + if tx_changed: + if context.tx_header is None: + # ``apply_context`` handles this transition through ATD. Keep this + # guard so a future caller cannot reintroduce an undocumented empty + # ATSH reset by bypassing that path. + raise ElmUnsupportedError("Clearing ELM transmit header requires baseline restoration") + else: + self.command(f"ATSH{context.tx_header & 0x7FF:03X}") + + effective_rx = self._effective_rx_address(context, tx_priority if tx_is_29 else None) + previous_rx = self._effective_rx_address(prev, prev_priority if prev_is_29 else None) + if effective_rx != previous_rx or tx_is_29 != prev_is_29: + if effective_rx is None: + self.command("ATCRA") + elif tx_is_29: + self.command(f"ATCRA{effective_rx:08X}") + else: + self.command(f"ATCRA{effective_rx & 0x7FF:03X}") + + # 4. Extended Address change + if context.extended_address != prev.extended_address: + if context.extended_address is not None: + self.command(f"ATCEA{context.extended_address:02X}") + else: + self.command("ATCEA") + + # Tester address is also sticky on adapters that support ISO 15765 + # addressing. Clearing it is handled by the ATD baseline path above; + # empty ATTA reset behavior is not portable across ELM implementations. + if context.tester_address != prev.tester_address: + if context.tester_address is None: + raise ElmUnsupportedError("Clearing ELM tester address requires baseline restoration") + else: + self.command(f"ATTA{context.tester_address:02X}") + + # 5. CAN formatting and flow-control changes are separate ELM settings. + if context.can_auto_format != prev.can_auto_format: + can_auto_format = DEFAULT_CAN_AUTO_FORMAT if context.can_auto_format is None else context.can_auto_format + self.command("ATCAF1" if can_auto_format else "ATCAF0") + self._apply_flow_control(context, prev, tx_is_29, tx_priority, tx_lower, + prev_is_29, prev_priority, prev_lower) + + # 6. Timeout change (ATST in 4ms increments: 0-FF) + if context.timeout != prev.timeout: + st_val = DEFAULT_TIMEOUT if context.timeout is None else max(0, min(255, int(context.timeout))) + self.command(f"ATST{st_val:02X}") + + self.active_context = context + + @staticmethod + def _is_29bit(context: ElmContext) -> bool: + """Return whether a context uses extended CAN addressing.""" + protocol = str(context.protocol).strip().upper() if context.protocol is not None else "" + exact_protocol = str(context.exact_protocol).strip().upper() if context.exact_protocol is not None else "" + return ( + context.priority is not None + or context.response_priority is not None + or context.protocol == ProtocolRequirement.CAN_29BIT + or protocol in { + ProtocolRequirement.CAN_29BIT.value, + "15765-4-29BIT", + "15765-4-29BIT-500K", + "15765-4-29BIT-250K", + "ISO 15765-4 (CAN 29/500)", + "CAN 29/500", + "CAN_29_500", + "ISO 15765-4 (CAN 29/250)", + "CAN 29/250", + "CAN_29_250", + "7", + "9", + "A", + "SAE J1939 (CAN 29/250)", + } + or exact_protocol in { + "7", "9", "A", "CAN_29BIT", "15765-4-29BIT", + "15765-4-29BIT-500K", "15765-4-29BIT-250K", + "ISO 15765-4 (CAN 29/500)", "CAN 29/500", "CAN_29_500", + "ISO 15765-4 (CAN 29/250)", "CAN 29/250", "CAN_29_250", + "SAE J1939 (CAN 29/250)", + } + # A lower-24-bit extended CAN identifier is often supplied without its + # priority byte (for example ``DB33F1``). Anything wider than an + # 11-bit identifier therefore still needs the CP + SH path. + or (context.tx_header is not None and context.tx_header > 0x7FF) + or (context.rx_filter is not None and context.rx_filter > 0x7FF) + ) + + @classmethod + def _effective_tx_address(cls, context: ElmContext) -> tuple[bool, int, int | None]: + """Return (is_29_bit, CP priority, lower TX identifier).""" + is_29 = cls._is_29bit(context) + if not is_29: + return False, 0, context.tx_header + if context.tx_header is None: + return True, context.priority if context.priority is not None else 0x18, None + + raw_header = context.tx_header + if context.priority is None and raw_header > 0xFFFFFF: + priority = (raw_header >> 24) & 0xFF + else: + priority = context.priority if context.priority is not None else 0x18 + return True, priority & 0xFF, raw_header & 0xFFFFFF + + @classmethod + def _effective_rx_address(cls, context: ElmContext, tx_priority: int | None = None) -> int | None: + """Return the actual CRA address, including an effective 29-bit priority.""" + if context.rx_filter is None: + return None + if not cls._is_29bit(context): + return context.rx_filter + + raw_filter = context.rx_filter + if context.response_priority is not None: + response_priority = context.response_priority & 0xFF + elif tx_priority is not None: + response_priority = tx_priority & 0xFF + elif raw_filter > 0xFFFFFF: + response_priority = (raw_filter >> 24) & 0xFF + else: + response_priority = 0x18 + return (response_priority << 24) | (raw_filter & 0xFFFFFF) + + @staticmethod + def _flow_control_cfc_enabled(context: ElmContext) -> bool: + # ``flow_control`` is an explicit compatibility/raw CFC override. Native + # OBDb fcm1 never sets it, so fcm1=false still leaves CFC enabled. + if context.flow_control is None: + return DEFAULT_FLOW_CONTROL + if not isinstance(context.flow_control, bool): + raise ElmUnsupportedError("ELM flow_control must be a boolean") + return context.flow_control + + def _apply_flow_control(self, context: ElmContext, prev: ElmContext, + tx_is_29: bool, tx_priority: int, tx_lower: int | None, + prev_is_29: bool, prev_priority: int, prev_lower: int | None) -> None: + """Apply OBDb FCM mode without conflating it with the CFC switch.""" + desired_cfc = self._flow_control_cfc_enabled(context) + previous_cfc = self._flow_control_cfc_enabled(prev) + if desired_cfc != previous_cfc: + self.command("ATCFC1" if desired_cfc else "ATCFC0") + + if not isinstance(context.flow_control_mode1, bool) or not isinstance(prev.flow_control_mode1, bool): + raise ElmUnsupportedError("ELM flow_control_mode1 must be a boolean") + desired_mode1 = context.flow_control_mode1 + previous_mode1 = prev.flow_control_mode1 + desired_header = ((tx_priority << 24) | tx_lower) if desired_mode1 and tx_is_29 and tx_lower is not None \ + else tx_lower if desired_mode1 else None + previous_header = ((prev_priority << 24) | prev_lower) if previous_mode1 and prev_is_29 and prev_lower is not None \ + else prev_lower if previous_mode1 else None + if not desired_mode1: + if previous_mode1: + self.command("ATFCSM0") + return + if desired_header is None: + raise ElmUnsupportedError("Flow Control Mode 1 requires a transmit CAN header") + if self.adapter_capabilities.get("fc_mode1") is False: + raise ElmUnsupportedError("Adapter does not support custom Flow Control Mode 1") + + # ELM requires FCSH/FCSD to be configured before enabling FCSM1. Reapply + # the complete mode-1 definition when entering mode 1 or changing its + # request header so the adapter cannot retain stale flow-control state. + if not previous_mode1 or desired_header != previous_header: + fc_header = f"{desired_header:08X}" if tx_is_29 else f"{desired_header & 0x7FF:03X}" + try: + self.command(f"ATFCSH{fc_header}") + self.command("ATFCSD300000") + self.command("ATFCSM1") + except ElmCommandError as err: + self.adapter_capabilities["fc_mode1"] = False + raise ElmUnsupportedError("Adapter does not support custom Flow Control Mode 1") from err + else: + self.adapter_capabilities["fc_mode1"] = True + + @staticmethod + def _parse_response_line(line: str) -> tuple[int | None, int | None, bytes] | None: + """Parse one ELM hex line, optionally returning CAN and frame indexes.""" + text = line.strip() + frame_index: int | None = None + response_id: int | None = None + index_match = re.match(r"^([0-9A-Fa-f]+):\s*(.*)$", text) + if index_match: + prefix = index_match.group(1) + # ELM clones commonly print CAN headers as ``7E8: ...`` while a few + # print ISO-TP frame indexes as ``0: ...``. Do not mistake a 3/8-digit + # CAN identifier for a frame index. + if len(prefix) in (3, 8): + response_id = int(prefix, 16) + elif len(prefix) <= 2: + frame_index = int(prefix, 16) + else: + return None + text = index_match.group(2) + + tokens = text.split() + if not tokens: + return None + if len(tokens) == 1: + compact = tokens[0].replace(" ", "") + if not re.fullmatch(r"[0-9A-Fa-f]+", compact): + return None + # With spaces disabled, an 11-bit CAN header is sometimes prefixed to + # the frame as three nibbles (for example ``7E804410C...``). Its odd + # length makes the boundary unambiguous. + if response_id is None and len(compact) % 2 and len(compact) >= 5: + response_id = int(compact[:3], 16) + compact = compact[3:] + if len(compact) % 2: + return None + return response_id, frame_index, bytes.fromhex(compact) + if not all(re.fullmatch(r"[0-9A-Fa-f]+", token) for token in tokens): + return None + + if response_id is None and len(tokens[0]) in (3, 8): + response_id = int(tokens.pop(0), 16) + + if not all(len(token) % 2 == 0 for token in tokens): + return None + data = bytes.fromhex("".join(tokens)) + + # With headers enabled, some adapters include the CAN DLC after the ID. + # Remove it only when it exactly describes the remaining frame bytes; an + # ISO-TP first-frame byte (0x10..0x1F) must remain intact. + if response_id is not None and data and data[0] <= 8 and data[0] == len(data) - 1: + data = data[1:] + return response_id, frame_index, data + + @classmethod + def _parse_response_groups(cls, lines: list[str]) -> tuple[tuple[int | None, bytes], ...]: + """Keep responders separate and strictly reconstruct raw ISO-TP frames.""" + responses: list[tuple[int | None, bytes]] = [] + # key -1 is the local/indexed stream used by clones that print ``0:`` and + # ``1:`` instead of a CAN responder ID. CAN IDs are non-negative. + streams: dict[int, tuple[bytearray, int, int]] = {} + stream_order: list[int] = [] + + def stream_key(response_id: int | None, frame_index: int | None) -> int: + return -1 if frame_index is not None else (response_id if response_id is not None else -2) + + def complete(key: int) -> None: + state = streams.pop(key, None) + if state is None: + return + payload, expected, _next_seq = state + if len(payload) != expected: + raise ElmIncompleteResponseError( + f"ISO-TP response is incomplete: received {len(payload)} of {expected} bytes" + ) + response_id = None if key in (-1, -2) else key + responses.append((response_id, bytes(payload))) + if key in stream_order: + stream_order.remove(key) + + for line in lines: + parsed = cls._parse_response_line(line) + if parsed is None: + continue + response_id, frame_index, data = parsed + if not data: + continue + key = stream_key(response_id, frame_index) + pci_type = data[0] >> 4 + + if pci_type == 1: + if len(data) < 2: + raise ElmIsoTpError("ISO-TP first frame is missing its length") + if key in streams: + raise ElmIsoTpError("ISO-TP first frame arrived before the previous response completed") + expected = ((data[0] & 0x0F) << 8) | data[1] + if expected <= 0: + raise ElmIsoTpError("ISO-TP first frame declared an empty response") + payload = bytearray(data[2:]) + if len(payload) > expected: + raise ElmIsoTpError("ISO-TP first frame exceeds its declared length") + if len(payload) == expected: + response_id_out = None if key in (-1, -2) else key + responses.append((response_id_out, bytes(payload))) + else: + streams[key] = (payload, expected, 1) + stream_order.append(key) + continue + + if pci_type == 2: + if key not in streams: + # Headerless output may omit the ID on consecutive frames; use the + # sole active stream in that unambiguous case. + if response_id is None and frame_index is None and len(streams) == 1: + key = next(iter(streams)) + else: + raise ElmIsoTpError("ISO-TP consecutive frame has no matching first frame") + payload, expected, next_seq = streams[key] + sequence = data[0] & 0x0F + if sequence != next_seq: + raise ElmIsoTpError( + f"ISO-TP sequence mismatch: expected {next_seq:X}, received {sequence:X}" + ) + payload.extend(data[1:]) + if len(payload) > expected: + raise ElmIsoTpError("ISO-TP consecutive frame exceeds its declared length") + if len(payload) == expected: + complete(key) + else: + streams[key] = (payload, expected, (next_seq + 1) & 0x0F) + continue + + if pci_type == 0: + length = data[0] & 0x0F + if length == 0: + raise ElmIsoTpError("ISO-TP single frame declared an empty response") + if length > len(data) - 1: + raise ElmIncompleteResponseError( + f"ISO-TP single frame declares {length} bytes but only {len(data) - 1} arrived" + ) + response_id_out = None if key in (-1, -2) else key + responses.append((response_id_out, bytes(data[1:1 + length]))) + continue + + if pci_type == 3: + # Flow-control frames are emitted by the tester, not a diagnostic ECU + # response. Seeing one in an adapter response stream means the raw + # transcript is not a complete response we can safely decode. + raise ElmIsoTpError("Unexpected ISO-TP flow-control frame in response") + + # Formatted ELM output normally has no PCI byte at this point. Preserve + # such diagnostic payloads verbatim. + responses.append((response_id, data)) + + for key in tuple(stream_order): + complete(key) + if streams: + # Defensive: complete() removes every key in stream_order. + raise ElmIncompleteResponseError("ISO-TP response ended before completion") + return tuple(responses) + + @classmethod + def _parse_responses(cls, lines: list[str]) -> tuple[bytes, ...]: + """Compatibility view containing only parsed responder payloads.""" + return tuple(payload for _response_id, payload in cls._parse_response_groups(lines)) + + def _read_pending_lines(self, timeout: float, cmd_clean: str = "") -> list[str]: + if timeout <= 0: + return [] + read_command = cmd_clean or "" + buffer = self._read_until_prompt(timeout, read_command, require_prompt=False) + if not buffer: + return [] + # Clones can re-enable command echo after an ECU's pending response. Use + # the original diagnostic command here as well, otherwise an echoed + # payload such as ``22F190`` could be mistaken for an ISO-TP frame. + try: + return self._clean_lines(buffer.decode("ascii", errors="ignore"), cmd_clean) + except ElmStoppedError: + self.reset_context() + raise + + def _make_diagnostic_response(self, payload: bytes, lines: list[str]) -> DiagnosticResponse: + parsed_responses = self._parse_response_groups(lines) + responses = tuple(response for _response_id, response in parsed_responses) + if not responses: + raise ElmIncompleteResponseError("ELM returned no parseable diagnostic response") + response_groups: list[tuple[int | None, tuple[bytes, ...]]] = [] + for response_id, response in parsed_responses: + if response_id is None: + response_groups.append((None, (response,))) + continue + for index, (group_id, group_payloads) in enumerate(response_groups): + if group_id == response_id: + response_groups[index] = (group_id, group_payloads + (response,)) + break + else: + response_groups.append((response_id, (response,))) + + expected_service = (payload[0] + 0x40) & 0xFF + positive_responses = tuple(response for response in responses if response and response[0] == expected_service) + negative_responses = tuple(response for response in responses if len(response) >= 3 and response[0] == 0x7F and + response[1] == payload[0]) + pending_responses = tuple(response for response in negative_responses if response[2] == 0x78) + + if not positive_responses and not negative_responses: + raise ElmUnexpectedResponseError( + f"ELM returned no response for service 0x{payload[0]:02X}; received {responses[0].hex().upper()}" + ) + + # A functional request can have a negative response from one ECU and a + # valid positive response from another. Prefer the matching positive + # response and only raise when no responder succeeded. + selected = positive_responses[0] if positive_responses else (responses[0] if responses else b"") + if not positive_responses and negative_responses: + non_pending = tuple(response for response in negative_responses if response[2] != 0x78) + if non_pending: + raw_bytes = non_pending[0] + from openpilot.starpilot.system.obdyssey.diagnostics import UdsNegativeResponseError + req_sid = raw_bytes[1] + nrc = raw_bytes[2] + raise UdsNegativeResponseError(req_sid, nrc) + + return DiagnosticResponse( + raw=b"".join(responses), + payload=selected, + lines=tuple(lines), + service=selected[0] if selected else None, + responses=responses, + response_groups=tuple(response_groups), + pending=bool(pending_responses and not positive_responses), + ) + + def request(self, payload: bytes, context: ElmContext | None = None, *, timeout: float | None = None, + retry: bool = True, pending_timeout: float | None = None) -> DiagnosticResponse: + if not payload: + raise ValueError("Diagnostic payload must not be empty") + payload_hex = payload.hex().upper() + request_start = time.monotonic() + self._debug_event("diagnostic_start", payload=payload_hex) + try: + return self._request(payload, context, timeout=timeout, retry=retry, pending_timeout=pending_timeout) + except Exception as err: + self._debug_event("diagnostic_error", payload=payload_hex, error_type=type(err).__name__) + raise + finally: + self._debug_event( + "diagnostic_complete", + payload=payload_hex, + duration_ms=round((time.monotonic() - request_start) * 1000, 1), + ) + + def _request(self, payload: bytes, context: ElmContext | None = None, *, timeout: float | None = None, + retry: bool = True, pending_timeout: float | None = None) -> DiagnosticResponse: + # ``retry`` remains accepted for API compatibility with the prototype, but + # reconnect ownership belongs to OBDysseyController. Replaying a request + # here would make write safety and connection state ambiguous. + del retry + # A request without an explicit context still needs a known ELM baseline + # after debug AT, a timeout, or a partial context change. Preserve the + # existing context when it is known; otherwise establish the neutral one. + if context is not None or self._context_unknown: + self.apply_context(context or ElmContext()) + + payload_hex = payload.hex().upper() + self._debug_event("context", **self._debug_context_fields(self.active_context or context or ElmContext())) + lines = self.command(payload_hex, timeout=timeout) + transaction_size = sum(len(line.encode("ascii", errors="ignore")) + 2 for line in lines) + response = self._make_diagnostic_response(payload, lines) + if not response.pending: + return response + + # NRC 0x78 is part of this one transaction. Never write payload_hex again; + # wait for additional ECU responses until the bounded P2* deadline. + wait_timeout = DEFAULT_PENDING_TIMEOUT if pending_timeout is None else pending_timeout + if wait_timeout <= 0: + self.reset_context() + raise ElmPendingTimeoutError(f"UDS response pending for service 0x{payload[0]:02X}") + deadline = time.monotonic() + wait_timeout + pending_marker = bytes([0x7F, payload[0], 0x78]) + + def count_pending(response_value: DiagnosticResponse) -> int: + return sum( + 1 for response_payload in response_value.responses + if response_payload[:3] == pending_marker + ) + + pending_count = count_pending(response) + while response.pending: + remaining = deadline - time.monotonic() + if remaining <= 0: + self.reset_context() + raise ElmPendingTimeoutError(f"UDS response pending for service 0x{payload[0]:02X}") + late_lines = self._read_pending_lines(remaining, payload_hex) + if not late_lines: + self.reset_context() + raise ElmPendingTimeoutError(f"UDS response pending for service 0x{payload[0]:02X}") + transaction_size += sum(len(line.encode("ascii", errors="ignore")) + 2 for line in late_lines) + if transaction_size > self._max_buffer_size: + self.reset_context() + raise ElmResponseTooLargeError( + f"ELM diagnostic response for '{payload_hex}' exceeds {self._max_buffer_size} bytes" + ) + lines.extend(late_lines) + previous_pending_count = pending_count + response = self._make_diagnostic_response(payload, lines) + pending_count = count_pending(response) + if pending_count > previous_pending_count: + # Each newly received matching NRC 0x78 starts a fresh P2* window. + deadline = time.monotonic() + wait_timeout + return response diff --git a/starpilot/system/obdyssey/obdb.py b/starpilot/system/obdyssey/obdb.py new file mode 100644 index 000000000..233914e41 --- /dev/null +++ b/starpilot/system/obdyssey/obdb.py @@ -0,0 +1,914 @@ +from __future__ import annotations + +"""The deliberately small boundary between native OBDb and OBDyssey. + +OBDb is a useful data source, but it is not OBDyssey's runtime model. Keep +schema details in this module and hand the rest of the daemon typed, +validated objects. A little compatibility code for the original +StarPilot-only profile dialect is retained for already-installed developer +profiles; native v3 data always takes the strict path below. +""" + +import math +import re +from dataclasses import dataclass, field +from typing import Any + +from openpilot.starpilot.system.obdyssey.elm327 import ElmContext, ProtocolRequirement + + +class OBDbProfileError(ValueError): + """A profile does not conform to the native OBDb shape supported here.""" + + +class SignalDecodeError(ValueError): + """A signal could not be decoded without inventing data.""" + + def __init__(self, reason: str, message: str | None = None, *, signal_id: str | None = None): + self.reason = reason + self.signal_id = signal_id + detail = message or reason.replace("_", " ") + super().__init__(detail) + + +class SyntheticSignalError(SignalDecodeError): + """A native synthetic signal has unavailable or invalid inputs.""" + + +@dataclass(frozen=True) +class SignalFormat: + bix: int = 0 + len: int = 8 + blsb: bool = False + sign: bool = False + mul: float = 1.0 + div: float = 1.0 + add: float = 0.0 + min: float | None = None + max: float | None = None + nullmin: float | None = None + nullmax: float | None = None + # OBDb map keys are raw integer values. String keys remain accepted in the + # public constructor for compatibility, while normalized profiles contain + # integer keys. + map: dict[int | str, str] | None = None + unit: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SignalFormat: + if not isinstance(data, dict): + raise OBDbProfileError("Signal fmt must be an object") + + def number(name: str, default: float | None = None) -> float | None: + value = data.get(name, default) + if value is None: + return None + if isinstance(value, bool): + raise OBDbProfileError(f"Signal fmt {name} must be numeric") + try: + result = float(value) + except (TypeError, ValueError) as err: + raise OBDbProfileError(f"Signal fmt {name} must be numeric") from err + if not math.isfinite(result): + raise OBDbProfileError(f"Signal fmt {name} must be finite") + return result + + def integer(name: str, default: int) -> int: + value = data.get(name, default) + if isinstance(value, bool) or (isinstance(value, float) and not value.is_integer()): + raise OBDbProfileError(f"Signal fmt {name} must be an integer") + try: + parsed = int(value) + except (TypeError, ValueError) as err: + raise OBDbProfileError(f"Signal fmt {name} must be an integer") from err + # ``int('1.0')`` already fails; reject a stringified fractional value + # explicitly rather than allowing Python's permissive conversions to + # truncate it. + if isinstance(value, str) and str(parsed) != value.strip(): + raise OBDbProfileError(f"Signal fmt {name} must be an integer") + return parsed + + bix = integer("bix", 0) + bit_len = integer("len", 8) + if bix < 0 or bit_len <= 0: + raise OBDbProfileError("Signal fmt bix must be non-negative and len must be positive") + + div = number("div", 1.0) + assert div is not None + if div == 0: + raise OBDbProfileError("Signal fmt div must not be zero") + + raw_map = data.get("map") + normalized_map: dict[int | str, str] | None = None + if raw_map is not None: + if not isinstance(raw_map, dict): + raise OBDbProfileError("Signal fmt map must be an object") + normalized_map = {} + for key, value in raw_map.items(): + try: + map_key: int | str = int(str(key), 10) + except (TypeError, ValueError): + map_key = str(key) + if isinstance(value, dict): + if "value" not in value: + raise OBDbProfileError(f"Signal map entry {key!r} has no value") + value = value["value"] + normalized_map[map_key] = str(value) + + minimum = number("min") + maximum = number("max") + if minimum is not None and maximum is not None and minimum > maximum: + raise OBDbProfileError("Signal fmt min must not exceed max") + + return cls( + bix=bix, + len=bit_len, + blsb=_parse_bool(data.get("blsb"), default=False), + sign=_parse_bool(data.get("sign"), default=False), + mul=number("mul", 1.0) or 0.0, + div=div, + add=number("add", 0.0) or 0.0, + min=minimum, + max=maximum, + nullmin=number("nullmin"), + nullmax=number("nullmax"), + map=normalized_map, + unit=str(data.get("unit", "")), + ) + + +@dataclass(frozen=True) +class SignalDefinition: + id: str + name: str + path: str | None = None + suggested_metric: str | None = None + format: SignalFormat = field(default_factory=SignalFormat) + synthetic: dict[str, Any] | None = None + hidden: bool = False + description: str | None = None + + @property + def suggestedMetric(self) -> str | None: # native OBDb spelling + return self.suggested_metric + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SignalDefinition: + if not isinstance(data, dict): + raise OBDbProfileError("Signal must be an object") + signal_id = str(data.get("id", "")).strip() + if not signal_id: + raise OBDbProfileError("Signal id is required") + if "fmt" in data and not isinstance(data["fmt"], dict): + raise OBDbProfileError(f"Signal {signal_id!r} fmt must be an object") + fmt_dict = data.get("fmt") or data.get("format") or {} + suggested = data.get("suggestedMetric", data.get("suggested_metric")) + return cls( + id=signal_id, + name=str(data.get("name", signal_id)), + path=str(data.get("path")) if data.get("path") is not None else None, + suggested_metric=str(suggested) if suggested is not None else None, + format=SignalFormat.from_dict(fmt_dict), + synthetic=data.get("synthetic"), + hidden=_parse_bool(data.get("hidden"), default=False), + description=str(data.get("description")) if data.get("description") is not None else None, + ) + + +@dataclass(frozen=True) +class SyntheticSignalDefinition: + id: str + name: str + path: str | None = None + unit: str = "" + suggested_metric: str | None = None + operation: str = "" + sources: tuple[str, ...] = () + min: float | None = None + max: float | None = None + + +@dataclass(frozen=True) +class Applicability: + """The native OBDb filter, evaluated once while selecting a profile.""" + + from_year: int | None = None + to_year: int | None = None + years: frozenset[int] = frozenset() + + @classmethod + def from_dict(cls, data: Any) -> Applicability | None: + if data is None: + return None + if not isinstance(data, dict): + raise OBDbProfileError("filter must be an object") + allowed = {"from", "to", "years"} + unknown = set(data) - allowed + if unknown: + raise OBDbProfileError(f"filter has unsupported fields: {', '.join(sorted(unknown))}") + + def year_value(name: str) -> int | None: + value = data.get(name) + if value is None: + return None + if isinstance(value, bool): + raise OBDbProfileError(f"filter {name} must be an integer year") + try: + numeric = float(value) + if not math.isfinite(numeric) or not numeric.is_integer(): + raise ValueError + value = int(numeric) + except (TypeError, ValueError) as err: + raise OBDbProfileError(f"filter {name} must be an integer year") from err + if value < 1886 or value > 3000: + raise OBDbProfileError(f"filter {name} is outside a valid year range") + return value + + start, end = year_value("from"), year_value("to") + raw_years = data.get("years", []) + if not isinstance(raw_years, list): + raise OBDbProfileError("filter years must be an array") + years: set[int] = set() + for value in raw_years: + if isinstance(value, bool): + raise OBDbProfileError("filter years must contain integer years") + try: + numeric = float(value) + if not math.isfinite(numeric) or not numeric.is_integer(): + raise ValueError + parsed = int(numeric) + except (TypeError, ValueError) as err: + raise OBDbProfileError("filter years must contain integer years") from err + if parsed < 1886 or parsed > 3000: + raise OBDbProfileError("filter year is outside a valid year range") + years.add(parsed) + if start is None and end is None and not years: + return None + return cls(start, end, frozenset(years)) + + def matches(self, model_year: int | None) -> bool: + if model_year is None: + return False + # OBDb filter members are alternatives: a signal applies to an inclusive + # range, any explicitly listed year, or both. This is important for + # profiles such as ``to: 2011, years: [2014, 2015], from: 2026``. + matches_year = bool(self.years and model_year in self.years) + has_range = self.from_year is not None or self.to_year is not None + if self.from_year is not None and self.to_year is not None and self.from_year > self.to_year: + # OBDb uses reversed bounds to express two open-ended ranges (for + # example, ``to: 2011`` and ``from: 2026`` with selected middle years). + matches_range = model_year <= self.to_year or model_year >= self.from_year + else: + matches_range = has_range and ( + (self.from_year is None or model_year >= self.from_year) + and (self.to_year is None or model_year <= self.to_year) + ) + return matches_year or matches_range + + +@dataclass(frozen=True) +class DiagnosticCommand: + id: str + context: ElmContext + service: int + parameter: bytes + frequency: float = 1.0 + diagnostic_session_in: int | None = None + diagnostic_session_out: int | None = None + signals: tuple[SignalDefinition, ...] = () + expected_prefix: bytes = b"" + applicability: Applicability | None = None + # ``dbgfilter`` is upstream metadata used by OBDb tooling. Preserve it at + # the boundary without treating it as a runtime availability filter; only + # the command's ``filter`` controls whether a request is offered for a + # selected model year. + debug_applicability: Applicability | None = None + debug: bool = False + + @property + def filter(self) -> Applicability | None: + return self.applicability + + @property + def dbgfilter(self) -> Applicability | None: # native OBDb spelling + return self.debug_applicability + + +@dataclass(frozen=True) +class VehicleProfile: + id: str + name: str + provider: str = "obdb" + revision: str = "" + commands: tuple[DiagnosticCommand, ...] = () + signals: dict[str, SignalDefinition] = field(default_factory=dict) + synthetic_signals: dict[str, SyntheticSignalDefinition | SignalDefinition] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + diagnostic_level: int | None = None + signal_groups: tuple[dict[str, Any], ...] = () + + +def parse_obdb_hex(value: Any, field_name: str, *, maximum: int = 0x1FFFFFFF) -> int: + """Parse a schema-declared OBDb hexadecimal field (including ``"710"``).""" + if isinstance(value, bool) or value is None: + raise OBDbProfileError(f"OBDb {field_name} must be a hexadecimal string") + if isinstance(value, int): + parsed = value + else: + text = str(value).strip() + if text.lower().startswith("0x"): + text = text[2:] + if not text or re.fullmatch(r"[0-9A-Fa-f]+", text) is None: + raise OBDbProfileError(f"OBDb {field_name} must be hexadecimal") + parsed = int(text, 16) + if not 0 <= parsed <= maximum: + raise OBDbProfileError(f"OBDb {field_name} is outside the supported range") + return parsed + + +def parse_obdb_decimal(value: Any, field_name: str, *, default: int | None = None) -> int | None: + if value is None or value == "": + return default + if isinstance(value, bool): + raise OBDbProfileError(f"OBDb {field_name} must be decimal") + try: + numeric = float(value) + if not math.isfinite(numeric) or not numeric.is_integer(): + raise ValueError + parsed = int(numeric) + except (TypeError, ValueError) as err: + raise OBDbProfileError(f"OBDb {field_name} must be decimal") from err + return parsed + + +def _parse_obdb_bytes(value: Any, field_name: str) -> bytes: + if isinstance(value, bytes): + return value + if isinstance(value, list): + if any(isinstance(item, bool) or not isinstance(item, int) or not 0 <= item <= 0xFF for item in value): + raise OBDbProfileError(f"OBDb {field_name} must contain byte values") + return bytes(value) + text = str(value or "").replace(" ", "").strip() + if not text or len(text) % 2 or re.fullmatch(r"[0-9A-Fa-f]+", text) is None: + raise OBDbProfileError(f"OBDb {field_name} must contain an even number of hexadecimal digits") + return bytes.fromhex(text) + + +def _parse_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise OBDbProfileError(f"Invalid boolean value: {value!r}") + if isinstance(value, bool): + return value + raise OBDbProfileError(f"Invalid boolean value: {value!r}") + + +def _parse_legacy_int(value: Any) -> int | None: + if value is None or value == "": + return None + if isinstance(value, bool): + raise ValueError("integer expected") + if isinstance(value, int): + return value + text = str(value).strip() + if text.lower().startswith("0x"): + return int(text, 16) + if re.fullmatch(r"[0-9A-Fa-f]+", text) and not text.isdigit(): + return int(text, 16) + return int(text) + + +# Compatibility helpers retained for developer profiles that imported the +# prototype's private functions. Native OBDb parsing never uses these +# ambiguous parsers. +def _parse_int(value: Any) -> int | None: + return _parse_legacy_int(value) + + +def _parse_bytes(value: Any) -> bytes: + if value is None: + return b"" + return _parse_obdb_bytes(value, "bytes") + + +def _first_present(data: dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in data and data[key] not in (None, ""): + return data[key] + return None + + +def extract_raw_value(data: bytes, bix: int, bit_len: int, blsb: bool = False, sign: bool = False) -> int: + """Extract a bit-level value, rejecting truncated payloads.""" + if bix < 0 or bit_len <= 0: + raise SignalDecodeError("invalid_bit_range") + start_byte = bix // 8 + end_byte = (bix + bit_len - 1) // 8 + if end_byte >= len(data): + raise SignalDecodeError( + "truncated_payload", + f"payload has {len(data)} bytes but bits {bix}:{bix + bit_len} were requested", + ) + + raw_int = 0 + if blsb: + for index in range(end_byte, start_byte - 1, -1): + raw_int = (raw_int << 8) | data[index] + else: + for index in range(start_byte, end_byte + 1): + raw_int = (raw_int << 8) | data[index] + + bit_offset = (8 - ((bix + bit_len) % 8)) % 8 + extracted = (raw_int >> bit_offset) & ((1 << bit_len) - 1) + if sign and (extracted & (1 << (bit_len - 1))): + extracted -= 1 << bit_len + return extracted + + +def decode_signal(payload: bytes, signal: SignalDefinition, strip_prefix: bytes | None = None) -> Any: + """Decode one signal without padding, clamping, or otherwise inventing data.""" + data = payload + if strip_prefix: + if not data.startswith(strip_prefix): + raise SignalDecodeError("unexpected_prefix", signal_id=signal.id) + data = data[len(strip_prefix):] + + fmt = signal.format + if fmt.div == 0: + raise SignalDecodeError("division_by_zero", signal_id=signal.id) + try: + raw_val = extract_raw_value(data, fmt.bix, fmt.len, fmt.blsb, fmt.sign) + except SignalDecodeError as err: + raise SignalDecodeError(err.reason, str(err), signal_id=signal.id) from err + + scaled_val = raw_val * fmt.mul / fmt.div + fmt.add + if not math.isfinite(scaled_val): + raise SignalDecodeError("non_finite", f"{signal.id} decoded to a non-finite value", signal_id=signal.id) + # OBDb's two null thresholds are independent. In particular, an equal + # pair is not an inclusive range: each comparison still applies on its own + # (which makes every value null, as the source profile explicitly says). + if fmt.nullmin is not None and scaled_val <= fmt.nullmin: + return None + if fmt.nullmax is not None and scaled_val >= fmt.nullmax: + return None + if fmt.min is not None and scaled_val < fmt.min: + raise SignalDecodeError("below_minimum", f"{signal.id} decoded below its minimum", signal_id=signal.id) + if fmt.max is not None and scaled_val > fmt.max: + raise SignalDecodeError("above_maximum", f"{signal.id} decoded above its maximum", signal_id=signal.id) + + # OBDb maps are keyed by the raw value, before scaling. + if fmt.map is not None: + mapped = fmt.map.get(raw_val) + if mapped is None: + mapped = fmt.map.get(str(raw_val)) + if mapped is not None: + return mapped + + if isinstance(scaled_val, float) and scaled_val.is_integer(): + return int(scaled_val) + return round(scaled_val, 4) if isinstance(scaled_val, float) else scaled_val + + +def calculate_synthetic_signal(signal: SyntheticSignalDefinition | SignalDefinition, + available_signals: dict[str, Any], *, strict: bool = False) -> Any: + """Calculate supported synthetics from already-decoded values only.""" + if isinstance(signal, SyntheticSignalDefinition): + operation = signal.operation.lower() + source_ids = signal.sources + low, high = signal.min, signal.max + else: + synth = signal.synthetic if isinstance(signal.synthetic, dict) else {} + operation = str(synth.get("operation", synth.get("op", ""))).lower() + source_ids = tuple(str(value) for value in synth.get("signals", synth.get("sources", []))) + low, high = signal.format.min, signal.format.max + + def unavailable(reason: str) -> Any: + if strict: + raise SyntheticSignalError(reason, signal_id=getattr(signal, "id", None)) + return None + + values = [available_signals.get(source_id) for source_id in source_ids] + if not source_ids or any(value is None for value in values): + return unavailable("synthetic_input_unavailable") + try: + if operation in {"ratio", "divide", "div"}: + if len(values) != 2 or values[1] == 0: + return unavailable("synthetic_division_by_zero") + result = values[0] / values[1] + elif operation in {"sum", "add"}: + result = sum(values) + elif operation in {"subtract", "diff"} and len(values) >= 2: + result = values[0] - sum(values[1:]) + elif operation in {"multiply", "mul"} and len(values) >= 2: + result = 1.0 + for value in values: + result *= value + elif operation == "average": + result = sum(values) / len(values) + elif operation == "min": + result = min(values) + elif operation == "max": + result = max(values) + else: + return unavailable("unsupported_synthetic_operation") + except SyntheticSignalError: + raise + except (TypeError, ValueError, ZeroDivisionError): + return unavailable("synthetic_input_invalid") + + if isinstance(signal, SignalDefinition): + fmt = signal.format + if fmt.div == 0: + return unavailable("synthetic_division_by_zero") + result = result * fmt.mul / fmt.div + fmt.add + if not isinstance(result, (int, float)) or not math.isfinite(float(result)): + return unavailable("synthetic_non_finite") + if low is not None and result < low: + return unavailable("synthetic_below_minimum") + if high is not None and result > high: + return unavailable("synthetic_above_maximum") + return int(result) if isinstance(result, float) and result.is_integer() else round(result, 4) + + +def _normalize_protocol(value: Any) -> ProtocolRequirement | str | None: + if value in (None, ""): + return None + text = str(value).strip() + normalized = text.lower() + mapping: dict[str, ProtocolRequirement] = { + "9141-2": ProtocolRequirement.ISO9141_2, + "iso 9141-2": ProtocolRequirement.ISO9141_2, + "iso9141_2": ProtocolRequirement.ISO9141_2, + "14230": ProtocolRequirement.KWP14230, + "iso 14230-4": ProtocolRequirement.KWP14230, + "kwp14230": ProtocolRequirement.KWP14230, + "15765-4-11bit": ProtocolRequirement.CAN_11BIT, + "15765-4-29bit": ProtocolRequirement.CAN_29BIT, + "can_11bit": ProtocolRequirement.CAN_11BIT, + "can_29bit": ProtocolRequirement.CAN_29BIT, + "auto": ProtocolRequirement.AUTO, + } + # Recognized developer ELM codes and descriptive aliases remain strings; + # Elm327 validates them against its map before issuing ATSP. + return mapping.get(normalized, text) + + +def _profile_metadata(data: dict[str, Any], profile_id: str) -> tuple[str, str, str, dict[str, Any]]: + raw_meta = data.get("metadata") + meta = dict(raw_meta) if isinstance(raw_meta, dict) else {} + if isinstance(data.get("vehicle"), dict): + meta = {**data["vehicle"], **meta} + pid = profile_id or str(meta.get("id") or data.get("id") or meta.get("name") or "vehicle_profile") + name = str(meta.get("name") or data.get("name") or pid) + provider = str(meta.get("provider") or data.get("provider") or "obdb").lower() + revision = str(meta.get("revision") or data.get("revision") or "v3") + return pid, name, provider, {**meta, "source_revision": revision} + + +def _native_command(cmd_data: dict[str, Any], index: int, profile_diagnostic_level: int | None = None) -> DiagnosticCommand: + allowed_fields = { + "id", "hdr", "rax", "eax", "pri", "tst", "tmo", "fcm1", "dbg", "din", "dout", + "cmd", "freq", "proto", "filter", "dbgfilter", "signals", + } + unknown_fields = set(cmd_data) - allowed_fields + if unknown_fields: + raise OBDbProfileError( + f"Command {index} has unsupported fields: {', '.join(sorted(unknown_fields))}" + ) + required = {"hdr", "cmd", "freq", "signals"} + missing = required - set(cmd_data) + if missing: + raise OBDbProfileError(f"Command {index} is missing required fields: {', '.join(sorted(missing))}") + + def native_bool(name: str, default: bool = False) -> bool: + if name not in cmd_data: + return default + value = cmd_data[name] + if not isinstance(value, bool): + raise OBDbProfileError(f"Command {index} {name} must be a boolean") + return value + if not isinstance(cmd_data["cmd"], dict) or len(cmd_data["cmd"]) != 1: + raise OBDbProfileError(f"Command {index} cmd must contain exactly one service entry") + service_text, parameter_text = next(iter(cmd_data["cmd"].items())) + service = parse_obdb_hex(service_text, f"commands[{index}].cmd service", maximum=0xFF) + if service not in (0x01, 0x21, 0x22): + raise OBDbProfileError(f"Command {index} uses unsupported service 0x{service:02X}") + parameter = _parse_obdb_bytes(parameter_text, f"commands[{index}].cmd payload") + expected_parameter_len = 2 if service == 0x22 else 1 + if len(parameter) != expected_parameter_len: + raise OBDbProfileError( + f"Command {index} service 0x{service:02X} requires {expected_parameter_len} parameter bytes" + ) + try: + frequency = float(cmd_data["freq"]) + except (TypeError, ValueError) as err: + raise OBDbProfileError(f"Command {index} freq must be positive") from err + if not math.isfinite(frequency) or frequency <= 0: + raise OBDbProfileError(f"Command {index} freq must be positive") + + hdr = parse_obdb_hex(cmd_data["hdr"], f"commands[{index}].hdr") + + def optional_hex(name: str, maximum: int = 0x1FFFFFFF) -> int | None: + return parse_obdb_hex(cmd_data[name], f"commands[{index}].{name}", maximum=maximum) if name in cmd_data else None + + rax = optional_hex("rax") + eax = optional_hex("eax", maximum=0xFF) + pri = optional_hex("pri", maximum=0xFF) + tst = optional_hex("tst", maximum=0xFF) + tmo = optional_hex("tmo", maximum=0xFF) + if "din" in cmd_data: + din = parse_obdb_hex(cmd_data["din"], f"commands[{index}].din", maximum=0xFF) + else: + din = profile_diagnostic_level + dout = optional_hex("dout", maximum=0xFF) + + raw_protocol = cmd_data.get("proto") + if raw_protocol is not None and str(raw_protocol).strip().lower() not in { + "9141-2", "14230", "15765-4-11bit", "15765-4-29bit", + }: + raise OBDbProfileError(f"Command {index} has unsupported native proto {raw_protocol!r}") + context = ElmContext( + protocol=_normalize_protocol(raw_protocol), + tx_header=hdr, + rx_filter=rax, + priority=pri, + extended_address=eax, + tester_address=tst, + timeout=tmo, + # Native OBDb fcm1 selects custom Flow Control Mode 1. It does not disable + # the ELM CFC switch; that switch remains enabled by default. + flow_control_mode1=native_bool("fcm1"), + can_auto_format=True, + ) + raw_signals = cmd_data["signals"] + if not isinstance(raw_signals, list) or not raw_signals: + raise OBDbProfileError(f"Command {index} signals must be a non-empty array") + for signal_index, signal in enumerate(raw_signals): + if not isinstance(signal, dict): + raise OBDbProfileError(f"Command {index} signal {signal_index} must be an object") + # Native OBDb requires a format and an explicit bit length. Keeping this + # check at the native boundary prevents a malformed upstream entry from + # silently becoming an eight-bit value through SignalFormat defaults. + if not isinstance(signal.get("fmt"), dict) or "len" not in signal["fmt"]: + raise OBDbProfileError(f"Command {index} signal {signal_index} requires fmt.len") + if not str(signal.get("id", "")).strip() or not str(signal.get("name", "")).strip(): + raise OBDbProfileError(f"Command {index} signal {signal_index} requires id and name") + for bool_field in ("hidden",): + if bool_field in signal and not isinstance(signal[bool_field], bool): + raise OBDbProfileError(f"Command {index} signal {signal_index} {bool_field} must be a boolean") + fmt = signal["fmt"] + unknown_signal_fields = set(signal) - { + "id", "name", "hidden", "description", "fmt", "path", "suggestedMetric", + } + if unknown_signal_fields: + raise OBDbProfileError( + f"Command {index} signal {signal_index} has unsupported fields: " + + ", ".join(sorted(unknown_signal_fields)) + ) + unknown_fmt_fields = set(fmt) - { + "bix", "len", "blsb", "sign", "min", "max", "add", "mul", "div", "unit", + "nullmin", "nullmax", "omin", "omax", "oval", "map", + } + if unknown_fmt_fields: + raise OBDbProfileError( + f"Command {index} signal {signal_index} fmt has unsupported fields: " + + ", ".join(sorted(unknown_fmt_fields)) + ) + for bool_field in ("blsb", "sign"): + if bool_field in fmt and not isinstance(fmt[bool_field], bool): + raise OBDbProfileError(f"Command {index} signal {signal_index} fmt.{bool_field} must be a boolean") + # Native OBDb guarantees that a signal is either an enumeration (map) or + # has a physical range/unit. Enforcing that choice here keeps malformed + # entries from reaching the decoder with an accidental unitless default. + if "map" not in fmt and not ("max" in fmt and "unit" in fmt): + raise OBDbProfileError( + f"Command {index} signal {signal_index} requires fmt.map or fmt.max/fmt.unit" + ) + if "map" not in fmt and (not isinstance(fmt.get("unit"), str) or not fmt["unit"].strip()): + raise OBDbProfileError(f"Command {index} signal {signal_index} fmt.unit must be a non-empty string") + signals = tuple(SignalDefinition.from_dict(signal) for signal in raw_signals) + response_service = service + 0x40 + command_id = str(cmd_data.get("id") or f"cmd_{index}_{service:02X}_{parameter.hex().upper()}") + return DiagnosticCommand( + id=command_id, + context=context, + service=service, + parameter=parameter, + frequency=frequency, + diagnostic_session_in=din, + diagnostic_session_out=dout, + signals=signals, + expected_prefix=bytes([response_service]) + parameter, + applicability=Applicability.from_dict(cmd_data.get("filter")), + debug_applicability=Applicability.from_dict(cmd_data.get("dbgfilter")), + debug=native_bool("dbg"), + ) + + +def _legacy_command(cmd_data: dict[str, Any], index: int) -> DiagnosticCommand: + """Read the pre-native StarPilot dialect for already-installed dev data.""" + hdr = _parse_legacy_int(cmd_data.get("hdr", cmd_data.get("header", cmd_data.get("tx_header")))) + rax = _parse_legacy_int(cmd_data.get("rax", cmd_data.get("receive_filter", cmd_data.get("rx_filter")))) + service = _parse_legacy_int(cmd_data.get("service", 1)) or 1 + parameter_value = cmd_data.get("pid", cmd_data.get("parameter", "")) + if isinstance(parameter_value, bytes): + parameter = parameter_value + else: + text = str(parameter_value).replace(" ", "") + parameter = bytes.fromhex(text if len(text) % 2 == 0 else "0" + text) + protocol = cmd_data.get("proto", cmd_data.get("protocol")) + if protocol is None and isinstance(cmd_data.get("_profile_protocol"), str): + protocol = cmd_data["_profile_protocol"] + context = ElmContext( + protocol=str(protocol) if protocol else None, + tx_header=hdr, + rx_filter=rax, + priority=_parse_legacy_int(cmd_data.get("priority", cmd_data.get("pri"))), + response_priority=_parse_legacy_int(cmd_data.get("response_priority", cmd_data.get("responsePriority"))), + extended_address=_parse_legacy_int(cmd_data.get("extended_address", cmd_data.get("eax"))), + tester_address=_parse_legacy_int(cmd_data.get("tester_address", cmd_data.get("tst"))), + timeout=_parse_legacy_int(cmd_data.get("timeout", cmd_data.get("tmo"))), + # ``fcm1`` has one meaning at this boundary regardless of profile age: + # it selects custom ELM Flow Control Mode 1. CFC remains enabled by + # default; an explicit ``flow_control`` context field is the only raw + # developer escape hatch for disabling it. + flow_control_mode1=_parse_bool(cmd_data.get("fcm1")) if "fcm1" in cmd_data else False, + can_auto_format=_parse_bool(cmd_data.get("caf"), default=True), + ) + raw_signals = cmd_data.get("signals", []) + signals = tuple(SignalDefinition.from_dict(signal) for signal in raw_signals) + response_service = service + 0x40 + return DiagnosticCommand( + id=str(cmd_data.get("id", f"cmd_{index}")), + context=context, + service=service, + parameter=parameter, + frequency=float(cmd_data.get("freq", 1.0)), + diagnostic_session_in=_parse_legacy_int(cmd_data.get("din")), + diagnostic_session_out=_parse_legacy_int(cmd_data.get("dout")), + signals=signals, + expected_prefix=_parse_obdb_bytes(cmd_data["eax_prefix"], "eax_prefix") if cmd_data.get("eax_prefix") else bytes([response_service]) + parameter, + ) + + +def parse_obdb_profile(data: dict[str, Any], profile_id: str = "") -> VehicleProfile: + """Normalize a native OBDb v3 signalset into OBDyssey's model.""" + if not isinstance(data, dict): + raise OBDbProfileError("Profile must be a JSON object") + # ``commands`` is the native v3 name. ``pids`` remains accepted only for + # profiles created by the original StarPilot developer dialect. + raw_commands = data.get("commands") if "commands" in data else data.get("pids") + if not isinstance(raw_commands, list): + raise OBDbProfileError("Profile must contain a commands array") + + pid, name, provider, metadata = _profile_metadata(data, profile_id) + diagnostic_level = ( + parse_obdb_hex(data["diagnosticLevel"], "diagnosticLevel", maximum=0xFF) + if "diagnosticLevel" in data else None + ) + raw_signal_groups = data.get("signalGroups", []) + if not isinstance(raw_signal_groups, list) or any(not isinstance(group, dict) for group in raw_signal_groups): + raise OBDbProfileError("signalGroups must be an array of objects") + if "signalGroups" in data and not raw_signal_groups: + raise OBDbProfileError("signalGroups must contain at least one group") + for index, group in enumerate(raw_signal_groups): + unknown_group_fields = set(group) - {"id", "path", "matchingRegex", "suggestedMetricGroup", "name"} + if unknown_group_fields: + raise OBDbProfileError( + f"signalGroups entry {index} has unsupported fields: " + + ", ".join(sorted(unknown_group_fields)) + ) + if not isinstance(group.get("id"), str) or not group["id"].strip(): + raise OBDbProfileError(f"signalGroups entry {index} requires a non-empty id") + if not isinstance(group.get("matchingRegex"), str) or not group["matchingRegex"].strip(): + raise OBDbProfileError(f"signalGroups entry {index} requires a non-empty matchingRegex") + signal_groups = tuple(dict(group) for group in raw_signal_groups) + if diagnostic_level is not None: + metadata["diagnosticLevel"] = diagnostic_level + if "signalGroups" in data: + metadata["signalGroups"] = [dict(group) for group in signal_groups] + native_commands = [command for command in raw_commands + if isinstance(command, dict) and "cmd" in command] + legacy_commands = [command for command in raw_commands + if isinstance(command, dict) and any(key in command for key in ("service", "pid", "parameter"))] + if native_commands and legacy_commands: + raise OBDbProfileError("Profile cannot mix native OBDb and legacy command dialects") + native = bool(native_commands) + if native: + native_profile_fields = { + "diagnosticLevel", "commands", "signalGroups", "synthetics", + # Installation provenance/wrapper metadata is outside the upstream + # signalset but is intentionally accepted at this boundary. + "metadata", "id", "name", "provider", "revision", "vehicle", + } + unknown_fields = set(data) - native_profile_fields + if unknown_fields: + raise OBDbProfileError( + "Native profile has unsupported fields: " + ", ".join(sorted(unknown_fields)) + ) + commands: list[DiagnosticCommand] = [] + for index, command in enumerate(raw_commands): + if not isinstance(command, dict): + raise OBDbProfileError(f"Command {index} must be an object") + if native: + commands.append(_native_command(command, index, diagnostic_level)) + elif any(key in command for key in ("service", "pid", "parameter")): + legacy_command = dict(command) + if "protocol" in metadata and "protocol" not in legacy_command and "proto" not in legacy_command: + legacy_command["_profile_protocol"] = metadata["protocol"] + commands.append(_legacy_command(legacy_command, index)) + else: + raise OBDbProfileError(f"Command {index} is not a native OBDb command") + + all_signals: dict[str, SignalDefinition] = {} + for command in commands: + for signal in command.signals: + # A few upstream signalsets repeat an identifier in related commands. + # Keep the first canonical definition for lookup while retaining both + # command memberships for explicit command grouping. + all_signals.setdefault(signal.id, signal) + + synthetics: dict[str, SyntheticSignalDefinition | SignalDefinition] = {} + raw_synthetics = data.get("synthetics", []) + if not isinstance(raw_synthetics, list): + raise OBDbProfileError("synthetics must be an array") + for index, raw in enumerate(raw_synthetics): + if not isinstance(raw, dict): + raise OBDbProfileError(f"Synthetic signal {index} must be an object") + unknown_fields = set(raw) - {"id", "name", "path", "min", "max", "unit", "suggestedMetric", "formula"} + if unknown_fields: + raise OBDbProfileError( + f"Synthetic signal {index} has unsupported fields: {', '.join(sorted(unknown_fields))}" + ) + sid = str(raw.get("id", "")).strip() + formula = raw.get("formula") + if not sid or not isinstance(formula, dict): + raise OBDbProfileError(f"Synthetic signal {index} requires id and formula") + operation = str(formula.get("op", "")).lower() + if (operation != "ratio" or not isinstance(formula.get("a"), str) or + not isinstance(formula.get("b"), str) or not formula["a"].strip() or not formula["b"].strip()): + raise OBDbProfileError(f"Synthetic signal {sid!r} has unsupported formula") + missing = [field for field in ("name", "path", "max", "unit") if field not in raw] + if missing: + raise OBDbProfileError( + f"Synthetic signal {sid!r} is missing required fields: {', '.join(missing)}" + ) + if not isinstance(raw.get("name"), str) or not raw["name"].strip(): + raise OBDbProfileError(f"Synthetic signal {sid!r} name must be a non-empty string") + if not isinstance(raw.get("path"), str) or not raw["path"].strip(): + raise OBDbProfileError(f"Synthetic signal {sid!r} path must be a non-empty string") + if not isinstance(raw.get("unit"), str) or not raw["unit"].strip(): + raise OBDbProfileError(f"Synthetic signal {sid!r} unit must be a non-empty string") + if set(formula) - {"op", "a", "b"}: + raise OBDbProfileError(f"Synthetic signal {sid!r} formula has unsupported fields") + if sid in all_signals or sid in synthetics: + raise OBDbProfileError(f"Duplicate signal id {sid!r}") + try: + minimum = float(raw["min"]) if raw.get("min") is not None else None + maximum = float(raw["max"]) + except (TypeError, ValueError) as err: + raise OBDbProfileError(f"Synthetic signal {sid!r} bounds must be numeric") from err + if (minimum is not None and not math.isfinite(minimum)) or not math.isfinite(maximum): + raise OBDbProfileError(f"Synthetic signal {sid!r} bounds must be finite") + if minimum is not None and minimum > maximum: + raise OBDbProfileError(f"Synthetic signal {sid!r} min must not exceed max") + synthetics[sid] = SyntheticSignalDefinition( + id=sid, + name=str(raw.get("name", sid)), + path=str(raw.get("path")) if raw.get("path") is not None else None, + unit=str(raw.get("unit", "")), + suggested_metric=str(raw["suggestedMetric"]) if raw.get("suggestedMetric") is not None else None, + operation=operation, + sources=(str(formula["a"]), str(formula["b"])), + min=minimum, + max=maximum, + ) + + # Compatibility for the original developer-only top-level synthetic list. + if not raw_synthetics and isinstance(data.get("signals"), list): + for raw in data["signals"]: + signal = SignalDefinition.from_dict(raw) + if signal.synthetic: + synthetics[signal.id] = signal + else: + all_signals.setdefault(signal.id, signal) + + metadata["native"] = native + return VehicleProfile( + id=pid, + name=name, + provider=provider, + revision=str(metadata.get("source_revision", "v3")), + commands=tuple(commands), + signals=all_signals, + synthetic_signals=synthetics, + metadata=metadata, + diagnostic_level=diagnostic_level, + signal_groups=signal_groups, + ) diff --git a/starpilot/system/obdyssey/obdb_provider.py b/starpilot/system/obdyssey/obdb_provider.py new file mode 100644 index 000000000..99009d38c --- /dev/null +++ b/starpilot/system/obdyssey/obdb_provider.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +"""Small, bounded provider for trusted OBDb GitHub signalset repositories.""" + +import io +import json +import re +import urllib.request +import zipfile +from collections.abc import Callable +from pathlib import Path +from typing import Any + + +class OBDbProviderError(ValueError): + pass + + +class OBDbProvider: + MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 + MAX_EXTRACTED_BYTES = 128 * 1024 * 1024 + _SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") + + def __init__(self, cache_dir: Path | str, opener: Callable[..., Any] | None = None): + self.cache_dir = Path(cache_dir) + self._opener = opener or urllib.request.urlopen + + @staticmethod + def _validate_model_year(model_year: int | None) -> int | None: + if model_year is None: + return None + if isinstance(model_year, bool): + raise OBDbProviderError("Model year must be an integer") + try: + numeric = float(model_year) + if not numeric.is_integer(): + raise ValueError + value = int(numeric) + except (TypeError, ValueError) as err: + raise OBDbProviderError("Model year must be an integer") from err + if value < 1886 or value > 3000: + raise OBDbProviderError("Model year is outside a valid range") + return value + + @staticmethod + def _validate_slug(repository: str) -> str: + slug = repository.strip() + if not re.fullmatch(r"[A-Za-z0-9_.-]+", slug) or slug in {".", ".."}: + raise OBDbProviderError("Invalid OBDb repository slug") + return slug + + @classmethod + def _validate_revision(cls, revision: str) -> str: + revision = revision.strip() + if not cls._SHA_RE.fullmatch(revision): + raise OBDbProviderError("OBDb revisions must be reviewed full commit SHA values") + return revision.lower() + + @staticmethod + def _select_name(names: list[str], model_year: int | None) -> str: + candidates = [name for name in names if name.lower().endswith(".json")] + if not candidates: + raise OBDbProviderError("Repository contains no v3 signalset JSON") + by_stem = {Path(name).stem.lower(): name for name in candidates} + if model_year is not None: + exact = by_stem.get(str(model_year)) + if exact: + return exact + ranges: list[tuple[int, int, str]] = [] + for stem, name in by_stem.items(): + match = re.fullmatch(r"(\d{4})[-_](\d{4})", stem) + if match: + start, end = int(match.group(1)), int(match.group(2)) + if start <= model_year <= end: + ranges.append((start, end, name)) + if ranges: + # Prefer the narrowest range, then the newest start. + return min(ranges, key=lambda item: (item[1] - item[0], -item[0]))[2] + default = by_stem.get("default") + if default: + return default + # Year-only repositories are alternatives, not an invitation to guess. + # Without a known matching year there is no safe signalset to activate. + raise OBDbProviderError( + "OBDb repository has no default signalset applicable to the requested model year" + ) + + @classmethod + def _extract(cls, raw: bytes) -> tuple[dict[str, Any], str]: + if len(raw) > cls.MAX_ARCHIVE_BYTES: + raise OBDbProviderError("OBDb archive is too large") + try: + archive = zipfile.ZipFile(io.BytesIO(raw)) + except zipfile.BadZipFile as err: + raise OBDbProviderError("OBDb repository archive is invalid") from err + + total = 0 + signalsets: dict[str, bytes] = {} + license_text = "" + try: + for info in archive.infolist(): + if info.is_dir(): + continue + name = info.filename.replace("\\", "/") + if name.startswith("/") or ".." in Path(name).parts: + raise OBDbProviderError("OBDb archive contains an unsafe path") + # ZIP symlinks can point outside the extraction root on readers that + # materialize them. We never need links for signalsets. + mode = (info.external_attr >> 16) & 0o170000 + if mode == 0o120000: + raise OBDbProviderError("OBDb archive contains an unsafe symlink") + # GitHub prefixes files with -/, while unit fixtures and + # mirrors may contain the repository tree directly. Accept either + # form, but never extract arbitrary files. + parts = tuple(part for part in Path(name).parts if part not in {"."}) + signalset_index = next((i for i in range(len(parts) - 1) + if parts[i:i + 2] == ("signalsets", "v3")), None) + if signalset_index is not None and len(parts) == signalset_index + 3 and name.lower().endswith(".json"): + relative = parts[-1] + if info.file_size < 0: + raise OBDbProviderError("OBDb archive contains an invalid file size") + if info.file_size > cls.MAX_EXTRACTED_BYTES: + raise OBDbProviderError("OBDb signalset exceeds its size limit") + total += info.file_size + if total > cls.MAX_EXTRACTED_BYTES: + raise OBDbProviderError("OBDb archive expands beyond its size limit") + try: + signalsets[relative] = archive.read(info) + except (OSError, RuntimeError, zipfile.BadZipFile) as err: + raise OBDbProviderError("OBDb signalset could not be read") from err + elif parts and parts[-1].upper() in {"LICENSE", "LICENSE.TXT", "COPYING", "COPYING.TXT"}: + # Retain a bounded copy for attribution/provenance. It is metadata, + # not executable profile input, and is never required for decoding. + if info.file_size <= 1 * 1024 * 1024: + try: + license_text = archive.read(info).decode("utf-8", errors="replace")[:4096] + except (OSError, RuntimeError, zipfile.BadZipFile) as err: + raise OBDbProviderError("OBDb license file could not be read") from err + finally: + archive.close() + + if not signalsets: + raise OBDbProviderError("OBDb archive has no signalsets/v3 JSON files") + parsed: dict[str, Any] = {} + for name, value in signalsets.items(): + try: + parsed[name] = json.loads(value.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as err: + raise OBDbProviderError(f"OBDb signalset {name!r} is not valid UTF-8 JSON") from err + return parsed, license_text + + def fetch(self, repository: str, revision: str, model_year: int | None = None) -> tuple[dict[str, Any], dict[str, Any]]: + slug = self._validate_slug(repository) + sha = self._validate_revision(revision) + model_year = self._validate_model_year(model_year) + cache_root = self.cache_dir / slug / sha + cache_key = str(model_year) if model_year is not None else "default" + selected_cache = cache_root / f"{cache_key}.json" + # Keep provenance alongside each selected signalset. A repository can have + # multiple year variants, and one shared metadata file would make a later + # fetch report the wrong signalset after a restart. + metadata_cache = cache_root / f"{cache_key}.metadata.json" + legacy_metadata_cache = cache_root / "metadata.json" + try: + if selected_cache.is_file(): + with selected_cache.open("r", encoding="utf-8") as handle: + cached_data = json.load(handle) + metadata_path = metadata_cache if metadata_cache.is_file() else legacy_metadata_cache + cached_metadata = json.loads(metadata_path.read_text(encoding="utf-8")) if metadata_path.is_file() else {} + if not isinstance(cached_data, dict) or not isinstance(cached_metadata, dict): + raise OBDbProviderError("Cached OBDb profile is invalid") + return cached_data, cached_metadata + except (OSError, json.JSONDecodeError) as err: + raise OBDbProviderError("Cached OBDb profile is invalid") from err + + url = f"https://codeload.github.com/OBDb/{slug}/zip/{sha}" + request = urllib.request.Request(url, headers={"User-Agent": "StarPilot-OBDyssey/2"}) + try: + with self._opener(request, timeout=30.0) as response: + raw = response.read(self.MAX_ARCHIVE_BYTES + 1) + except Exception as err: + raise OBDbProviderError(f"Could not retrieve OBDb repository {slug}: {err}") from err + signalsets, license_text = self._extract(raw) + selected_name = self._select_name(list(signalsets), model_year) + data = signalsets[selected_name] + if not isinstance(data, dict): + raise OBDbProviderError("Selected OBDb signalset is not a JSON object") + metadata = { + "provider": "obdb", + "repository": slug, + "revision": sha, + "signalset": selected_name, + "model_year": model_year or 0, + } + if license_text: + metadata["license_text"] = license_text + if "CC BY-SA" in license_text.upper(): + metadata["license"] = "CC-BY-SA-4.0" + try: + cache_root.mkdir(parents=True, exist_ok=True) + selected_cache.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8") + metadata_cache.write_text(json.dumps(metadata, separators=(",", ":")), encoding="utf-8") + except OSError: + # A read-only cache should not make an otherwise valid offroad install + # fail; the next invocation may simply retrieve it again. + pass + return data, metadata diff --git a/starpilot/system/obdyssey/profiles.py b/starpilot/system/obdyssey/profiles.py new file mode 100644 index 000000000..1c0c04ffc --- /dev/null +++ b/starpilot/system/obdyssey/profiles.py @@ -0,0 +1,715 @@ +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import threading +import urllib.request +from dataclasses import replace +from functools import wraps +from pathlib import Path +from typing import Any + +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.starpilot.system.obdyssey.obdb import ( + DiagnosticCommand, + SignalDefinition, + VehicleProfile, + parse_obdb_profile, +) +from openpilot.starpilot.system.obdyssey.obdb_provider import OBDbProvider + +BUNDLED_SAEJ1979_PATH = Path(__file__).resolve().parents[3] / "third_party" / "obdb_saej1979" / "profile.json" +BUNDLED_SAEJ1979_REVISION = "d3259214a9e0340c4a6cff9ec5f8ff5953eee6f2" +BUNDLED_SAEJ1979_SOURCE = "https://github.com/OBDb/SAEJ1979/tree/d3259214a9e0340c4a6cff9ec5f8ff5953eee6f2/signalsets/v3/default.json" +# Repository-local corrections are explicit overrides of upstream OBDb data. +# Keep the old constant as a compatibility alias for callers of the prototype. +PROFILE_OVERRIDES_DIR = Path(__file__).resolve().parent / "curated_profiles" +CURATED_PROFILES_DIR = PROFILE_OVERRIDES_DIR +DEFAULT_DATA_DIR = Path("/data/obdyssey/profiles") if Path("/data").is_dir() else Path.home() / ".comma" / "obdyssey" / "profiles" +BUNDLE_FILENAME = "bundle.json" + +# The bundled upstream SAE signalset is intentionally stored and parsed in +# its native OBDb form. These aliases keep the original StarPilot developer +# IDs usable by existing dashboards/fakes without changing that snapshot or +# introducing a second profile dialect. +SAE_COMPATIBILITY_ALIASES: dict[str, str] = { + "SAE_ENGINE_RPM": "RPM", + "SAE_VEHICLE_SPEED": "VSS", + "SAE_ENGINE_COOLANT_TEMP": "ECT", + "SAE_CALCULATED_ENGINE_LOAD": "LOAD_PCT", + "SAE_THROTTLE_POSITION": "TP", + "SAE_INTAKE_AIR_TEMP": "IAT", + "SAE_MAF_AIR_FLOW": "MAF", + "SAE_FUEL_TANK_LEVEL": "FLI", + "SAE_CONTROL_MODULE_VOLTAGE": "VPWR", + "SAE_AMBIENT_AIR_TEMP": "AAT", + "SAE_HYBRID_EV_BATTERY_REMAINING": "BAT_SOC", +} + +# Mapping from StarPilot / openpilot CarFingerprint / CarModel to known OBDb profiles +VEHICLE_PROFILE_MAPPINGS: dict[str, str] = { + "CHEVROLET BOLT EV": "Chevrolet-Bolt-EV", + "CHEVROLET BOLT EUV": "Chevrolet-Bolt-EV", + "CHEVROLET_BOLT_CC_2017": "Chevrolet-Bolt-EV", + "CHEVROLET_BOLT_CC_2018_2021": "Chevrolet-Bolt-EV", + "CHEVROLET_BOLT_CC_2019_2021": "Chevrolet-Bolt-EV", + "CHEVROLET_BOLT_ACC_2022_2023": "Chevrolet-Bolt-EV", + "CHEVROLET_BOLT_ACC_2022_2023_PEDAL": "Chevrolet-Bolt-EV", + "CHEVROLET_BOLT_CC_2022_2023": "Chevrolet-Bolt-EV", + "HYUNDAI IONIQ 5": "Hyundai-Ioniq-5", + "HYUNDAI IONIQ 6": "Hyundai-Ioniq-6", + "KIA EV6": "Kia-EV6", + "TOYOTA RAV4": "Toyota-RAV4", + "TOYOTA COROLLA": "Toyota-Corolla", + "TOYOTA PRIUS": "Toyota-Prius", +} + + +def parse_model_year(value: Any) -> int | None: + """Normalize an optional IPC/profile model year without truncating input.""" + if value is None or value == "": + return None + if isinstance(value, bool): + raise ValueError("model_year must be an integer") + if value == 0 or value == "0": + return None + try: + numeric = float(value) + if not numeric.is_integer(): + raise ValueError + year = int(numeric) + except (TypeError, ValueError) as err: + raise ValueError("model_year must be an integer") from err + if not 1886 <= year <= 3000: + raise ValueError("model_year is outside a valid range") + return year + + +def _profile_locked(method): + """Serialize ProfileManager state and filesystem mutations with one RLock.""" + @wraps(method) + def locked(self, *args, **kwargs): + with self._lock: + return method(self, *args, **kwargs) + return locked + + +def load_profile_from_file(path: Path | str, profile_id: str = "") -> VehicleProfile: + with open(path, encoding="utf-8") as f: + data = json.load(f) + return parse_obdb_profile(data, profile_id=profile_id) + + +class ProfileManager: + def __init__(self, data_dir: Path | str | None = None, params: Params | None = None): + self._lock = threading.RLock() + self.data_dir = Path(data_dir) if data_dir is not None else DEFAULT_DATA_DIR + self.params = params or Params() + self._cached_profiles: dict[str, VehicleProfile] = {} + self._raw_profiles: dict[str, VehicleProfile] = {} + self._active_profile: VehicleProfile | None = None + self._obdb_provider = OBDbProvider(self.data_dir / ".obdb-cache") + # Profile and model-year choices are daemon-session state. Vehicle make + # and model remain the durable identity supplied by the existing Params. + self._selected_profile: tuple[str, tuple[str, str, int | None]] | None = None + self._session_model_year: int | None = None + self._ensure_storage() + + @staticmethod + def _valid_profile_id(profile_id: str) -> bool: + return ( + isinstance(profile_id, str) + and bool(profile_id) + and profile_id not in {".", ".."} + and "\x00" not in profile_id + and "/" not in profile_id + and "\\" not in profile_id + and Path(profile_id).name == profile_id + ) + + @classmethod + def _require_profile_id(cls, profile_id: str) -> str: + if not cls._valid_profile_id(profile_id): + raise ValueError(f"Invalid profile id: {profile_id!r}") + return profile_id + + def _ensure_storage(self) -> None: + try: + self.data_dir.mkdir(parents=True, exist_ok=True) + except Exception: + pass + + def _param_text(self, key: str) -> str: + try: + value = self.params.get(key, encoding="utf-8") + except TypeError: + value = self.params.get(key) + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value or "") + + def _vehicle_identity(self) -> tuple[str, str, int | None]: + """Return the vehicle identity used to scope a profile selection.""" + return ( + self._param_text("CarMake").upper().strip(), + self._param_text("CarModel").upper().strip(), + # Only an explicitly selected year scopes the in-memory override. A + # year inferred from installed profile metadata describes that profile, + # not the vehicle identity used to invalidate a selection. + self._session_model_year, + ) + + @_profile_locked + def model_year(self) -> int | None: + """Return the session year, falling back to active profile metadata.""" + if self._session_model_year is not None: + return self._session_model_year + if self._active_profile is not None: + return self._profile_metadata_model_year(self._active_profile) + return None + + @classmethod + def _profile_metadata_model_year(cls, profile: VehicleProfile) -> int | None: + """Use a year recorded with an installed profile, when it is valid.""" + try: + return parse_model_year(profile.metadata.get("model_year")) + except (AttributeError, TypeError, ValueError): + return None + + @staticmethod + def _add_sae_compatibility_aliases(profile: VehicleProfile) -> VehicleProfile: + """Expose legacy SAE IDs while retaining native OBDb definitions. + + The aliases point at the exact same command/format semantics as their + native counterparts. They are deliberately limited to the bundled SAE + profile and are not applied to OEM profiles, where an apparently similar + name could hide different scaling or ECU semantics. + """ + signals = dict(profile.signals) + commands = list(profile.commands) + for alias, canonical_id in SAE_COMPATIBILITY_ALIASES.items(): + canonical = signals.get(canonical_id) + if canonical is None or alias in signals: + continue + alias_signal = replace(canonical, id=alias) + signals[alias] = alias_signal + for index, command in enumerate(commands): + if any(signal.id == canonical_id for signal in command.signals): + commands[index] = replace(command, signals=command.signals + (alias_signal,)) + return replace(profile, commands=tuple(commands), signals=signals) + + @staticmethod + def _has_applicable_commands(profile: VehicleProfile | None) -> bool: + """Only treat a profile as active when it can issue a diagnostic command.""" + return profile is not None and bool(profile.commands) + + def _apply_model_year(self, profile: VehicleProfile, model_year: int | None = None) -> VehicleProfile: + """Evaluate native command filters once when a profile is loaded.""" + if not any(command.applicability is not None for command in profile.commands): + return profile + effective_year = self._session_model_year if model_year is None else model_year + if effective_year is None: + effective_year = self._profile_metadata_model_year(profile) + # A generation-specific command is unsafe when the year is unknown. Only + # commands without a filter remain eligible in that case. + commands = tuple(command for command in profile.commands + if command.applicability is None or + (effective_year is not None and command.applicability.matches(effective_year))) + # Signal definitions are command-owned in native OBDb. Do not advertise + # a signal whose only command was filtered out for this model year, while + # retaining any compatibility/top-level definitions that are not tied to + # a command. + command_signal_ids = {signal.id for command in profile.commands for signal in command.signals} + active_signal_ids = {signal.id for command in commands for signal in command.signals} + signals = { + signal_id: signal for signal_id, signal in profile.signals.items() + if signal_id not in command_signal_ids or signal_id in active_signal_ids + } + return replace(profile, commands=commands, signals=signals) + + @_profile_locked + def load_bundled_saej1979(self) -> VehicleProfile: + if "saej1979" in self._cached_profiles: + return self._apply_model_year(self._raw_profiles.get("saej1979", self._cached_profiles["saej1979"])) + if BUNDLED_SAEJ1979_PATH.is_file(): + profile = load_profile_from_file(BUNDLED_SAEJ1979_PATH, profile_id="saej1979") + profile = replace( + profile, + provider="obdb", + revision=BUNDLED_SAEJ1979_REVISION, + metadata={ + **profile.metadata, + "provider": "obdb", + "source_repository": "OBDb/SAEJ1979", + "source_path": "signalsets/v3/default.json", + "source_revision": BUNDLED_SAEJ1979_REVISION, + "source": BUNDLED_SAEJ1979_SOURCE, + "license": "CC-BY-SA-4.0", + }, + ) + profile = self._add_sae_compatibility_aliases(profile) + self._raw_profiles["saej1979"] = profile + self._cached_profiles["saej1979"] = self._apply_model_year(profile) + return self._cached_profiles["saej1979"] + # Keep a valid, inert fallback if a stripped-down desktop installation + # does not ship the snapshot. Runtime callers can still report profile + # status without accidentally treating missing data as a parse error. + profile = parse_obdb_profile({ + "metadata": { + "id": "saej1979", + "name": "SAE J1979 Standard OBD-II", + "provider": "obdb", + "revision": "unavailable", + }, + "commands": [], + }, "saej1979") + self._raw_profiles["saej1979"] = profile + self._cached_profiles["saej1979"] = profile + return profile + + @_profile_locked + def list_profiles(self) -> list[dict[str, Any]]: + profiles: list[dict[str, Any]] = [] + + # 1. Bundled standard + sae = self.load_bundled_saej1979() + profiles.append({ + "id": sae.id, + "name": sae.name, + "provider": sae.provider, + "revision": sae.revision, + "bundled": True, + "signal_count": len(sae.signals), + }) + + # 2. Curated profiles in repository + if CURATED_PROFILES_DIR.is_dir(): + for file in sorted(CURATED_PROFILES_DIR.glob("*.json")): + try: + prof = load_profile_from_file(file, profile_id=file.stem) + profiles.append({ + "id": prof.id, + "name": prof.name, + "provider": prof.provider, + "revision": prof.revision, + "bundled": True, + "signal_count": len(prof.signals), + }) + except Exception: + pass + + # 3. Installed profiles in persistent data dir + if self.data_dir.is_dir(): + for subdir in sorted(self.data_dir.iterdir()): + if (subdir / BUNDLE_FILENAME).is_file() or (subdir / "profile.json").is_file(): + try: + prof = self.get_profile(subdir.name) + if prof is None: + continue + profiles.append({ + "id": prof.id, + "name": prof.name, + "provider": prof.provider, + "revision": prof.revision, + "bundled": False, + "signal_count": len(prof.signals), + }) + except Exception: + pass + + # Installed profiles intentionally shadow a curated entry with the same + # id. Present one deterministic record to frontends instead of exposing + # duplicate choices. + unique: dict[str, dict[str, Any]] = {} + for profile in profiles: + current = unique.get(profile["id"]) + if current is None or (current.get("bundled", False) and not profile.get("bundled", False)): + unique[profile["id"]] = profile + return list(unique.values()) + + @_profile_locked + def get_profile(self, profile_id: str) -> VehicleProfile | None: + if not self._valid_profile_id(profile_id): + return None + if profile_id in self._cached_profiles: + return self._apply_model_year(self._raw_profiles.get(profile_id, self._cached_profiles[profile_id])) + + # 1. Check bundled standard + if profile_id == "saej1979": + return self.load_bundled_saej1979() + + # 2. Installed profiles take precedence over repository-local curated + # snapshots so an explicitly installed native OBDb revision is honored. + installed_dir = self.data_dir / profile_id + bundle_path = installed_dir / BUNDLE_FILENAME + installed_path = installed_dir / "profile.json" + if bundle_path.is_file() or installed_path.is_file(): + try: + if bundle_path.is_file(): + bundle = json.loads(bundle_path.read_text(encoding="utf-8")) + if not isinstance(bundle, dict) or not isinstance(bundle.get("profile"), dict): + raise ValueError("installed profile bundle is malformed") + prof = parse_obdb_profile(bundle["profile"], profile_id=profile_id) + source = bundle.get("source", {}) + if source is not None and not isinstance(source, dict): + raise ValueError("installed profile bundle source is malformed") + if isinstance(source, dict): + # The raw OBDb signalset often has no provider/revision fields. + # Restore installation provenance from the bundle so a daemon + # restart reports the same profile identity and update source. + prof = replace( + prof, + provider=str(source.get("provider") or prof.provider).lower(), + revision=str(source.get("revision") or prof.revision), + metadata={**prof.metadata, **source}, + ) + else: + prof = load_profile_from_file(installed_path, profile_id=profile_id) + metadata_path = installed_dir / "metadata.json" + if metadata_path.is_file(): + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(metadata, dict): + raise ValueError("installed profile metadata is malformed") + prof = replace( + prof, + provider=str(metadata.get("provider") or prof.provider).lower(), + revision=str(metadata.get("revision") or prof.revision), + metadata={**prof.metadata, **metadata}, + ) + self._raw_profiles[profile_id] = prof + self._cached_profiles[profile_id] = self._apply_model_year(prof) + return self._cached_profiles[profile_id] + except Exception as err: + cloudlog.error(f"Error loading installed profile {profile_id}: {err}") + + # 3. Check curated profiles + curated_path = CURATED_PROFILES_DIR / f"{profile_id}.json" + if curated_path.is_file(): + try: + prof = load_profile_from_file(curated_path, profile_id=profile_id) + self._raw_profiles[profile_id] = prof + self._cached_profiles[profile_id] = self._apply_model_year(prof) + return self._cached_profiles[profile_id] + except Exception as err: + cloudlog.error(f"Error loading curated profile {profile_id}: {err}") + + return None + + @_profile_locked + def install_profile_data(self, profile_id: str, data: dict[str, Any], metadata: dict[str, Any] | None = None) -> VehicleProfile: + """Validate, normalize, and atomically install a profile dictionary.""" + self._require_profile_id(profile_id) + self._validate_profile_data(data) + profile = parse_obdb_profile(data, profile_id=profile_id) + # Installation provenance is authoritative for an explicitly retrieved + # revision. Native signalsets commonly omit a revision in their JSON, so + # expose the pinned provider revision in the normalized runtime model. + if metadata: + profile_metadata = {**profile.metadata, **metadata} + revision = str(metadata.get("revision") or profile.revision) + profile = replace(profile, revision=revision, metadata=profile_metadata) + if not profile.commands: + raise ValueError(f"Profile {profile_id!r} must contain at least one diagnostic command") + self._ensure_storage() + + target_dir = self.data_dir / profile_id + # Profile IDs are validated above, but an existing directory could still + # be replaced by a symlink through an interrupted/manual filesystem edit. + # Refuse to follow it during staging or rollback. + if target_dir.is_symlink() or (target_dir.exists() and not target_dir.is_dir()): + raise ValueError(f"Profile target {profile_id!r} is not a regular directory") + temp_dir = Path(tempfile.mkdtemp(prefix="obd_prof_", dir=self.data_dir)) + profile_path = target_dir / "profile.json" + metadata_path = target_dir / "metadata.json" + bundle_path = target_dir / BUNDLE_FILENAME + old_profile = profile_path.read_bytes() if profile_path.is_file() else None + old_metadata = metadata_path.read_bytes() if metadata_path.is_file() else None + old_bundle = bundle_path.read_bytes() if bundle_path.is_file() else None + try: + with open(temp_dir / "profile.json", "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + # Always stage metadata. An empty object deliberately clears a stale + # update source when a profile is replaced from inline data. + with open(temp_dir / "metadata.json", "w", encoding="utf-8") as f: + json.dump(metadata or {}, f, indent=2) + with open(temp_dir / BUNDLE_FILENAME, "w", encoding="utf-8") as f: + json.dump({"format_version": 1, "source": metadata or {}, "profile": data}, f, indent=2) + + target_dir.mkdir(parents=True, exist_ok=True) + # bundle.json is the authoritative single commit point. The two legacy + # mirrors remain for older readers and are replaced only after the + # bundle is safely visible. + os.replace(temp_dir / BUNDLE_FILENAME, bundle_path) + os.replace(temp_dir / "metadata.json", target_dir / "metadata.json") + os.replace(temp_dir / "profile.json", target_dir / "profile.json") + self._raw_profiles[profile_id] = profile + self._cached_profiles[profile_id] = self._apply_model_year(profile) + return profile + except Exception: + # A profile update has two files because update provenance is kept next + # to the normalized data. Restore both originals if either replacement + # fails so a last-known-good profile is never paired with new metadata. + try: + if old_bundle is None: + bundle_path.unlink(missing_ok=True) + else: + bundle_path.write_bytes(old_bundle) + if old_profile is None: + profile_path.unlink(missing_ok=True) + else: + profile_path.write_bytes(old_profile) + if old_metadata is None: + metadata_path.unlink(missing_ok=True) + else: + metadata_path.write_bytes(old_metadata) + except Exception as restore_err: + cloudlog.error(f"Could not restore profile {profile_id!r} after failed update: {restore_err}") + raise + finally: + if temp_dir.exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + + @staticmethod + def _validate_profile_data(data: dict[str, Any]) -> None: + """Check the required OBDb command/signal shape before touching storage.""" + if not isinstance(data, dict): + raise ValueError("Profile data must be a JSON object") + + raw_commands = data.get("commands") + if raw_commands is None: + raw_commands = data.get("pids") + if not isinstance(raw_commands, list) or not raw_commands: + raise ValueError("Profile must contain a non-empty commands or pids list") + + for index, command in enumerate(raw_commands): + if not isinstance(command, dict): + raise ValueError(f"Profile command {index} must be an object") + if "signals" in command and not isinstance(command["signals"], list): + raise ValueError(f"Profile command {index} signals must be a list") + for signal_index, signal in enumerate(command.get("signals", [])): + if not isinstance(signal, dict) or not str(signal.get("id", "")).strip(): + raise ValueError(f"Profile command {index} signal {signal_index} must have an id") + + raw_signals = data.get("signals", []) + if not isinstance(raw_signals, list): + raise ValueError("Profile signals must be a list") + for index, signal in enumerate(raw_signals): + if not isinstance(signal, dict) or not str(signal.get("id", "")).strip(): + raise ValueError(f"Profile signal {index} must have an id") + + @staticmethod + def _load_profile_source(source: str) -> dict[str, Any]: + """Load a JSON profile from a local file/directory or an explicit URL.""" + source = source.strip() + if not source: + raise ValueError("A profile source is required") + + if source.startswith(("http://", "https://")): + request = urllib.request.Request(source, headers={"User-Agent": "StarPilot-OBDyssey/1"}) + with urllib.request.urlopen(request, timeout=30.0) as response: + raw = response.read(8 * 1024 * 1024 + 1) + if len(raw) > 8 * 1024 * 1024: + raise ValueError("Profile source is too large") + data = json.loads(raw.decode("utf-8")) + else: + source_path = Path(source) + if source_path.is_dir(): + source_path /= "profile.json" + with open(source_path, encoding="utf-8") as f: + data = json.load(f) + + if not isinstance(data, dict): + raise ValueError("Profile source must contain a JSON object") + return data + + @_profile_locked + def install_profile_source(self, source: str, profile_id: str = "", provider: str = "", + metadata: dict[str, Any] | None = None) -> VehicleProfile: + """Fetch, validate, and install a profile while retaining its update source.""" + data = self._load_profile_source(source) + source_metadata = dict(metadata or {}) + source_metadata["source"] = source + if provider: + source_metadata.setdefault("provider", provider) + + raw_meta = data.get("metadata") + if not profile_id and isinstance(raw_meta, dict): + profile_id = str(raw_meta.get("id", "")) + if not profile_id: + raise ValueError("Profile source does not define a profile id") + return self.install_profile_data(profile_id, data, source_metadata) + + @_profile_locked + def install_obdb_profile(self, repository: str, revision: str, model_year: int | None = None) -> VehicleProfile: + """Retrieve one reviewed OBDb signalset, normalize it, and cache it.""" + effective_year = self.model_year() if model_year is None else parse_model_year(model_year) + data, provider_metadata = self._obdb_provider.fetch(repository, revision, effective_year) + profile_id = repository.strip() + metadata = { + **provider_metadata, + "source": f"obdb:{repository}@{revision}", + } + installed = self.install_profile_data(profile_id, data, metadata) + if effective_year is not None: + # Apply the explicitly requested year to this installed profile. The + # provider metadata records it with the profile for future updates. + self._cached_profiles[profile_id] = self._apply_model_year(self._raw_profiles[profile_id], effective_year) + installed = self._cached_profiles[profile_id] + return installed + + @_profile_locked + def update_profile(self, profile_id: str) -> VehicleProfile: + """Refresh an installed profile from the source recorded at installation.""" + self._require_profile_id(profile_id) + metadata_path = self.data_dir / profile_id / "metadata.json" + bundle_path = self.data_dir / profile_id / BUNDLE_FILENAME + try: + if bundle_path.is_file(): + bundle = json.loads(bundle_path.read_text(encoding="utf-8")) + metadata = bundle.get("source", {}) if isinstance(bundle, dict) else {} + else: + if not metadata_path.is_file(): + raise RuntimeError(f"Profile {profile_id!r} has no update source") + with open(metadata_path, encoding="utf-8") as f: + metadata = json.load(f) + except (OSError, json.JSONDecodeError) as err: + raise RuntimeError(f"Profile {profile_id!r} metadata is invalid") from err + if not isinstance(metadata, dict): + raise RuntimeError(f"Profile {profile_id!r} metadata is invalid") + if not isinstance(metadata.get("source"), str) and not ( + metadata.get("provider") == "obdb" and metadata.get("repository") and metadata.get("revision") + ): + raise RuntimeError(f"Profile {profile_id!r} has no update source") + if metadata.get("provider") == "obdb" and metadata.get("repository") and metadata.get("revision"): + try: + stored_year = parse_model_year(metadata.get("model_year")) + except ValueError as err: + raise RuntimeError(f"Profile {profile_id!r} metadata has an invalid model year") from err + return self.install_obdb_profile(str(metadata["repository"]), str(metadata["revision"]), stored_year) + return self.install_profile_source(metadata["source"], profile_id=profile_id, + provider=str(metadata.get("provider", "")), metadata=metadata) + + @_profile_locked + def remove_profile(self, profile_id: str) -> bool: + self._require_profile_id(profile_id) + if profile_id == "saej1979": + raise RuntimeError("Cannot remove bundled SAE J1979 profile") + target_dir = self.data_dir / profile_id + self._cached_profiles.pop(profile_id, None) + self._raw_profiles.pop(profile_id, None) + if self._selected_profile is not None and self._selected_profile[0] == profile_id: + self._selected_profile = None + self._session_model_year = None + self._active_profile = None + elif self._active_profile is not None and self._active_profile.id == profile_id: + self._active_profile = None + if target_dir.exists() or target_dir.is_symlink(): + if target_dir.is_symlink(): + target_dir.unlink(missing_ok=True) + else: + shutil.rmtree(target_dir, ignore_errors=True) + return True + return False + + @_profile_locked + def select_profile(self, profile_id: str, model_year: int | None = None) -> VehicleProfile: + """Select a profile and optional model year for this daemon session.""" + self._require_profile_id(profile_id) + previous_year = self._session_model_year + self._session_model_year = parse_model_year(model_year) + try: + prof = self.get_profile(profile_id) + if not self._has_applicable_commands(prof): + raise KeyError(f"Profile {profile_id} not found") + except Exception: + self._session_model_year = previous_year + raise + self._selected_profile = (profile_id, self._vehicle_identity()) + self._active_profile = prof + return prof + + @_profile_locked + def resolve_active_profile(self, explicit_id: str | None = None) -> VehicleProfile: + """Resolve an explicit/session profile, then exact vehicle mapping, then SAE J1979.""" + if explicit_id: + prof = self.get_profile(explicit_id) + if self._has_applicable_commands(prof): + self._active_profile = prof + return prof + + # An explicitly requested but unavailable profile must never silently + # resolve to a different OEM profile. + cloudlog.warning(f"OBDyssey profile {explicit_id!r} is unavailable; using SAE J1979") + prof = self.load_bundled_saej1979() + self._active_profile = prof + return prof + + vehicle_identity = self._vehicle_identity() + selection = self._selected_profile + if selection is not None: + selected_id, selected_vehicle = selection + if selected_vehicle == vehicle_identity: + prof = self.get_profile(selected_id) + if self._has_applicable_commands(prof): + self._active_profile = prof + return prof + cloudlog.warning( + f"Selected OBDyssey profile {selected_id!r} is unavailable; deriving a profile" + ) + else: + cloudlog.info("OBDyssey vehicle settings changed; clearing session profile selection") + self._selected_profile = None + self._session_model_year = None + + # Check exact StarPilot fingerprint/model values only. Fuzzy OEM matches + # can select the wrong diagnostic headers and are unsafe. + car_make, car_model, _model_year = vehicle_identity + exact_keys = [car_model] + if car_make and car_model: + exact_keys.append(f"{car_make} {car_model}") + mapped_id = next((VEHICLE_PROFILE_MAPPINGS[key] for key in exact_keys if key in VEHICLE_PROFILE_MAPPINGS), None) + + if mapped_id: + prof = self.get_profile(mapped_id) + if self._has_applicable_commands(prof): + self._active_profile = prof + return prof + + # Fallback to standard SAE J1979 + prof = self.load_bundled_saej1979() + self._active_profile = prof + return prof + + @_profile_locked + def enhanced_profile_available(self) -> bool: + """Whether the exact mapped OEM profile is installed locally.""" + car_make, car_model, _model_year = self._vehicle_identity() + keys = [car_model] + if car_make and car_model: + keys.append(f"{car_make} {car_model}") + mapped_id = next((VEHICLE_PROFILE_MAPPINGS[key] for key in keys if key in VEHICLE_PROFILE_MAPPINGS), None) + return bool(mapped_id and self._has_applicable_commands(self.get_profile(mapped_id))) + + @staticmethod + def group_signals_by_command( + profile: VehicleProfile, signal_ids: list[str], model_year: int | None = None, + ) -> list[tuple[DiagnosticCommand, list[SignalDefinition]]]: + """Group requested signal IDs by their parent DiagnosticCommand to execute each command exactly ONCE.""" + requested_set = set(signal_ids) + grouped: list[tuple[DiagnosticCommand, list[SignalDefinition]]] = [] + + for cmd in profile.commands: + # A filtered command is only eligible when a known model year matches. + # This mirrors _apply_model_year and keeps direct callers conservative + # when they operate on an unfiltered profile. + if cmd.applicability is not None and (model_year is None or not cmd.applicability.matches(model_year)): + continue + matching_signals = [sig for sig in cmd.signals if sig.id in requested_set] + if matching_signals: + grouped.append((cmd, matching_signals)) + requested_set.difference_update(sig.id for sig in matching_signals) + + return grouped diff --git a/starpilot/system/obdyssey/protocol.py b/starpilot/system/obdyssey/protocol.py new file mode 100644 index 000000000..66e198d89 --- /dev/null +++ b/starpilot/system/obdyssey/protocol.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import json +import os +import socket +from dataclasses import asdict, dataclass +from typing import Any + +OBDYSSEY_SOCKET_PATH = "/tmp/starpilot-obdyssey.sock" +API_VERSION = 2 +MAX_REQUEST_BYTES = 1 * 1024 * 1024 +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + +COMMAND_TIMEOUTS = { + "status": 5.0, + "connect": 35.0, + "disconnect": 10.0, + "test_adapter": 15.0, + "adapter_info": 5.0, + "vehicle_info": 10.0, + "list_signals": 5.0, + "read_signal": 10.0, + "read_signals": 15.0, + "read_dtcs": 15.0, + "read_pending_dtcs": 15.0, + "read_permanent_dtcs": 15.0, + "read_all_dtcs": 30.0, + "read_freeze_frame": 10.0, + "read_uds_dtcs": 15.0, + "clear_dtcs": 15.0, + "clear_uds_dtcs": 15.0, + "uds_request": 15.0, + "raw_request": 15.0, + "debug_at_command": 10.0, + "profile_status": 5.0, + "install_profile": 30.0, + "update_profile": 30.0, + "select_profile": 10.0, + "remove_profile": 10.0, +} + + +@dataclass(frozen=True) +class OBDysseyStatus: + api_version: int = API_VERSION + state: str = "disabled" + enabled: bool = False + bluetooth_enabled: bool = False + adapter_address: str = "" + adapter_name: str = "" + connected: bool = False + elm_identity: str = "" + adapter_voltage: float | None = None + protocol: str = "" + profile: str = "saej1979" + signal_count: int = 0 + last_error: str = "" + last_request_ms: int = 0 + requests: int = 0 + errors: int = 0 + reconnects: int = 0 + link_connected: bool = False + diagnostic_ready: bool = False + configured_adapter: bool = False + profile_provider: str = "" + profile_id: str = "" + profile_revision: str = "" + model_year: int = 0 + generic_signal_count: int = 0 + oem_signal_count: int = 0 + enhanced_profile_available: bool = False + last_warning: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OBDysseyStatus: + connected = bool(data.get("connected", False)) + return cls( + api_version=int(data.get("api_version", API_VERSION)), + state=str(data.get("state", "disabled")), + enabled=bool(data.get("enabled", False)), + bluetooth_enabled=bool(data.get("bluetooth_enabled", False)), + adapter_address=str(data.get("adapter_address", "")), + adapter_name=str(data.get("adapter_name", "")), + connected=connected, + link_connected=bool(data.get("link_connected", connected)), + diagnostic_ready=bool(data.get("diagnostic_ready", connected)), + elm_identity=str(data.get("elm_identity", "")), + adapter_voltage=float(data["adapter_voltage"]) if data.get("adapter_voltage") is not None else None, + protocol=str(data.get("protocol", "")), + profile=str(data.get("profile", "saej1979")), + signal_count=int(data.get("signal_count", 0)), + last_error=str(data.get("last_error", "")), + last_request_ms=int(data.get("last_request_ms", 0)), + requests=int(data.get("requests", 0)), + errors=int(data.get("errors", 0)), + reconnects=int(data.get("reconnects", 0)), + configured_adapter=bool(data.get("configured_adapter", bool(data.get("adapter_address", "")))), + profile_provider=str(data.get("profile_provider", "")), + profile_id=str(data.get("profile_id", data.get("profile", ""))), + profile_revision=str(data.get("profile_revision", "")), + model_year=int(data.get("model_year", 0) or 0), + generic_signal_count=int(data.get("generic_signal_count", 0)), + oem_signal_count=int(data.get("oem_signal_count", 0)), + enhanced_profile_available=bool(data.get("enhanced_profile_available", False)), + last_warning=str(data.get("last_warning", "")), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +class OBDysseyClient: + def __init__(self, socket_path: str = OBDYSSEY_SOCKET_PATH, timeout: float = 5.0): + self.socket_path = socket_path + self.timeout = timeout + + def call(self, cmd: str, **payload: Any) -> dict[str, Any]: + request_data = {"command": cmd, **payload} + request_bytes = json.dumps(request_data, separators=(",", ":")).encode("utf-8") + b"\n" + if len(request_bytes) > MAX_REQUEST_BYTES: + raise ValueError(f"OBDyssey request exceeds {MAX_REQUEST_BYTES} bytes") + + cmd_timeout = max(self.timeout, COMMAND_TIMEOUTS.get(cmd, 5.0)) + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(cmd_timeout) + sock.connect(self.socket_path) + sock.sendall(request_bytes) + + response_bytes = bytearray() + while not response_bytes.endswith(b"\n"): + chunk = sock.recv(65536) + if not chunk: + break + response_bytes.extend(chunk) + if len(response_bytes) > MAX_RESPONSE_BYTES: + raise RuntimeError(f"OBDyssey response exceeds {MAX_RESPONSE_BYTES} bytes") + + if not response_bytes: + raise RuntimeError(f"OBDyssey service returned no response for '{cmd}'") + + result = json.loads(response_bytes.decode("utf-8")) + if not result.get("ok", False): + err_msg = result.get("error", "OBDyssey operation failed") + err_type = result.get("error_type", "RuntimeError") + raise RuntimeError(f"[{err_type}] {err_msg}") + + return result + + def status(self) -> OBDysseyStatus: + if not os.path.exists(self.socket_path): + return OBDysseyStatus(state="disabled", enabled=False) + res = self.call("status") + return OBDysseyStatus.from_dict(res.get("status", {})) + + def connect(self, address: str = "") -> dict[str, Any]: + return self.call("connect", **({"address": address} if address else {})) + + def disconnect(self) -> dict[str, Any]: + return self.call("disconnect") + + def clear_adapter(self) -> dict[str, Any]: + return self.call("clear_adapter") + + def set_enabled(self, enabled: bool) -> dict[str, Any]: + if not isinstance(enabled, bool): + raise TypeError("enabled must be a bool") + return self.call("set_enabled", enabled=enabled) + + def test_adapter(self) -> dict[str, Any]: + return self.call("test_adapter") + + def adapter_info(self) -> dict[str, Any]: + return self.call("adapter_info") + + def vehicle_info(self) -> dict[str, Any]: + return self.call("vehicle_info") + + def list_signals(self) -> list[dict[str, Any]]: + res = self.call("list_signals") + return res.get("signals", []) + + def read_signal(self, signal_id: str) -> dict[str, Any]: + res = self.call("read_signal", id=signal_id) + return res.get("signal", {}) + + def read_signals(self, signal_ids: list[str]) -> dict[str, Any]: + res = self.call("read_signals", ids=signal_ids) + structured = { + "signals": res.get("signals", {}), + "errors": res.get("errors", {}), + } + # Keep the original flat lookup contract for existing developer callers; + # API v2 consumers should use the explicit ``signals``/``errors`` maps. + # Values are additive and cannot collide with either reserved key because + # signal IDs are profile-defined and validated independently. + for signal_id, value in structured["signals"].items(): + if signal_id not in structured: + structured[signal_id] = value + return structured + + def read_dtcs(self) -> list[dict[str, Any]]: + res = self.call("read_dtcs") + return res.get("dtcs", []) + + def read_pending_dtcs(self) -> list[dict[str, Any]]: + res = self.call("read_pending_dtcs") + return res.get("dtcs", []) + + def read_permanent_dtcs(self) -> list[dict[str, Any]]: + res = self.call("read_permanent_dtcs") + return res.get("dtcs", []) + + def read_all_dtcs(self) -> list[dict[str, Any]]: + res = self.call("read_all_dtcs") + return res.get("dtcs", []) + + def read_freeze_frame(self, pid: int, frame: int = 0) -> dict[str, Any]: + return self.call("read_freeze_frame", pid=pid, frame=frame) + + def read_uds_dtcs(self, tx_addr: str, rx_addr: str) -> list[dict[str, Any]]: + res = self.call("read_uds_dtcs", tx_addr=tx_addr, rx_addr=rx_addr) + return res.get("dtcs", []) + + def clear_dtcs(self) -> dict[str, Any]: + return self.call("clear_dtcs") + + def clear_uds_dtcs(self, tx_addr: str, rx_addr: str) -> dict[str, Any]: + return self.call("clear_uds_dtcs", tx_addr=tx_addr, rx_addr=rx_addr) + + def uds_request(self, tx_addr: str, rx_addr: str, payload: str, protocol: str | None = None, + timeout: float | None = None, priority: str | int | None = None, + response_priority: str | int | None = None) -> dict[str, Any]: + kwargs: dict[str, Any] = {"tx_addr": tx_addr, "rx_addr": rx_addr, "payload": payload} + if protocol is not None: + kwargs["protocol"] = protocol + if timeout is not None: + kwargs["timeout"] = timeout + if priority is not None: + kwargs["priority"] = priority + if response_priority is not None: + kwargs["response_priority"] = response_priority + return self.call("uds_request", **kwargs) + + def raw_request(self, tx_addr: str, rx_addr: str, payload: str, protocol: str | None = None, + priority: str | int | None = None, response_priority: str | int | None = None) -> dict[str, Any]: + kwargs: dict[str, Any] = {"tx_addr": tx_addr, "rx_addr": rx_addr, "payload": payload} + if protocol is not None: + kwargs["protocol"] = protocol + if priority is not None: + kwargs["priority"] = priority + if response_priority is not None: + kwargs["response_priority"] = response_priority + return self.call("raw_request", **kwargs) + + def debug_at_command(self, command: str) -> dict[str, Any]: + return self.call("debug_at_command", command_str=command) + + def profile_status(self) -> dict[str, Any]: + return self.call("profile_status") + + def install_profile(self, provider: str = "", repository: str = "", profile_id: str = "", + data: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, + revision: str = "", model_year: int | None = None) -> dict[str, Any]: + kwargs: dict[str, Any] = {"provider": provider, "repository": repository} + if profile_id: + kwargs["profile_id"] = profile_id + if data is not None: + kwargs["data"] = data + if metadata is not None: + kwargs["metadata"] = metadata + if revision: + kwargs["revision"] = revision + if model_year is not None: + kwargs["model_year"] = model_year + return self.call("install_profile", **kwargs) + + def install_obdb_profile(self, repository: str, revision: str, model_year: int | None = None) -> dict[str, Any]: + """Install a reviewed native OBDb signalset through the provider path.""" + return self.install_profile(provider="obdb", repository=repository, revision=revision, model_year=model_year) + + def update_profile(self, profile_id: str = "") -> dict[str, Any]: + return self.call("update_profile", **({"profile_id": profile_id} if profile_id else {})) + + def select_profile(self, profile_id: str, model_year: int | None = None) -> dict[str, Any]: + kwargs: dict[str, Any] = {"profile_id": profile_id} + if model_year is not None: + kwargs["model_year"] = model_year + return self.call("select_profile", **kwargs) + + def remove_profile(self, profile_id: str) -> dict[str, Any]: + return self.call("remove_profile", profile_id=profile_id) diff --git a/starpilot/system/obdyssey/tests/__init__.py b/starpilot/system/obdyssey/tests/__init__.py new file mode 100644 index 000000000..2e0152a48 --- /dev/null +++ b/starpilot/system/obdyssey/tests/__init__.py @@ -0,0 +1 @@ +# OBDyssey test suite diff --git a/starpilot/system/obdyssey/tests/test_daemon.py b/starpilot/system/obdyssey/tests/test_daemon.py new file mode 100644 index 000000000..a36e965d2 --- /dev/null +++ b/starpilot/system/obdyssey/tests/test_daemon.py @@ -0,0 +1,547 @@ +import threading +import time +import pytest + +from openpilot.starpilot.system.bluetooth.protocol import BluetoothDevice, BluetoothStatus +from openpilot.starpilot.system.bluetooth.tests.test_bluetooth import FakeParams +from openpilot.starpilot.system.obdyssey.daemon import OBDysseyController, OBDysseyServer +from openpilot.starpilot.system.obdyssey.profiles import ProfileManager +from openpilot.starpilot.system.obdyssey.protocol import OBDysseyClient +from openpilot.starpilot.system.obdyssey.transport import FakeElmTransport + + +class FakeBluetoothClient: + def __init__(self, devices: list[BluetoothDevice] | None = None, enabled: bool = True): + self._enabled = enabled + self._devices = devices or [ + BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDLink MX+", paired=True, trusted=True, serial=True), + ] + + def status(self) -> BluetoothStatus: + return BluetoothStatus( + available=True, + enabled=self._enabled, + powered=self._enabled, + devices=tuple(self._devices), + ) + + +class BlockingTransport(FakeElmTransport): + def __init__(self): + super().__init__() + self.connect_started = threading.Event() + self.allow_connect = threading.Event() + + def connect(self) -> None: + self.connect_started.set() + self.allow_connect.wait(timeout=2.0) + super().connect() + + +@pytest.fixture +def obdyssey_service(tmp_path): + socket_path = str(tmp_path / "obdyssey.sock") + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + bt_client = FakeBluetoothClient() + + # Create fake transport with realistic mock responses + fake_transport = FakeElmTransport(default_responses={ + "010C": "41 0C 1F 40\r\n>", # 2000 rpm + "010D": "41 0D 41\r\n>", # 65 km/h + "0902": "49 02 01 31 47 31 46 58 36 53 30 35 48 34 31 30 30 30 30 30\r\n>", + "03": "43 01 33 03 00\r\n>", + "04": "44\r\n>", + "22F190": "62 F1 90 31 47 31 46\r\n>", + "ATRV": "13.9V\r\n>", + }) + + controller = OBDysseyController( + params=params, + transport_factory=lambda _addr: fake_transport, + profile_manager=prof_mgr, + bluetooth_client=bt_client, + sleep=lambda _delay: None, + ) + + # Start Unix socket server in background + server = OBDysseyServer(socket_path, controller) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + client = OBDysseyClient(socket_path=socket_path) + + # Wait for server socket to be active + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + try: + client.status() + break + except Exception: + time.sleep(0.02) + + yield client, controller, params, fake_transport + + server.shutdown() + server.server_close() + + +def test_daemon_status_and_explicit_connect(obdyssey_service): + client, controller, params, transport = obdyssey_service + + # First connect + client.connect() + status = client.status() + + assert status.connected is True + assert status.link_connected is True + assert status.diagnostic_ready is True + assert status.state == "ready" + assert status.adapter_address == "AA:BB:CC:DD:EE:FF" + assert status.adapter_name == "OBDLink MX+" + assert status.adapter_voltage == 13.9 + assert status.elm_identity == "ELM327 v1.5" + + +def test_daemon_read_signals_batch(obdyssey_service): + client, controller, params, transport = obdyssey_service + client.connect() + + # Read batch of standard signals + res = client.read_signals(["SAE_ENGINE_RPM", "SAE_VEHICLE_SPEED"]) + + assert res["SAE_ENGINE_RPM"] == 2000 + assert res["SAE_VEHICLE_SPEED"] == 65 + + +def test_daemon_read_dtcs_and_vin(obdyssey_service): + client, controller, params, transport = obdyssey_service + client.connect() + + # Vehicle info / VIN + vin_res = client.vehicle_info() + assert vin_res["vin"].startswith("1G1FX6") + + # Read DTCs + dtcs = client.read_dtcs() + codes = [d["code"] for d in dtcs] + assert "P0133" in codes + assert "P0300" in codes + + +def test_daemon_offroad_safety_enforcement(obdyssey_service): + client, controller, params, transport = obdyssey_service + client.connect() + + # When ONROAD: mutating operations must be rejected! + params.values["IsOffroad"] = False + + with pytest.raises(RuntimeError, match="offroad"): + client.clear_dtcs() + + with pytest.raises(RuntimeError, match="offroad"): + client.debug_at_command("ATZ") + + with pytest.raises(RuntimeError, match="offroad"): + client.uds_request("7E0", "7E8", "2EF19001") # Write DID is mutating + + # Read operations are still allowed while onroad! + rpm_res = client.read_signal("SAE_ENGINE_RPM") + assert rpm_res["value"] == 2000 + + # When OFFROAD: mutating operations succeed + params.values["IsOffroad"] = True + clear_res = client.clear_dtcs() + assert clear_res["ok"] is True + + at_res = client.debug_at_command("ATI") + assert at_res["ok"] is True + + +def test_daemon_uds_and_raw_requests(obdyssey_service): + client, controller, params, transport = obdyssey_service + client.connect() + + uds_res = client.uds_request("7E0", "7E8", "22F190") + assert uds_res["ok"] is True + assert uds_res["response"] == "62F19031473146" + + +def test_daemon_list_and_select_profile(obdyssey_service): + client, controller, params, transport = obdyssey_service + + prof_status = client.profile_status() + assert prof_status["ok"] is True + + select_res = client.select_profile("Chevrolet-Bolt-EV") + assert select_res["ok"] is True + assert select_res["active_profile"] == "Chevrolet-Bolt-EV" + + +def test_daemon_status_remains_observable_during_slow_connection(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + bt_client = FakeBluetoothClient() + transport = BlockingTransport() + controller = OBDysseyController( + params=params, + transport_factory=lambda _addr: transport, + profile_manager=prof_mgr, + bluetooth_client=bt_client, + ) + + worker = threading.Thread(target=lambda: controller.connect_adapter("AA:BB:CC:DD:EE:FF")) + worker.start() + assert transport.connect_started.wait(timeout=1.0) + + status = controller.status() + assert status["state"] == "connecting" + assert status["connected"] is False + assert status["diagnostic_ready"] is False + + transport.allow_connect.set() + worker.join(timeout=2.0) + assert not worker.is_alive() + assert controller.status()["state"] == "ready" + + +def test_daemon_is_inert_until_explicit_connect(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + bt_client = FakeBluetoothClient() + factory_calls: list[str] = [] + + def transport_factory(address): + factory_calls.append(address) + return FakeElmTransport() + + controller = OBDysseyController( + params=params, + transport_factory=transport_factory, + profile_manager=prof_mgr, + bluetooth_client=bt_client, + ) + + controller.reconnect_step() + + status = controller.status() + assert status["enabled"] is False + assert status["state"] == "idle" + assert status["connected"] is False + assert factory_calls == [] + + +def test_adapter_selection_is_session_only(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + first_transport = FakeElmTransport() + first_controller = OBDysseyController( + params=params, + transport_factory=lambda _addr: first_transport, + profile_manager=ProfileManager(data_dir=tmp_path / "profiles", params=params), + bluetooth_client=FakeBluetoothClient(), + ) + assert first_controller.connect_adapter("AA:BB:CC:DD:EE:FF") + + reconnect_calls: list[str] = [] + second_controller = OBDysseyController( + params=params, + transport_factory=lambda address: reconnect_calls.append(address) or FakeElmTransport(), + profile_manager=ProfileManager(data_dir=tmp_path / "profiles", params=params), + bluetooth_client=FakeBluetoothClient(), + ) + second_controller.reconnect_step() + + assert reconnect_calls == [] + assert second_controller.status()["adapter_address"] == "" + + +def test_no_address_connect_refuses_ambiguous_adapter_selection(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + bt_client = FakeBluetoothClient(devices=[ + BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDLink MX+", paired=True, trusted=True, serial=True), + BluetoothDevice("11:22:33:44:55:66", "Vgate iCar", paired=True, trusted=True, serial=True), + ]) + factory_calls: list[str] = [] + + def transport_factory(address): + factory_calls.append(address) + return FakeElmTransport() + + controller = OBDysseyController( + params=params, + transport_factory=transport_factory, + profile_manager=prof_mgr, + bluetooth_client=bt_client, + ) + + assert controller.connect_adapter() is False + + status = controller.status() + assert status["state"] == "error" + assert "select" in status["last_error"].lower() + assert factory_calls == [] + + +def test_diagnostic_request_does_not_probe_without_active_adapter(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + factory_calls: list[str] = [] + + def transport_factory(address): + factory_calls.append(address) + return FakeElmTransport() + + controller = OBDysseyController( + params=params, + transport_factory=transport_factory, + profile_manager=prof_mgr, + bluetooth_client=FakeBluetoothClient(), + ) + + with pytest.raises(RuntimeError, match="active OBD adapter"): + controller.handle({"command": "read_signal", "id": "SAE_ENGINE_RPM"}) + + assert factory_calls == [] + + +def test_bluetooth_disable_tears_down_active_diagnostic_link(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + transport = FakeElmTransport() + factory_calls: list[str] = [] + + def transport_factory(address): + factory_calls.append(address) + return transport + + controller = OBDysseyController( + params=params, + transport_factory=transport_factory, + profile_manager=prof_mgr, + bluetooth_client=FakeBluetoothClient(), + ) + assert controller.connect_adapter("AA:BB:CC:DD:EE:FF") + + params.values["BluetoothEnabled"] = False + controller.reconnect_step() + + assert controller.status()["state"] == "idle" + assert controller.status()["link_connected"] is False + assert transport.connected is False + + params.values["BluetoothEnabled"] = True + controller.reconnect_step() + assert factory_calls == ["AA:BB:CC:DD:EE:FF", "AA:BB:CC:DD:EE:FF"] + + +def test_profile_selection_is_vehicle_scoped_across_vehicle_changes(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True, + CarMake="Toyota", CarModel="RAV4") + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + controller = OBDysseyController( + params=params, + profile_manager=prof_mgr, + bluetooth_client=FakeBluetoothClient(), + ) + + result = controller.handle({ + "command": "select_profile", + "profile_id": "Chevrolet-Bolt-EV", + "model_year": 2023, + }) + + assert result["active_profile"] == "Chevrolet-Bolt-EV" + + params.values["CarMake"] = "Subaru" + params.values["CarModel"] = "Forester" + # The selection is session-scoped and must not follow the device to an + # unrelated vehicle. + assert controller.status()["profile"] == "saej1979" + assert prof_mgr.model_year() is None + + +def test_read_request_reconnects_once_in_controller(tmp_path): + class DisconnectingReadTransport(FakeElmTransport): + def __init__(self, disconnect_on_read: bool): + super().__init__(default_responses={"010C": "41 0C 1F 40\r\n>"}) + self.disconnect_on_read = disconnect_on_read + + def write(self, data): + if self.disconnect_on_read and data.decode("ascii").strip().upper() == "010C": + self.disconnect_on_read = False + self.connected = False + raise ConnectionResetError("simulated read disconnect") + super().write(data) + + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + transports = [DisconnectingReadTransport(True), DisconnectingReadTransport(False)] + created: list[str] = [] + + def transport_factory(address): + created.append(address) + return transports.pop(0) + + controller = OBDysseyController( + params=params, + transport_factory=transport_factory, + profile_manager=prof_mgr, + bluetooth_client=FakeBluetoothClient(), + ) + assert controller.connect_adapter("AA:BB:CC:DD:EE:FF") + assert controller.status()["adapter_address"] == "AA:BB:CC:DD:EE:FF" + + result = controller.handle({"command": "read_signal", "id": "SAE_ENGINE_RPM"}) + + assert result["signal"]["value"] == 2000 + assert created == ["AA:BB:CC:DD:EE:FF", "AA:BB:CC:DD:EE:FF"] + + +def test_mutating_disconnect_is_not_replayed(tmp_path): + class DisconnectingClearTransport(FakeElmTransport): + def __init__(self): + super().__init__(default_responses={"04": "44\r\n>"}) + self.clear_attempts = 0 + + def write(self, data): + if data.decode("ascii").strip().upper() == "04": + self.clear_attempts += 1 + self.connected = False + raise ConnectionResetError("ambiguous clear disconnect") + super().write(data) + + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + transport = DisconnectingClearTransport() + controller = OBDysseyController( + params=params, + transport_factory=lambda _addr: transport, + profile_manager=prof_mgr, + bluetooth_client=FakeBluetoothClient(), + ) + assert controller.connect_adapter("AA:BB:CC:DD:EE:FF") + + with pytest.raises(Exception, match="disconnect"): + controller.handle({"command": "clear_dtcs"}) + + assert transport.clear_attempts == 1 + + +def test_read_response_pending_is_not_replayed_by_controller(tmp_path): + class PendingThenSuccessTransport(FakeElmTransport): + def __init__(self): + super().__init__(default_responses={ + "22F190": "7F 22 78\r\n62 F1 90 31 47 31 46\r\n>", + }) + self.read_attempts = 0 + + def write(self, data): + if data.decode("ascii").strip().upper() == "22F190": + self.read_attempts += 1 + super().write(data) + + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + transport = PendingThenSuccessTransport() + controller = OBDysseyController( + params=params, + transport_factory=lambda _addr: transport, + profile_manager=prof_mgr, + bluetooth_client=FakeBluetoothClient(), + sleep=lambda _delay: None, + ) + assert controller.connect_adapter("AA:BB:CC:DD:EE:FF") + + result = controller.handle({ + "command": "uds_request", + "tx_addr": "7E0", + "rx_addr": "7E8", + "payload": "22F190", + }) + + assert result["response"] == "62F19031473146" + assert transport.read_attempts == 1 + + +def test_mutating_response_pending_is_not_replayed(tmp_path): + class PendingWriteTransport(FakeElmTransport): + def __init__(self): + super().__init__(default_responses={"2EF19001": "7F 2E 78\r\n>"}) + self.write_attempts = 0 + + def write(self, data): + if data.decode("ascii").strip().upper() == "2EF19001": + self.write_attempts += 1 + super().write(data) + + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + transport = PendingWriteTransport() + controller = OBDysseyController( + params=params, + transport_factory=lambda _addr: transport, + profile_manager=prof_mgr, + bluetooth_client=FakeBluetoothClient(), + ) + assert controller.connect_adapter("AA:BB:CC:DD:EE:FF") + + with pytest.raises(Exception, match="pending"): + controller.handle({ + "command": "uds_request", + "tx_addr": "7E0", + "rx_addr": "7E8", + "payload": "2EF19001", + }) + + assert transport.write_attempts == 1 + + +def test_uds_clear_requires_explicit_addresses(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + prof_mgr = ProfileManager(data_dir=tmp_path / "profiles", params=params) + controller = OBDysseyController( + params=params, + transport_factory=lambda _addr: FakeElmTransport(), + profile_manager=prof_mgr, + bluetooth_client=FakeBluetoothClient(), + ) + + with pytest.raises(ValueError, match="tx_addr"): + controller.handle({"command": "clear_uds_dtcs"}) + + +def test_profile_session_control_is_rejected_onroad(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + class FixedProfileManager(ProfileManager): + def resolve_active_profile(self, explicit_id=None): + return self.get_profile("session_profile") or super().resolve_active_profile(explicit_id) + + prof_mgr = FixedProfileManager(data_dir=tmp_path / "profiles", params=params) + prof_mgr.install_profile_data("session_profile", { + "metadata": {"name": "Session Profile"}, + "commands": [{ + "id": "SESSION_SIGNAL", + "service": 34, + "pid": "1234", + "din": 3, + "dout": 1, + "signals": [{"id": "SESSION_VALUE", "name": "Session value"}], + }], + }) + transport = FakeElmTransport() + controller = OBDysseyController( + params=params, + transport_factory=lambda _addr: transport, + profile_manager=prof_mgr, + bluetooth_client=FakeBluetoothClient(), + ) + + controller.connect_adapter("AA:BB:CC:DD:EE:FF") + writes_before = len(transport.writes) + params.values["IsOffroad"] = False + + with pytest.raises(RuntimeError, match="offroad"): + controller.handle({"command": "read_signal", "id": "SESSION_VALUE"}) + + assert len(transport.writes) == writes_before diff --git a/starpilot/system/obdyssey/tests/test_diagnostics.py b/starpilot/system/obdyssey/tests/test_diagnostics.py new file mode 100644 index 000000000..c88af008e --- /dev/null +++ b/starpilot/system/obdyssey/tests/test_diagnostics.py @@ -0,0 +1,249 @@ +import pytest +from openpilot.starpilot.system.obdyssey.diagnostics import ( + DEFAULT_OBD_CONTEXT, + SERVICE_TYPE, + SESSION_TYPE, + clear_dtcs, + is_mutating_service, + is_read_only_payload, + is_read_only_service, + parse_standard_dtcs, + parse_uds_dtcs, + read_current_data, + read_freeze_frame, + read_pending_dtcs, + read_permanent_dtcs, + read_stored_dtcs, + read_vin, + uds_clear_diagnostic_information, + uds_diagnostic_session_control, + uds_ecu_reset, + uds_input_output_control, + uds_read_data_by_identifier, + uds_read_dtc_information, + uds_routine_control, + uds_security_access, + uds_tester_present, + uds_write_data_by_identifier, +) +from openpilot.starpilot.system.obdyssey.elm327 import Elm327, ElmNoDataError +from openpilot.starpilot.system.obdyssey.transport import FakeElmTransport + + +def test_standard_dtc_parsing(): + # Raw bytes: 43 (Mode 03 resp) + 01 33 (P0133) + 40 35 (C0035) + 80 01 (B0001) + C1 00 (U0100) + 00 00 (padding) + raw = bytes([0x43, 0x01, 0x33, 0x40, 0x35, 0x80, 0x01, 0xC1, 0x00, 0x00, 0x00]) + dtcs = parse_standard_dtcs(raw, source="OBD_STORED") + + codes = [d.code for d in dtcs] + assert codes == ["P0133", "C0035", "B0001", "U0100"] + assert dtcs[0].source == "OBD_STORED" + assert dtcs[0].raw == bytes([0x01, 0x33]) + + +def test_uds_dtc_parsing(): + # Positive response 0x59 02 FF followed by 24-bit DTC + status records. + raw = bytes([0x59, 0x02, 0xFF, 0x00, 0x01, 0x33, 0x24, 0xC1, 0x00, 0x2F, 0x2A]) + dtcs = parse_uds_dtcs(raw, ecu="7E0") + + assert len(dtcs) == 2 + assert dtcs[0].code == "000133" + assert dtcs[0].status == 0x24 + assert dtcs[0].ecu == "7E0" + assert dtcs[1].code == "C1002F" + assert dtcs[1].status == 0x2A + + +def test_standard_obd_services(): + transport = FakeElmTransport(default_responses={ + "010C": "41 0C 1F 40\r\n>", + "020C00": "42 0C 00 1F 40\r\n>", + "03": "43 01 33 03 00\r\n>", + "07": "47 01 71\r\n>", + "0A": "4A 04 20\r\n>", + "04": "44\r\n>", + "0902": "49 02 01 31 47 31 46 58 36 53 30 35 48 34 31 30 30 30 30 30\r\n>", + }) + transport.connect() + elm = Elm327(transport) + + # Mode 01 + res_01 = read_current_data(elm, 0x0C) + assert res_01.payload == bytes([0x41, 0x0C, 0x1F, 0x40]) + + # Mode 02 + res_02 = read_freeze_frame(elm, 0x0C, 0) + assert res_02.payload == bytes([0x42, 0x0C, 0x00, 0x1F, 0x40]) + + # Mode 03 / 07 / 0A + dtcs_stored = read_stored_dtcs(elm) + assert [d.code for d in dtcs_stored] == ["P0133", "P0300"] + + dtcs_pending = read_pending_dtcs(elm) + assert [d.code for d in dtcs_pending] == ["P0171"] + + dtcs_perm = read_permanent_dtcs(elm) + assert [d.code for d in dtcs_perm] == ["P0420"] + + # Mode 04 + res_04 = clear_dtcs(elm) + assert res_04.payload == bytes([0x44]) + + # Mode 09 VIN + vin = read_vin(elm) + assert len(vin) == 17 + assert vin.startswith("1G1FX6") + + +def test_functional_obd_keeps_responses_from_multiple_ecus(): + transport = FakeElmTransport(default_responses={ + "010C": "41 0C 1F 40\r\n41 0C 20 00\r\n>", + }) + transport.connect() + elm = Elm327(transport) + + response = read_current_data(elm, 0x0C) + + assert DEFAULT_OBD_CONTEXT.rx_filter is None + assert response.responses == ( + bytes.fromhex("410C1F40"), + bytes.fromhex("410C2000"), + ) + assert response.payload == bytes.fromhex("410C1F40") + assert "ATCRA7E8" not in [w.decode("ascii").strip() for w in transport.writes] + + +def test_functional_dtc_reads_aggregate_multiple_ecus(): + transport = FakeElmTransport(default_responses={ + "03": "7E8 04 43 01 33 00\r\n7E9 04 43 03 00 00\r\n>", + }) + transport.connect() + elm = Elm327(transport) + + dtcs = read_stored_dtcs(elm) + + assert [dtc.code for dtc in dtcs] == ["P0133", "P0300"] + + +def test_standard_clear_dtc_does_not_fallback_to_broadcast_uds_clear(): + commands: list[str] = [] + + def handler(data: bytes) -> bytes: + command = data.decode("ascii").strip().upper() + commands.append(command) + return b"NO DATA\r\n>" if command == "04" else b"54\r\n>" + + transport = FakeElmTransport(handler=handler) + transport.connect() + elm = Elm327(transport) + + with pytest.raises(ElmNoDataError): + clear_dtcs(elm) + + assert "04" in commands + assert "14FFFFFF" not in commands + + +def test_vin_does_not_join_fragments_from_different_headered_responders(): + transport = FakeElmTransport(default_responses={ + "0902": ( + "7E8 09 49 02 01 31 47 31 46 58 36\r\n" + "7E9 09 49 02 02 53 30 35 48 34 31\r\n>" + ), + }) + transport.connect() + elm = Elm327(transport) + + assert read_vin(elm) == "" + + +def test_uds_services(): + transport = FakeElmTransport(default_responses={ + "22F190": "62 F1 90 31 47 31 46 58 36\r\n>", + "2EF19001": "6E F1 90\r\n>", + "1003": "50 03 00 32 01 F4\r\n>", + "1101": "51 01\r\n>", + "1902FF": "59 02 FF 00 01 33 24\r\n>", + "14FFFFFF": "54\r\n>", + "2701": "67 01 11 22 33 44\r\n>", + "3101FF00": "71 01 FF 00\r\n>", + "2FF19000": "6F F1 90 00\r\n>", + "3E00": "7E 00\r\n>", + }) + transport.connect() + elm = Elm327(transport) + + # Read DID + did_data = uds_read_data_by_identifier(elm, 0xF190) + assert did_data == bytes.fromhex("314731465836") + + # Write DID + write_res = uds_write_data_by_identifier(elm, 0xF190, bytes([0x01])) + assert write_res == bytes([0x6E, 0xF1, 0x90]) + + # Session Control + sess_res = uds_diagnostic_session_control(elm, SESSION_TYPE.EXTENDED_DIAGNOSTIC) + assert sess_res.startswith(bytes([0x50, 0x03])) + + # ECU Reset + reset_res = uds_ecu_reset(elm, 1) + assert reset_res == bytes([0x51, 0x01]) + + # Read DTC Info + dtcs = uds_read_dtc_information(elm, ecu="7E0") + assert len(dtcs) == 1 and dtcs[0].code == "000133" + + # Clear Diagnostic Info + clear_res = uds_clear_diagnostic_information(elm, 0xFFFFFF) + assert clear_res == bytes([0x54]) + + # Security Access + sec_res = uds_security_access(elm, 1) + assert sec_res.startswith(bytes([0x67, 0x01])) + + # Routine Control + rc_res = uds_routine_control(elm, 1, 0xFF00) + assert rc_res == bytes([0x71, 0x01, 0xFF, 0x00]) + + # IO Control + io_res = uds_input_output_control(elm, 0xF190, 0) + assert io_res == bytes([0x6F, 0xF1, 0x90, 0x00]) + + # Tester Present + tp_res = uds_tester_present(elm, 0x00) + assert tp_res == bytes([0x7E, 0x00]) + + +def test_safety_classification(): + # Read-only + assert is_read_only_service(0x01) + assert is_read_only_service(0x02) + assert is_read_only_service(0x03) + assert is_read_only_service(0x07) + assert is_read_only_service(0x09) + assert is_read_only_service(0x0A) + assert is_read_only_service(SERVICE_TYPE.READ_DATA_BY_IDENTIFIER) + assert is_read_only_service(SERVICE_TYPE.READ_DTC_INFORMATION) + assert not is_read_only_service(SERVICE_TYPE.TESTER_PRESENT) + + # Mutating + assert is_mutating_service(0x04) + assert is_mutating_service(SERVICE_TYPE.WRITE_DATA_BY_IDENTIFIER) + assert is_mutating_service(SERVICE_TYPE.CLEAR_DIAGNOSTIC_INFORMATION) + assert is_mutating_service(SERVICE_TYPE.ECU_RESET) + assert is_mutating_service(SERVICE_TYPE.ROUTINE_CONTROL) + assert is_mutating_service(SERVICE_TYPE.INPUT_OUTPUT_CONTROL_BY_IDENTIFIER) + assert is_mutating_service(SERVICE_TYPE.TESTER_PRESENT) + assert is_mutating_service(SERVICE_TYPE.SECURITY_ACCESS) + + # Payload helper + assert is_read_only_payload(bytes([0x01, 0x0C])) + assert is_read_only_payload(bytes([0x22, 0xF1, 0x90])) + assert not is_read_only_payload(bytes([0x27, 0x01])) # SecurityAccess is always offroad + assert not is_read_only_payload(bytes([0x3E, 0x00])) # TesterPresent is stateful + assert not is_read_only_payload(bytes([0x04])) + assert not is_read_only_payload(bytes([0x2E, 0xF1, 0x90, 0x00])) + assert not is_read_only_payload(bytes([0x27, 0x02, 0x11, 0x22])) # Send key is mutating + assert not is_read_only_payload(bytes([0x99])) # Unknown raw service is conservative + assert not is_read_only_payload(b"") + assert is_mutating_service(0x99) diff --git a/starpilot/system/obdyssey/tests/test_elm327.py b/starpilot/system/obdyssey/tests/test_elm327.py new file mode 100644 index 000000000..5e93871b4 --- /dev/null +++ b/starpilot/system/obdyssey/tests/test_elm327.py @@ -0,0 +1,456 @@ +import pytest +import openpilot.starpilot.system.obdyssey.elm327 as elm327_module +from openpilot.starpilot.system.obdyssey.elm327 import ( + Elm327, + ElmBusError, + ElmCommandError, + ElmContext, + ElmDisconnectedError, + ElmNoDataError, + ElmPendingTimeoutError, + ElmTimeoutError, + ElmUnexpectedResponseError, +) +from openpilot.starpilot.system.obdyssey.diagnostics import UdsNegativeResponseError +from openpilot.starpilot.system.obdyssey.transport import FakeElmTransport + + +def test_elm327_initialize(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + info = elm.initialize() + assert info.identity == "ELM327 v1.5" + assert info.voltage == 13.8 + assert elm.active_context is None + + # Check that base init commands were sent + writes_str = [w.decode("ascii").strip() for w in transport.writes] + assert "ATZ" in writes_str + assert "ATE0" in writes_str + assert "ATL0" in writes_str + assert "ATS0" in writes_str + assert "ATR1" in writes_str + assert "ATI" in writes_str + assert "ATRV" in writes_str + + +def test_elm327_field_debug_logging_is_opt_in_and_redacts_vin(monkeypatch): + events = [] + monkeypatch.setattr( + elm327_module.cloudlog, + "event", + lambda event, *args, **fields: events.append((event, fields)), + ) + + quiet_transport = FakeElmTransport(default_responses={"010C": "41 0C 1F 40\r\n>"}) + quiet_transport.connect() + Elm327(quiet_transport, debug=False).request(bytes.fromhex("010C")) + assert events == [] + + transport = FakeElmTransport(default_responses={ + "010C": "41 0C 1F 40\r\n>", + "0902": "49 02 01 31 47 31 46 58\r\n>", + }) + transport.connect() + elm = Elm327(transport, debug=True) + elm.request(bytes.fromhex("010C"), ElmContext(tx_header=0x7E4, rx_filter=0x7EC)) + + assert any(event == "obdyssey.at_tx" and fields["command"] == "ATD" for event, fields in events) + context_events = [fields for event, fields in events if event == "obdyssey.context"] + assert context_events[-1] == { + "debug": True, + "effective_tx": "7E4", + "effective_rx_filter": "7EC", + "protocol": "0", + "flow_control_mode": 0, + } + assert any(event == "obdyssey.diagnostic_complete" and "duration_ms" in fields + for event, fields in events) + + events.clear() + elm.request(bytes.fromhex("0902")) + vin_events = [fields for event, fields in events + if event == "obdyssey.elm_rx" and fields["command"] == "0902"] + assert vin_events and "VIN response redacted" in vin_events[0]["raw"] + + +def test_elm327_field_debug_logging_captures_raw_input_before_reader_error(monkeypatch): + events = [] + monkeypatch.setattr( + elm327_module.cloudlog, + "event", + lambda event, *args, **fields: events.append((event, fields)), + ) + transport = FakeElmTransport(default_responses={"010C": "STOPPED\r\n>"}) + transport.connect() + + with pytest.raises(elm327_module.ElmStoppedError): + Elm327(transport, debug=True).request(bytes.fromhex("010C")) + + rx_events = [fields for event, fields in events if event == "obdyssey.elm_rx"] + assert rx_events and "STOPPED" in rx_events[-1]["raw"] + + +def test_elm327_command_strips_echo_and_noise(): + responses = { + "0100": "0100\r\nSEARCHING...\r\n41 00 BE 3E B8 11\r\n>", + } + transport = FakeElmTransport(default_responses=responses) + transport.connect() + elm = Elm327(transport) + + lines = elm.command("0100") + assert lines == ["41 00 BE 3E B8 11"] + + +def test_elm327_error_detection(): + transport = FakeElmTransport(default_responses={ + "0199": "NO DATA\r\n>", + "ATINVALID": "?\r\n>", + "0101": "CAN ERROR\r\n>", + "0102": "BUS ERROR\r\n>", + "0103": "UNABLE TO CONNECT\r\n>", + }) + transport.connect() + elm = Elm327(transport) + + with pytest.raises(ElmNoDataError): + elm.command("0199") + + with pytest.raises(ElmCommandError): + elm.command("ATINVALID") + + with pytest.raises(ElmBusError): + elm.command("0101") + + with pytest.raises(ElmBusError): + elm.command("0102") + + with pytest.raises(ElmBusError): + elm.command("0103") + + +def test_elm327_timeout(): + transport = FakeElmTransport(default_responses={"SLOW": "NO_PROMPT_HERE\r\n"}) + transport.connect() + elm = Elm327(transport, default_timeout=0.1) + + with pytest.raises(ElmTimeoutError): + elm.command("SLOW") + + +def test_elm327_context_diffing(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + elm.initialize() + + # Clear recorded writes from initialize + transport.writes.clear() + + # Apply Context 1: 11-bit header 7E4, filter 7EC, protocol 6 + ctx1 = ElmContext(protocol="6", tx_header=0x7E4, rx_filter=0x7EC) + elm.apply_context(ctx1) + + writes1 = [w.decode("ascii").strip() for w in transport.writes] + assert "ATSP6" in writes1 + assert "ATSH7E4" in writes1 + assert "ATCRA7EC" in writes1 + + # Apply same Context 1 again -> Should send ZERO AT commands + transport.writes.clear() + elm.apply_context(ctx1) + assert len(transport.writes) == 0 + + # Apply Context 2: only header changes to 7E0, filter to 7E8 + ctx2 = ElmContext(protocol="6", tx_header=0x7E0, rx_filter=0x7E8) + elm.apply_context(ctx2) + + writes2 = [w.decode("ascii").strip() for w in transport.writes] + assert "ATSP6" not in writes2 # Protocol unchanged + assert "ATSH7E0" in writes2 + assert "ATCRA7E8" in writes2 + + +def test_elm327_context_uses_can_flow_control_without_disabling_auto_format(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + elm.apply_context(ElmContext(flow_control=True, can_auto_format=True)) + + writes = [w.decode("ascii").strip() for w in transport.writes] + assert "ATCFC1" in writes + assert "ATCAF1" in writes + assert "ATCAF0" not in writes + + +def test_elm327_context_clears_receive_filter_when_returning_to_functional_obd(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + elm.apply_context(ElmContext(tx_header=0x7E4, rx_filter=0x7EC)) + transport.writes.clear() + + elm.apply_context(ElmContext(tx_header=0x7DF, rx_filter=None)) + + writes = [w.decode("ascii").strip() for w in transport.writes] + assert "ATSH7DF" in writes + assert "ATCRA" in writes + assert "ATCRA7EC" not in writes + + +def test_elm327_context_restores_sticky_settings_when_cleared(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + elm.apply_context(ElmContext( + protocol="6", + tx_header=0x7E4, + rx_filter=0x7EC, + extended_address=0xF1, + timeout=0x10, + flow_control=False, + can_auto_format=False, + )) + transport.writes.clear() + + elm.apply_context(ElmContext(tx_header=0x7DF, can_auto_format=True, flow_control=True)) + + writes = [w.decode("ascii").strip() for w in transport.writes] + assert "ATSP0" in writes + assert "ATCRA" in writes + assert "ATCEA" in writes + assert "ATCAF1" in writes + assert "ATCFC1" in writes + assert "ATST32" in writes + + +def test_elm327_29bit_priority_header(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + ctx_29bit = ElmContext(tx_header=0x18DB33F1, rx_filter=0x18DAF133) + elm.apply_context(ctx_29bit) + + writes = [w.decode("ascii").strip() for w in transport.writes] + assert "ATCP18" in writes + assert "ATSHDB33F1" in writes + assert "ATCRA18DAF133" in writes + + # Priority override + transport.writes.clear() + ctx_prio = ElmContext(tx_header=0x00DB33F1, priority=0x1D) + elm.apply_context(ctx_prio) + writes_prio = [w.decode("ascii").strip() for w in transport.writes] + assert "ATCP1D" in writes_prio + # The lower 24-bit header is unchanged; CP alone updates the 29-bit + # priority without needlessly rewriting SH. + assert "ATSHDB33F1" not in writes_prio + + +def test_elm327_diagnostic_request_parsing(): + transport = FakeElmTransport(default_responses={ + "010C": "41 0C 1F 40\r\n>", + "228334": "62 83 34 00 11 22 33\r\n>", + "22F190": "7F 22 31\r\n>", # UDS Negative response: Request Out of Range + }) + transport.connect() + elm = Elm327(transport) + + # Mode 01 PID 0C + res1 = elm.request(bytes([0x01, 0x0C])) + assert res1.payload == bytes([0x41, 0x0C, 0x1F, 0x40]) + assert res1.service == 0x41 + + # Mode 22 DID 8334 + res2 = elm.request(bytes([0x22, 0x83, 0x34])) + assert res2.payload == bytes([0x62, 0x83, 0x34, 0x00, 0x11, 0x22, 0x33]) + assert res2.service == 0x62 + + # Negative response throws UdsNegativeResponseError + with pytest.raises(UdsNegativeResponseError) as exc_info: + elm.request(bytes([0x22, 0xF1, 0x90])) + assert exc_info.value.service_id == 0x22 + assert exc_info.value.nrc == 0x31 + + +def test_elm327_request_does_not_own_transport_reconnects(): + class DisconnectOnceTransport(FakeElmTransport): + def __init__(self): + super().__init__(default_responses={"010C": "41 0C 1F 40\r\n>"}) + self.connect_calls = 0 + self.fail_next_request = True + + def connect(self): + self.connect_calls += 1 + super().connect() + + def write(self, data): + if self.fail_next_request and data.decode("ascii").strip().upper() == "010C": + self.fail_next_request = False + self.connected = False + raise ConnectionResetError("simulated disconnect") + super().write(data) + + transport = DisconnectOnceTransport() + transport.connect() + elm = Elm327(transport) + + with pytest.raises(ElmDisconnectedError): + elm.request(bytes.fromhex("010C"), retry=True) + + assert transport.connect_calls == 1 + + +def test_elm327_request_reassembles_headered_isotp_response(): + transport = FakeElmTransport(default_responses={ + "22F190": ( + "7E8 10 14 62 F1 90 31 47\r\n" + + "7E8 21 31 46 58 36 53 30 35\r\n" + + "7E8 22 48 34 31 30 30 30 30 30\r\n>" + ), + }) + transport.connect() + elm = Elm327(transport) + + response = elm.request(bytes.fromhex("22F190")) + + assert response.responses == (bytes.fromhex("62F1903147314658365330354834313030303030"),) + assert response.payload == response.responses[0] + + +def test_elm327_request_reassembles_colon_headered_isotp_response(): + transport = FakeElmTransport(default_responses={ + "22F190": ( + "7E8: 10 0C 62 F1 90 31 47 31\r\n" + + "7E8: 21 46 58 36 53 30 35\r\n>" + ), + }) + transport.connect() + elm = Elm327(transport) + + response = elm.request(bytes.fromhex("22F190")) + + assert response.payload == bytes.fromhex("62F190314731465836533035") + + +def test_elm327_request_reassembles_indexed_isotp_response(): + transport = FakeElmTransport(default_responses={ + "22F190": ( + "0: 10 0C 62 F1 90 31 47 31\r\n" + + "1: 21 46 58 36 53 30 35\r\n>" + ), + }) + transport.connect() + elm = Elm327(transport) + + response = elm.request(bytes.fromhex("22F190")) + + assert response.payload == bytes.fromhex("62F190314731465836533035") + + +def test_elm327_request_keeps_interleaved_functional_isotp_responders_separate(): + transport = FakeElmTransport(default_responses={ + "22F190": ( + "7E8 10 0A 62 F1 90 41 42 43\r\n" + + "7E9 10 0A 62 F1 90 58 59 5A\r\n" + + "7E8 21 44 45 46 47\r\n" + + "7E9 21 5B 5C 5D 5E\r\n>" + ), + }) + transport.connect() + elm = Elm327(transport) + + response = elm.request(bytes.fromhex("22F190")) + + assert response.responses == ( + bytes.fromhex("62F19041424344454647"), + bytes.fromhex("62F19058595A5B5C5D5E"), + ) + + +def test_elm327_request_prefers_positive_responder_over_negative_responder(): + transport = FakeElmTransport(default_responses={ + "010C": "7E8 03 7F 01 78\r\n7E9 04 41 0C 1F 40\r\n>", + }) + transport.connect() + elm = Elm327(transport) + + response = elm.request(bytes.fromhex("010C")) + + assert response.payload == bytes.fromhex("410C1F40") + assert response.responses == (bytes.fromhex("7F0178"), bytes.fromhex("410C1F40")) + + +def test_elm327_request_accepts_response_pending_before_final_response(): + transport = FakeElmTransport(default_responses={ + "22F190": "7F 22 78\r\n62 F1 90 31 47 31 46\r\n>", + }) + transport.connect() + elm = Elm327(transport) + + response = elm.request(bytes.fromhex("22F190")) + + assert response.payload == bytes.fromhex("62F19031473146") + + +def test_elm327_request_reports_standalone_response_pending(): + transport = FakeElmTransport(default_responses={ + "22F190": "7F 22 78\r\n>", + }) + transport.connect() + elm = Elm327(transport) + + with pytest.raises(ElmPendingTimeoutError): + elm.request(bytes.fromhex("22F190"), pending_timeout=0.01) + + +def test_elm327_request_does_not_wait_on_unrelated_response_pending(): + transport = FakeElmTransport(default_responses={ + "22F190": "7F 10 78\r\n>", + }) + transport.connect() + elm = Elm327(transport) + + with pytest.raises(ElmUnexpectedResponseError): + elm.request(bytes.fromhex("22F190"), pending_timeout=0.01) + + assert [write for write in transport.writes if write.strip() == b"22F190"] == [b"22F190\r"] + + +def test_elm327_request_reads_final_response_after_pending_prompt(): + class LateResponseTransport(FakeElmTransport): + def __init__(self): + super().__init__(default_responses={"22F190": "7F 22 78\r\n>"}) + self._queue_late_response = False + self._late_response_queued = False + + def write(self, data): + super().write(data) + if data.decode("ascii").strip().upper() == "22F190": + self._queue_late_response = True + + def read(self, size=4096): + with self._lock: + should_queue = self._queue_late_response and not self._late_response_queued and not self._read_buffer + if should_queue: + self._late_response_queued = True + if should_queue: + self.queue_response(b"62 F1 90 31 47 31 46\r\n>") + return super().read(size) + + transport = LateResponseTransport() + transport.connect() + elm = Elm327(transport) + + response = elm.request(bytes.fromhex("22F190"), pending_timeout=0.1) + + assert response.pending is False + assert response.payload == bytes.fromhex("62F19031473146") + assert transport.writes.count(b"22F190\r") == 1 diff --git a/starpilot/system/obdyssey/tests/test_obdb.py b/starpilot/system/obdyssey/tests/test_obdb.py new file mode 100644 index 000000000..1cbde7636 --- /dev/null +++ b/starpilot/system/obdyssey/tests/test_obdb.py @@ -0,0 +1,151 @@ +from openpilot.starpilot.system.obdyssey.obdb import ( + SignalDefinition, + SignalFormat, + calculate_synthetic_signal, + decode_signal, + extract_raw_value, + parse_obdb_profile, +) + + +def test_extract_raw_value_bits(): + # Byte 0xAB, 0xCD -> Binary: 10101011 11001101 + data = bytes([0xAB, 0xCD]) + + # Extract 8 bits at offset 0 -> 0xAB (171) + assert extract_raw_value(data, bix=0, bit_len=8) == 0xAB + + # Extract 8 bits at offset 8 -> 0xCD (205) + assert extract_raw_value(data, bix=8, bit_len=8) == 0xCD + + # Extract 16 bits at offset 0 (Big-Endian) -> 0xABCD (43981) + assert extract_raw_value(data, bix=0, bit_len=16, blsb=False) == 0xABCD + + # Extract 16 bits at offset 0 (Little-Endian) -> 0xCDAB (52651) + assert extract_raw_value(data, bix=0, bit_len=16, blsb=True) == 0xCDAB + + # Extract 4 bits at offset 4 (lower nibble of byte 0) -> 1011 -> 0x0B (11) + assert extract_raw_value(data, bix=4, bit_len=4) == 0x0B + + # Signed extraction: 8-bit 0xFE (-2) + data_signed = bytes([0xFE]) + assert extract_raw_value(data_signed, bix=0, bit_len=8, sign=True) == -2 + assert extract_raw_value(data_signed, bix=0, bit_len=8, sign=False) == 254 + + +def test_decode_signal_scaling_and_bounds(): + # Engine RPM: 2 bytes at offset 0, mul=1, div=4 -> (0x1F40 = 8000) / 4 = 2000 rpm + sig_rpm = SignalDefinition( + id="RPM", + name="RPM", + format=SignalFormat(bix=0, len=16, mul=1.0, div=4.0, unit="rpm"), + ) + payload = bytes([0x1F, 0x40]) + assert decode_signal(payload, sig_rpm) == 2000 + + # Coolant Temp: 1 byte at offset 0, add=-40 -> 0x82 (130) - 40 = 90 C + sig_temp = SignalDefinition( + id="TEMP", + name="Coolant Temp", + format=SignalFormat(bix=0, len=8, add=-40.0, unit="°C"), + ) + assert decode_signal(bytes([0x82]), sig_temp) == 90 + + # Null thresholds are independent; equal thresholds therefore both apply. + sig_null = SignalDefinition( + id="SENSOR", + name="Sensor", + format=SignalFormat(bix=0, len=8, add=-40.0, nullmin=-40.0, nullmax=-40.0), + ) + assert decode_signal(bytes([0x00]), sig_null) is None # 0 - 40 = -40 -> None + assert decode_signal(bytes([0x50]), sig_null) is None + + # Enum mapping + sig_map = SignalDefinition( + id="GEAR", + name="Gear", + format=SignalFormat(bix=0, len=8, map={"0": "P", "1": "R", "2": "N", "3": "D"}), + ) + assert decode_signal(bytes([0x00]), sig_map) == "P" + assert decode_signal(bytes([0x03]), sig_map) == "D" + + +def test_synthetic_signal_calculation(): + # Power (kW) = Voltage (V) * Current (A) * 0.001 + sig_power = SignalDefinition( + id="HVBAT_POWER", + name="Battery Power", + synthetic={"operation": "multiply", "signals": ["HVBAT_VOLTAGE", "HVBAT_CURRENT"]}, + format=SignalFormat(mul=0.001, unit="kW"), + ) + + signals = {"HVBAT_VOLTAGE": 380.0, "HVBAT_CURRENT": 50.0} + power_kw = calculate_synthetic_signal(sig_power, signals) + assert power_kw == 19.0 # 380 * 50 * 0.001 = 19 kW + + # Missing input returns None + assert calculate_synthetic_signal(sig_power, {"HVBAT_VOLTAGE": 380.0}) is None + + +def test_parse_obdb_profile_json(): + obdb_data = { + "metadata": { + "id": "Test-Car", + "name": "Test Vehicle", + "provider": "OBDb", + "revision": "v3.0.0", + "protocol": "ISO 15765-4 (CAN 11/500)", + }, + "commands": [ + { + "id": "CMD_BMS", + "hdr": "7E4", + "rax": "7EC", + "service": 34, + "pid": "8334", + "freq": 2.0, + "din": 3, + "dout": 1, + "fcm1": "0", + "signals": [ + { + "id": "SOC", + "name": "State of Charge", + "fmt": {"bix": 0, "len": 8, "mul": 0.5, "unit": "%"} + }, + { + "id": "VOLT", + "name": "Pack Voltage", + "fmt": {"bix": 16, "len": 16, "mul": 0.05, "unit": "V"} + } + ] + } + ], + "signals": [ + { + "id": "SYNTH_POWER", + "name": "Total Power", + "synthetic": {"operation": "multiply", "signals": ["VOLT", "CURR"]} + } + ] + } + + profile = parse_obdb_profile(obdb_data, "test_car") + assert profile.id == "test_car" + assert len(profile.commands) == 1 + + cmd = profile.commands[0] + assert cmd.context.tx_header == 0x7E4 + assert cmd.context.rx_filter == 0x7EC + assert cmd.context.protocol == "ISO 15765-4 (CAN 11/500)" + assert cmd.context.flow_control is None + assert cmd.context.flow_control_mode1 is False + assert cmd.context.can_auto_format is True + assert cmd.service == 34 + assert cmd.parameter == bytes.fromhex("8334") + assert cmd.diagnostic_session_in == 3 + assert cmd.diagnostic_session_out == 1 + assert len(cmd.signals) == 2 + assert "SOC" in profile.signals + assert "VOLT" in profile.signals + assert "SYNTH_POWER" in profile.synthetic_signals diff --git a/starpilot/system/obdyssey/tests/test_profiles.py b/starpilot/system/obdyssey/tests/test_profiles.py new file mode 100644 index 000000000..966fe20f6 --- /dev/null +++ b/starpilot/system/obdyssey/tests/test_profiles.py @@ -0,0 +1,220 @@ +import json + +import pytest +from openpilot.starpilot.system.bluetooth.tests.test_bluetooth import FakeParams +from openpilot.starpilot.system.obdyssey.profiles import ProfileManager + + +def test_load_bundled_saej1979(tmp_path): + manager = ProfileManager(data_dir=tmp_path) + profile = manager.load_bundled_saej1979() + + assert profile.id == "saej1979" + assert len(profile.commands) > 0 + assert "SAE_ENGINE_RPM" in profile.signals + assert "SAE_VEHICLE_SPEED" in profile.signals + assert "SAE_ENGINE_COOLANT_TEMP" in profile.signals + assert all(command.context.rx_filter == 0x7E8 for command in profile.commands) + + +def test_list_and_get_curated_profiles(tmp_path): + manager = ProfileManager(data_dir=tmp_path) + profiles = manager.list_profiles() + + prof_ids = [p["id"] for p in profiles] + assert "saej1979" in prof_ids + assert "Chevrolet-Bolt-EV" in prof_ids + + bolt = manager.get_profile("Chevrolet-Bolt-EV") + assert bolt is not None + assert "BOLT_HVBAT_SOC" in bolt.signals + assert "BOLT_HVBAT_VOLTAGE" in bolt.signals + assert "BOLT_HVBAT_POWER" in bolt.synthetic_signals + + +def test_resolve_active_profile_from_vehicle_settings(tmp_path): + # 1. Detected Chevy Bolt EV -> Resolves Chevrolet-Bolt-EV + params = FakeParams(CarMake="Chevrolet", CarModel="BOLT EV") + manager = ProfileManager(data_dir=tmp_path, params=params) + active = manager.resolve_active_profile() + assert active.id == "Chevrolet-Bolt-EV" + + # 2. Unmapped car -> Falls back to SAE J1979 + params_other = FakeParams(CarMake="Subaru", CarModel="Forester") + manager_other = ProfileManager(data_dir=tmp_path, params=params_other) + active_other = manager_other.resolve_active_profile() + assert active_other.id == "saej1979" + + # 3. Explicit override + active_explicit = manager_other.resolve_active_profile(explicit_id="Chevrolet-Bolt-EV") + assert active_explicit.id == "Chevrolet-Bolt-EV" + + +def test_session_profile_override_is_not_persisted_and_resets_on_vehicle_change(tmp_path): + params = FakeParams(CarMake="Subaru", CarModel="Forester") + manager = ProfileManager(data_dir=tmp_path, params=params) + + manager.select_profile("saej1979") + assert manager.resolve_active_profile().id == "saej1979" + + params.values["CarMake"] = "Chevrolet" + params.values["CarModel"] = "BOLT EV" + assert manager.resolve_active_profile().id == "Chevrolet-Bolt-EV" + + # A new manager has no access to the old process-local selection. + new_manager = ProfileManager(data_dir=tmp_path, params=FakeParams( + CarMake="Subaru", CarModel="Forester", + )) + assert new_manager.resolve_active_profile().id == "saej1979" + + +def test_invalid_explicit_profile_does_not_fuzzy_match_an_oem_profile(tmp_path): + params = FakeParams(CarMake="Chevrolet", CarModel="BOLT EV") + manager = ProfileManager(data_dir=tmp_path, params=params) + + assert manager.resolve_active_profile(explicit_id="not-a-real-profile").id == "saej1979" + + +def test_group_signals_by_command(tmp_path): + manager = ProfileManager(data_dir=tmp_path) + custom_data = { + "metadata": {"name": "Multi-Signal Profile"}, + "commands": [ + { + "id": "CMD_MULTI_1", + "service": 1, + "pid": "01", + "signals": [ + {"id": "SIG_A", "name": "Signal A"}, + {"id": "SIG_B", "name": "Signal B"}, + ] + }, + { + "id": "CMD_MULTI_2", + "service": 1, + "pid": "02", + "signals": [ + {"id": "SIG_C", "name": "Signal C"}, + ] + } + ] + } + profile = manager.install_profile_data("multi_test", custom_data) + requested = ["SIG_A", "SIG_B", "SIG_C"] + grouped = ProfileManager.group_signals_by_command(profile, requested) + + assert len(grouped) == 2 + assert grouped[0][0].id == "CMD_MULTI_1" + assert len(grouped[0][1]) == 2 + assert [s.id for s in grouped[0][1]] == ["SIG_A", "SIG_B"] + assert grouped[1][0].id == "CMD_MULTI_2" + assert len(grouped[1][1]) == 1 + assert [s.id for s in grouped[1][1]] == ["SIG_C"] + + +def test_profile_install_failure_preserves_last_known_good(tmp_path, monkeypatch): + manager = ProfileManager(data_dir=tmp_path) + old_data = { + "metadata": {"name": "Old profile"}, + "commands": [{"id": "OLD", "service": 1, "pid": "0C", "signals": [{"id": "OLD_RPM"}]}], + } + manager.install_profile_data("vehicle", old_data) + old_bytes = (tmp_path / "vehicle" / "profile.json").read_bytes() + + def fail_replace(_source, _target): + raise OSError("simulated interrupted profile replacement") + + monkeypatch.setattr("openpilot.starpilot.system.obdyssey.profiles.os.replace", fail_replace) + + with pytest.raises(OSError, match="replacement"): + manager.install_profile_data("vehicle", { + "metadata": {"name": "New profile"}, + "commands": [{"id": "NEW", "service": 1, "pid": "0D", "signals": [{"id": "NEW_SPEED"}]}], + }) + + assert (tmp_path / "vehicle" / "profile.json").read_bytes() == old_bytes + + +def test_profile_install_failure_restores_metadata_with_last_known_good(tmp_path, monkeypatch): + manager = ProfileManager(data_dir=tmp_path) + old_data = { + "metadata": {"name": "Old profile"}, + "commands": [{"id": "OLD", "service": 1, "pid": "0C", "signals": [{"id": "OLD_RPM"}]}], + } + manager.install_profile_data("vehicle", old_data, {"source": "old.json"}) + old_profile = (tmp_path / "vehicle" / "profile.json").read_bytes() + old_metadata = (tmp_path / "vehicle" / "metadata.json").read_bytes() + + import openpilot.starpilot.system.obdyssey.profiles as profiles_module + real_replace = profiles_module.os.replace + replace_calls = 0 + + def fail_second_replace(source, target): + nonlocal replace_calls + replace_calls += 1 + if replace_calls == 2: + raise OSError("simulated profile replacement failure") + real_replace(source, target) + + monkeypatch.setattr(profiles_module.os, "replace", fail_second_replace) + + with pytest.raises(OSError, match="replacement"): + manager.install_profile_data("vehicle", { + "metadata": {"name": "New profile"}, + "commands": [{"id": "NEW", "service": 1, "pid": "0D", "signals": [{"id": "NEW_SPEED"}]}], + }, {"source": "new.json"}) + + assert (tmp_path / "vehicle" / "profile.json").read_bytes() == old_profile + assert (tmp_path / "vehicle" / "metadata.json").read_bytes() == old_metadata + + +def test_profile_install_rejects_malformed_command_shape(tmp_path): + manager = ProfileManager(data_dir=tmp_path) + + with pytest.raises(ValueError, match="commands or pids"): + manager.install_profile_data("invalid", {"metadata": {}}) + + with pytest.raises(ValueError, match="signals must be a list"): + manager.install_profile_data("invalid", { + "commands": [{"service": 1, "pid": "0C", "signals": {}}], + }) + + +def test_profile_source_can_be_updated_without_losing_last_good(tmp_path): + source_path = tmp_path / "source.json" + source_data = { + "metadata": {"id": "source_car", "name": "Source car", "revision": "v1"}, + "commands": [{"id": "SOURCE", "service": 1, "pid": "0C", "signals": [{"id": "SOURCE_RPM"}]}], + } + source_path.write_text(json.dumps(source_data), encoding="utf-8") + + manager = ProfileManager(data_dir=tmp_path / "profiles") + installed = manager.install_profile_source(str(source_path), provider="test") + assert installed.id == "source_car" + assert manager.update_profile("source_car").revision == "v1" + assert json.loads((tmp_path / "profiles" / "source_car" / "metadata.json").read_text())[ + "source" + ] == str(source_path) + + +def test_install_and_remove_custom_profile(tmp_path): + manager = ProfileManager(data_dir=tmp_path) + custom_data = { + "metadata": {"name": "Custom Vehicle", "provider": "User"}, + "commands": [ + { + "id": "CMD_1", + "service": 1, + "pid": "0C", + "signals": [{"id": "CUSTOM_RPM", "name": "RPM"}] + } + ] + } + + installed = manager.install_profile_data("custom_car", custom_data) + assert installed.id == "custom_car" + assert manager.get_profile("custom_car") is not None + + removed = manager.remove_profile("custom_car") + assert removed + assert manager.get_profile("custom_car") is None diff --git a/starpilot/system/obdyssey/tests/test_protocol.py b/starpilot/system/obdyssey/tests/test_protocol.py new file mode 100644 index 000000000..1df841579 --- /dev/null +++ b/starpilot/system/obdyssey/tests/test_protocol.py @@ -0,0 +1,30 @@ +from openpilot.starpilot.system.obdyssey.protocol import OBDysseyStatus + + +def test_obdyssey_status_serialization(): + status = OBDysseyStatus( + state="ready", + enabled=True, + bluetooth_enabled=True, + adapter_address="00:11:22:33:44:55", + adapter_name="OBDLink MX+", + connected=True, + elm_identity="ELM327 v1.5", + adapter_voltage=13.9, + protocol="ISO 15765-4 CAN 11/500", + profile="Chevrolet-Bolt-EV", + signal_count=10, + last_error="", + last_request_ms=42, + requests=100, + errors=1, + reconnects=2, + ) + + data = status.to_dict() + assert data["state"] == "ready" + assert data["connected"] is True + assert data["adapter_voltage"] == 13.9 + + restored = OBDysseyStatus.from_dict(data) + assert restored == status diff --git a/starpilot/system/obdyssey/tests/test_stabilization.py b/starpilot/system/obdyssey/tests/test_stabilization.py new file mode 100644 index 000000000..ef0928769 --- /dev/null +++ b/starpilot/system/obdyssey/tests/test_stabilization.py @@ -0,0 +1,936 @@ +"""Regression tests for the protocol and persistence stabilization contract.""" + +import io +import json +import zipfile +from pathlib import Path + +import pytest + +from openpilot.starpilot.system.bluetooth.tests.test_bluetooth import FakeParams +from openpilot.starpilot.system.bluetooth.protocol import BluetoothDevice, BluetoothStatus +from openpilot.starpilot.system.obdyssey.daemon import OBDysseyController +from openpilot.starpilot.system.obdyssey.diagnostics import UdsDtcParseError, parse_uds_dtcs +from openpilot.starpilot.system.obdyssey.diagnostics import is_read_only_payload +from openpilot.starpilot.system.obdyssey.elm327 import ( + DEFAULT_PENDING_TIMEOUT, + Elm327, + ElmCommandError, + ElmContext, + ElmIncompleteResponseError, + ElmIsoTpError, + ElmPendingTimeoutError, + ElmResponseTooLargeError, + ElmStoppedError, + ElmUnsupportedError, + ProtocolRequirement, +) +from openpilot.starpilot.system.obdyssey.obdb import ( + OBDbProfileError, + SignalDecodeError, + SignalDefinition, + SignalFormat, + SyntheticSignalError, + calculate_synthetic_signal, + decode_signal, + parse_obdb_decimal, + parse_obdb_profile, +) +from openpilot.starpilot.system.obdyssey.obdb_provider import OBDbProvider, OBDbProviderError +from openpilot.starpilot.system.obdyssey.profiles import ProfileManager +from openpilot.starpilot.system.obdyssey.transport import FakeElmTransport + + +class _Bluetooth: + def __init__(self, enabled: bool = True): + self.enabled = enabled + + def status(self): + return BluetoothStatus( + available=True, + enabled=self.enabled, + powered=self.enabled, + devices=(BluetoothDevice("AA:BB:CC:DD:EE:FF", "OBDLink", paired=True, trusted=True, serial=True),), + ) + + +def _native_profile(command: dict | None = None) -> dict: + return { + "commands": [command or { + "hdr": "710", + "rax": "77A", + "proto": "15765-4-11bit", + "cmd": {"22": "2A53"}, + "freq": 1, + "filter": {"years": [2023]}, + "signals": [{ + "id": "SOC", + "name": "State of charge", + "suggestedMetric": "stateOfCharge", + "fmt": {"len": 8, "max": 100, "div": 2, "unit": "percent"}, + }], + }], + "synthetics": [{ + "id": "RATIO", + "name": "Ratio", + "path": "Battery", + "max": 10, + "unit": "scalar", + "formula": {"op": "ratio", "a": "SOC", "b": "SOC"}, + }], + } + + +def test_native_obdb_command_and_hex_addresses_are_typed(): + profile = parse_obdb_profile(_native_profile(), "taycan") + command = profile.commands[0] + + assert command.context.tx_header == 0x710 + assert command.context.rx_filter == 0x77A + assert command.context.protocol is ProtocolRequirement.CAN_11BIT + assert command.service == 0x22 + assert command.parameter == bytes.fromhex("2A53") + assert command.expected_prefix == bytes.fromhex("622A53") + assert profile.signals["SOC"].suggestedMetric == "stateOfCharge" + assert profile.synthetic_signals["RATIO"].sources == ("SOC", "SOC") + assert command.context.flow_control_mode1 is False + assert command.context.flow_control is None + + +def test_native_fcm1_selects_custom_mode_without_disabling_cfc(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + elm.apply_context(ElmContext(tx_header=0x744, rx_filter=0x74C, flow_control_mode1=False)) + default_writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATCFC1" in default_writes + assert "ATFCSM0" in default_writes + assert "ATCFC0" not in default_writes + + transport.writes.clear() + elm.apply_context(ElmContext(tx_header=0x744, rx_filter=0x74C, flow_control_mode1=True)) + mode1_writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATCFC1" not in mode1_writes + assert mode1_writes.index("ATFCSH744") < mode1_writes.index("ATFCSD300000") < mode1_writes.index("ATFCSM1") + + transport.writes.clear() + elm.apply_context(ElmContext(tx_header=0x7E4, rx_filter=0x7EC, flow_control_mode1=True)) + header_writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATFCSH7E4" in header_writes + assert "ATCFC0" not in header_writes + + transport.writes.clear() + elm.apply_context(ElmContext(tx_header=0x7E4, rx_filter=0x7EC, flow_control_mode1=False)) + mode0_writes = [write.decode("ascii").strip() for write in transport.writes] + assert mode0_writes == ["ATFCSM0"] + assert "ATCFC0" not in mode0_writes + + +def test_native_fcm1_is_strictly_boolean_and_maps_to_mode1(): + data = _native_profile() + data["commands"][0]["fcm1"] = True + profile = parse_obdb_profile(data) + assert profile.commands[0].context.flow_control_mode1 is True + assert profile.commands[0].context.flow_control is None + + data["commands"][0]["fcm1"] = "true" + with pytest.raises(OBDbProfileError, match="must be a boolean"): + parse_obdb_profile(data) + + +def test_elm_29bit_cp_sh_cra_and_response_priority_are_symmetric(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + elm.apply_context(ElmContext( + protocol=ProtocolRequirement.CAN_29BIT, + tx_header=0xDB33F1, + rx_filter=0xC6AE80, + priority=0x1D, + )) + writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATCP1D" in writes + assert "ATSHDB33F1" in writes + assert "ATCRA1DC6AE80" in writes + + transport.writes.clear() + elm.apply_context(ElmContext( + protocol=ProtocolRequirement.CAN_29BIT, + tx_header=0xDB33F1, + rx_filter=0xC6AE80, + priority=0x1D, + response_priority=0x1E, + )) + override_writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATCRA1EC6AE80" in override_writes + assert "ATCP1D" not in override_writes + + transport.writes.clear() + elm.apply_context(ElmContext( + protocol=ProtocolRequirement.CAN_11BIT, + tx_header=0x744, + rx_filter=0x74C, + )) + switched_writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATSH744" in switched_writes + assert "ATCRA74C" in switched_writes + assert "ATCP18" in switched_writes + + +def test_elm_29bit_mode1_uses_effective_full_request_id_for_flow_control(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + elm.apply_context(ElmContext( + protocol=ProtocolRequirement.CAN_29BIT, + tx_header=0xDB33F1, + rx_filter=0xC6AE80, + priority=0x1D, + flow_control_mode1=True, + )) + writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATCP1D" in writes + assert "ATSHDB33F1" in writes + assert "ATFCSH1DDB33F1" in writes + assert writes.index("ATFCSH1DDB33F1") < writes.index("ATFCSD300000") < writes.index("ATFCSM1") + + +def test_elm_29bit_explicit_tx_priority_wins_over_full_filter_priority(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + elm.apply_context(ElmContext( + protocol=ProtocolRequirement.CAN_29BIT, + tx_header=0xDB33F1, + rx_filter=0x1EC6AE80, + priority=0x1D, + )) + writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATCRA1DC6AE80" in writes + + +def test_elm_exact_29bit_protocol_selects_extended_addressing_for_lower_header(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + elm.apply_context(ElmContext( + exact_protocol="7", + tx_header=0xDB33F1, + rx_filter=0xC6AE80, + )) + writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATCP18" in writes + assert "ATSHDB33F1" in writes + assert "ATCRA18C6AE80" in writes + + +def test_elm_lower_24_bit_header_is_not_truncated_to_11_bits(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + + elm.apply_context(ElmContext(tx_header=0xDB33F1, rx_filter=0xC6AE80)) + writes = [write.decode("ascii").strip() for write in transport.writes] + assert "ATCP18" in writes + assert "ATSHDB33F1" in writes + assert "ATCRA18C6AE80" in writes + + +def test_unknown_elm_state_uses_atd_instead_of_empty_header_commands(): + transport = FakeElmTransport(default_responses={"010C": "41 0C 1F 40\r\n>"}) + transport.connect() + elm = Elm327(transport) + elm.apply_context(ElmContext(tx_header=0x7E4, rx_filter=0x7EC)) + transport.writes.clear() + + elm.debug_at_command("ATSP6") + transport.writes.clear() + elm.request(bytes.fromhex("010C")) + writes = [write.decode("ascii").strip() for write in transport.writes] + assert writes[0] == "ATD" + assert "ATSH" not in writes + assert "ATTA" not in writes + + +@pytest.mark.parametrize("custom_context", [ + ElmContext(tx_header=0x744, rx_filter=0x74C), + ElmContext(tester_address=0xF1), +]) +def test_clearing_custom_header_or_tester_address_uses_atd_not_empty_reset(custom_context): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + elm.apply_context(custom_context) + transport.writes.clear() + + elm.apply_context(ElmContext()) + + writes = [write.decode("ascii").strip() for write in transport.writes] + assert writes[0] == "ATD" + assert "ATSH" not in writes + assert "ATTA" not in writes + + +def test_failed_context_transition_invalidates_cache_and_next_context_restores_baseline(): + transport = FakeElmTransport() + transport.connect() + elm = Elm327(transport) + elm.apply_context(ElmContext(tx_header=0x744, rx_filter=0x74C)) + + transport.default_responses["ATSH"] = "?\r\n>" + transport.writes.clear() + with pytest.raises(ElmCommandError): + elm.apply_context(ElmContext(tx_header=0x7E4, rx_filter=0x7EC)) + assert elm.active_context is None + assert elm._context_unknown is True + + transport.default_responses["ATSH"] = "OK\r\n>" + transport.writes.clear() + elm.apply_context(ElmContext(tx_header=0x7E4, rx_filter=0x7EC)) + writes = [write.decode("ascii").strip() for write in transport.writes] + assert writes[0] == "ATD" + + +def test_optional_fcs_mode_probe_does_not_block_generic_sae(): + transport = FakeElmTransport(default_responses={"ATFCSM": "?\r\n>"}) + transport.connect() + elm = Elm327(transport) + + elm.initialize() + assert elm.adapter_capabilities["fc_mode1"] is False + + response = elm.request(bytes.fromhex("010C")) + assert response.payload == bytes.fromhex("410C1F40") + + +def test_required_fcm1_fails_cleanly_when_clone_lacks_flow_control_mode(): + transport = FakeElmTransport(default_responses={"ATFCSM": "?\r\n>"}) + transport.connect() + elm = Elm327(transport) + elm.initialize() + + with pytest.raises(ElmUnsupportedError, match="custom Flow Control Mode 1"): + elm.apply_context(ElmContext(tx_header=0x744, rx_filter=0x74C, flow_control_mode1=True)) + + +def test_required_fcm1_command_failure_is_reported_as_capability_error(): + transport = FakeElmTransport(default_responses={"ATFCSH": "?\r\n>"}) + transport.connect() + elm = Elm327(transport) + elm.initialize() + + with pytest.raises(ElmUnsupportedError, match="custom Flow Control Mode 1"): + elm.apply_context(ElmContext(tx_header=0x744, rx_filter=0x74C, flow_control_mode1=True)) + assert elm.adapter_capabilities["fc_mode1"] is False + + +def test_native_diagnostic_level_defaults_command_session_and_preserves_groups(): + data = _native_profile() + data["diagnosticLevel"] = "03" + data["signalGroups"] = [{"id": "battery", "matchingRegex": "^SOC$"}] + profile = parse_obdb_profile(data) + assert profile.diagnostic_level == 0x03 + assert profile.commands[0].diagnostic_session_in == 0x03 + assert profile.signal_groups == ({"id": "battery", "matchingRegex": "^SOC$"},) + assert profile.metadata["diagnosticLevel"] == 0x03 + + data["commands"][0]["din"] = "10" + overridden = parse_obdb_profile(data) + assert overridden.commands[0].diagnostic_session_in == 0x10 + + +def test_unknown_model_year_does_not_activate_filtered_commands(tmp_path): + data = _native_profile() + data["commands"].append({ + "hdr": "710", "cmd": {"22": "2A54"}, "freq": 1, + "filter": {"from": 2020, "to": 2024}, + "signals": [{"id": "YEAR_ONLY", "name": "Year only", "fmt": {"len": 8, "max": 255, "unit": "scalar"}}], + }) + manager = ProfileManager(data_dir=tmp_path, params=FakeParams()) + manager.install_profile_data("unknown_year", data) + profile = manager.get_profile("unknown_year") + assert profile is not None + assert all(command.applicability is None for command in profile.commands) + assert "YEAR_ONLY" not in profile.signals + + +def test_unknown_model_year_with_only_generation_commands_falls_back_to_sae(tmp_path): + params = FakeParams(CarMake="Chevrolet", CarModel="BOLT EV") + manager = ProfileManager(data_dir=tmp_path, params=params) + manager.install_profile_data("Chevrolet-Bolt-EV", { + "commands": [{ + "hdr": "710", "cmd": {"22": "2A53"}, "freq": 1, + "filter": {"from": 2023, "to": 2024}, + "signals": [{"id": "YEAR_ONLY", "name": "Year only", + "fmt": {"len": 8, "max": 255, "unit": "scalar"}}], + }], + }) + + assert manager.resolve_active_profile().id == "saej1979" + + +def test_installed_profile_metadata_supplies_model_year_without_global_param(tmp_path): + data = _native_profile() + manager = ProfileManager(data_dir=tmp_path, params=FakeParams()) + manager.install_profile_data("metadata_year", data, {"model_year": 2023}) + + active = manager.get_profile("metadata_year") + assert active is not None + assert len(active.commands) == 1 + assert manager.model_year() is None + + restarted = ProfileManager(data_dir=tmp_path, params=FakeParams()) + active_after_restart = restarted.get_profile("metadata_year") + assert active_after_restart is not None + assert len(active_after_restart.commands) == 1 + active_after_restart = restarted.resolve_active_profile(explicit_id="metadata_year") + assert restarted.model_year() == 2023 + assert ProfileManager.group_signals_by_command(active_after_restart, ["SOC"], restarted.model_year()) + + requested_years = [] + + class Provider: + def fetch(self, _repository, _revision, model_year=None): + requested_years.append(model_year) + return _native_profile(), {"provider": "obdb", "model_year": model_year or 0} + + restarted._obdb_provider = Provider() + restarted.install_obdb_profile("metadata_followup", "a" * 40) + assert requested_years == [2023] + + +def test_year_only_repository_without_default_is_not_guessed(): + with pytest.raises(OBDbProviderError, match="no default signalset"): + OBDbProvider._select_name(["2018-2020.json", "2021-2024.json"], None) + with pytest.raises(OBDbProviderError, match="no default signalset"): + OBDbProvider._select_name(["2018-2020.json", "2021-2024.json"], 2025) + + +def test_session_profile_selection_and_model_year_are_not_persisted(tmp_path): + params = FakeParams(CarMake="Subaru", CarModel="Forester") + manager = ProfileManager(data_dir=tmp_path, params=params) + manager.install_profile_data("session_profile", _native_profile()) + selected = manager.select_profile("session_profile", model_year=2023) + + assert selected.id == "session_profile" + assert manager.model_year() == 2023 + + restarted = ProfileManager(data_dir=tmp_path, params=params) + assert restarted.model_year() is None + assert restarted.resolve_active_profile().id == "saej1979" + + +def test_removing_selected_profile_clears_session_model_year(tmp_path): + manager = ProfileManager(data_dir=tmp_path, params=FakeParams()) + manager.install_profile_data("session_profile", _native_profile()) + manager.select_profile("session_profile", model_year=2023) + + assert manager.remove_profile("session_profile") is True + assert manager.model_year() is None + assert manager.resolve_active_profile().id == "saej1979" + + +def test_security_access_and_tester_present_are_always_offroad(tmp_path): + assert not is_read_only_payload(bytes.fromhex("2703")) + assert not is_read_only_payload(bytes.fromhex("2704")) + assert not is_read_only_payload(bytes.fromhex("3E00")) + controller = OBDysseyController( + params=FakeParams(BluetoothEnabled=True, IsOffroad=True), + profile_manager=ProfileManager(data_dir=tmp_path / "profiles"), + bluetooth_client=_Bluetooth(), + ) + with pytest.raises(ValueError, match="tx_addr is required"): + controller.handle({"command": "read_uds_dtcs"}) + + +def test_stateful_uds_services_are_rejected_onroad(tmp_path): + controller = OBDysseyController( + params=FakeParams(BluetoothEnabled=True, IsOffroad=False), + profile_manager=ProfileManager(data_dir=tmp_path / "profiles"), + bluetooth_client=_Bluetooth(), + ) + for payload in ("2701", "2702AABB", "3E00"): + with pytest.raises(RuntimeError, match="offroad"): + controller.handle({ + "command": "uds_request", + "tx_addr": "7E0", + "rx_addr": "7E8", + "payload": payload, + }) + + +def test_shared_diagnostic_request_rechecks_service_safety(tmp_path): + controller = OBDysseyController( + params=FakeParams(BluetoothEnabled=True, IsOffroad=False), + profile_manager=ProfileManager(data_dir=tmp_path / "profiles"), + bluetooth_client=_Bluetooth(), + ) + + with pytest.raises(RuntimeError, match="offroad"): + controller.diagnostic_request(bytes.fromhex("3E00"), read_only=True) + + +def test_set_enabled_requires_json_boolean(tmp_path): + controller = OBDysseyController( + params=FakeParams(BluetoothEnabled=True, IsOffroad=True), + profile_manager=ProfileManager(data_dir=tmp_path / "profiles"), + bluetooth_client=_Bluetooth(), + ) + with pytest.raises(ValueError, match="JSON boolean"): + controller.handle({"command": "set_enabled", "enabled": "false"}) + + +def test_set_enabled_is_session_compatibility_control(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + calls = [] + controller = OBDysseyController( + params=params, + transport_factory=lambda address: calls.append(address) or FakeElmTransport(), + profile_manager=ProfileManager(data_dir=tmp_path / "profiles", params=params), + bluetooth_client=_Bluetooth(), + ) + assert controller.connect_adapter("AA:BB:CC:DD:EE:FF") + + controller.set_enabled(False) + assert controller.status()["enabled"] is False + controller.set_enabled(True) + controller.reconnect_step() + assert controller.status()["state"] == "idle" + assert calls == ["AA:BB:CC:DD:EE:FF"] + + +def test_transcript_corpus_replays_fcm1_multiframe_response(): + transcript_dir = Path(__file__).with_name("transcripts") + required = { + "generic_clone_init.txt", + "known_good_adapter_init.txt", + "atd_reset.txt", + "fcm0_singleframe.txt", + "fcm1_multiframe.txt", + "gm_mode22_multiframe.txt", + "29bit_standard_priority.txt", + "29bit_different_response_priority.txt", + "response_pending.txt", + "uds_dtc_019.txt", + "stopped.txt", + "no_data.txt", + "can_error.txt", + "incomplete_multiframe.txt", + } + assert required <= {path.name for path in transcript_dir.glob("*.txt")} + + response_text = (transcript_dir / "fcm1_multiframe.txt").read_text(encoding="utf-8") + transport = FakeElmTransport(default_responses={"22F190": response_text}) + transport.connect() + response = Elm327(transport).request(bytes.fromhex("22F190")) + assert response.payload == bytes.fromhex("62F1903147314658365330354834313030303030") + + +@pytest.mark.parametrize("proto", ["9141-2", "14230", "15765-4-11bit", "15765-4-29bit"]) +def test_native_obdb_protocol_values_never_become_raw_atsp(proto): + data = _native_profile() + data["commands"][0]["proto"] = proto + profile = parse_obdb_profile(data) + assert isinstance(profile.commands[0].context.protocol, ProtocolRequirement) + assert proto != profile.commands[0].context.protocol.value + + +def test_native_obdb_rejects_unknown_protocol_and_invalid_divisor(): + data = _native_profile() + data["commands"][0]["proto"] = "15765-4-11bit-500K" + with pytest.raises(OBDbProfileError, match="unsupported native proto"): + parse_obdb_profile(data) + + data = _native_profile() + data["commands"][0]["signals"][0]["fmt"]["div"] = 0 + with pytest.raises(OBDbProfileError, match="div must not be zero"): + parse_obdb_profile(data) + + +def test_native_obdb_rejects_mixed_command_dialects(): + data = _native_profile() + data["commands"].append({ + "service": 1, + "pid": "0C", + "signals": [{"id": "RPM", "name": "RPM", "fmt": {"len": 8, "max": 255, "unit": "rpm"}}], + }) + with pytest.raises(OBDbProfileError, match="mix native OBDb and legacy"): + parse_obdb_profile(data) + + +def test_obdb_decimal_parser_rejects_fractional_values(): + assert parse_obdb_decimal("12", "test") == 12 + with pytest.raises(OBDbProfileError, match="must be decimal"): + parse_obdb_decimal("12.5", "test") + + +def test_obdb_filter_years_are_alternatives_and_filtered_signals_disappear(tmp_path): + data = _native_profile() + data["commands"][0]["filter"] = {"to": 2011, "years": [2014, 2015], "from": 2026} + data["commands"].append({ + "hdr": "710", "cmd": {"22": "2A54"}, "freq": 1, + "filter": {"from": 2018, "to": 2020}, + "signals": [{"id": "NEW", "name": "New", "fmt": {"len": 8, "max": 255, "unit": "scalar"}}], + }) + params = FakeParams() + manager = ProfileManager(data_dir=tmp_path, params=params) + manager.install_profile_data("filtered", data) + manager.select_profile("filtered", model_year=2014) + + active = manager.get_profile("filtered") + assert active is not None + assert [command.parameter for command in active.commands] == [bytes.fromhex("2A53")] + assert "NEW" not in active.signals + + +def test_decoder_is_strict_and_maps_raw_values(): + signal = SignalDefinition("x", "X", format=SignalFormat(len=16, max=10, map={2: "on"})) + with pytest.raises(SignalDecodeError) as truncated: + decode_signal(b"\x00", signal) + assert truncated.value.reason == "truncated_payload" + + with pytest.raises(SignalDecodeError) as out_of_range: + decode_signal(b"\x00\x0b", SignalDefinition("x", "X", format=SignalFormat(len=16, max=10))) + assert out_of_range.value.reason == "above_maximum" + assert decode_signal(b"\x00\x02", signal) == "on" + + null_min = SignalDefinition("x", "X", format=SignalFormat(len=8, nullmin=2)) + null_max = SignalDefinition("x", "X", format=SignalFormat(len=8, nullmax=2)) + assert decode_signal(b"\x02", null_min) is None + assert decode_signal(b"\x01", null_min) is None + assert decode_signal(b"\x02", null_max) is None + assert decode_signal(b"\x03", null_max) is None + + +def test_native_ratio_synthetic_is_structured_and_never_queries(): + profile = parse_obdb_profile(_native_profile()) + synthetic = profile.synthetic_signals["RATIO"] + assert calculate_synthetic_signal(synthetic, {"SOC": 8}) == 1 + with pytest.raises(SyntheticSignalError, match="division"): + calculate_synthetic_signal(synthetic, {"SOC": 0}, strict=True) + + +def test_uds_dtc_parser_preserves_24_bit_identifier_and_rejects_short_records(): + dtcs = parse_uds_dtcs(bytes.fromhex("5902FF12345624"), ecu="7E0") + assert dtcs[0].code == "123456" + assert dtcs[0].raw_code == 0x123456 + assert dtcs[0].status == 0x24 + assert dtcs[0].source == "uds" + with pytest.raises(UdsDtcParseError): + parse_uds_dtcs(bytes.fromhex("5902FF123324"), ecu="7E0") + + +def test_elm_buffer_stop_and_strict_isotp_errors(): + large_payload = " ".join(["11"] * 5000) + transport = FakeElmTransport(default_responses={"010C": f"41 0C {large_payload}\r\n>"}) + transport.connect() + response = Elm327(transport).request(bytes.fromhex("010C")) + assert len(response.payload) == 5002 + + limited = FakeElmTransport(default_responses={"010C": f"41 0C {large_payload}\r\n>"}) + limited.connect() + with pytest.raises(ElmResponseTooLargeError): + Elm327(limited, max_response_size=128).request(bytes.fromhex("010C")) + + stopped = FakeElmTransport(default_responses={"010C": "STOPPED\r\n>"}) + stopped.connect() + with pytest.raises(ElmStoppedError): + Elm327(stopped).request(bytes.fromhex("010C")) + + incomplete = FakeElmTransport(default_responses={"22F190": "10 0C 62 F1 90 01\r\n>"}) + incomplete.connect() + with pytest.raises(ElmIncompleteResponseError): + Elm327(incomplete).request(bytes.fromhex("22F190")) + + wrong_sequence = FakeElmTransport(default_responses={ + "22F190": "10 0C 62 F1 90 01\r\n22 02 03 04 05\r\n>", + }) + wrong_sequence.connect() + with pytest.raises(ElmIsoTpError): + Elm327(wrong_sequence).request(bytes.fromhex("22F190")) + + +def test_elm_pending_is_one_outbound_request(): + class PendingTransport(FakeElmTransport): + def __init__(self): + super().__init__(default_responses={"22F190": "7F 22 78\r\n>"}) + self.pending_seen = False + self.final_sent = False + + def write(self, data): + super().write(data) + if data.decode("ascii").strip().upper() == "22F190": + self.pending_seen = True + + def read(self, size=4096): + with self._lock: + if self.pending_seen and not self.final_sent and not self._read_buffer: + self._read_buffer.extend(b"62 F1 90 01\r\n>") + self.final_sent = True + return super().read(size) + + transport = PendingTransport() + transport.connect() + response = Elm327(transport).request(bytes.fromhex("22F190"), pending_timeout=0.1) + diagnostic_writes = [write for write in transport.writes if write.strip() == b"22F190"] + assert len(diagnostic_writes) == 1 + assert response.payload == bytes.fromhex("62F19001") + + +def test_elm_pending_echo_is_not_parsed_as_a_second_response(): + class EchoPendingTransport(FakeElmTransport): + def __init__(self): + super().__init__(default_responses={"22F190": "22F190\r\n7F 22 78\r\n>"}) + self.final_sent = False + + def read(self, size=4096): + with self._lock: + if self.final_sent and not self._read_buffer: + self._read_buffer.extend(b"22F190\r\n62 F1 90 01\r\n>") + self.final_sent = False + result = super().read(size) + if b"7F 22 78" in result: + self.final_sent = True + return result + + transport = EchoPendingTransport() + transport.connect() + response = Elm327(transport).request(bytes.fromhex("22F190"), pending_timeout=0.1) + assert response.payload == bytes.fromhex("62F19001") + + +def test_elm_repeated_pending_responses_stay_in_one_transaction(): + class RepeatedPendingTransport(FakeElmTransport): + def __init__(self): + super().__init__(default_responses={"22F190": "7F 22 78\r\n>"}) + self.pending_count = 0 + + def read(self, size=4096): + with self._lock: + if not self._read_buffer and self.pending_count < 2: + self.pending_count += 1 + self._read_buffer.extend(b"7F 22 78\r\n>") + elif not self._read_buffer and self.pending_count == 2: + self.pending_count += 1 + self._read_buffer.extend(b"62 F1 90 01\r\n>") + return super().read(size) + + transport = RepeatedPendingTransport() + transport.connect() + response = Elm327(transport).request(bytes.fromhex("22F190"), pending_timeout=0.1) + diagnostic_writes = [write for write in transport.writes if write.strip() == b"22F190"] + assert len(diagnostic_writes) == 1 + assert response.payload == bytes.fromhex("62F19001") + + +def test_elm_pending_deadline_resets_for_each_new_pending_response(monkeypatch): + class ClockedPendingTransport(FakeElmTransport): + def __init__(self, clock): + super().__init__(default_responses={"22F190": "7F 22 78\r\n>"}) + self.clock = clock + self.pending_count = 0 + + def read(self, size=4096): + with self._lock: + if not self._read_buffer: + self.pending_count += 1 + if self.pending_count <= 2: + self.clock[0] += 0.09 + self._read_buffer.extend(b"7F 22 78\r\n>") + else: + self.clock[0] += 0.01 + self._read_buffer.extend(b"62 F1 90 01\r\n>") + return super().read(size) + + clock = [0.0] + monkeypatch.setattr("openpilot.starpilot.system.obdyssey.elm327.time.monotonic", lambda: clock[0]) + transport = ClockedPendingTransport(clock) + transport.connect() + + response = Elm327(transport, default_timeout=0.01).request(bytes.fromhex("22F190"), pending_timeout=0.1) + + assert DEFAULT_PENDING_TIMEOUT == pytest.approx(5.0) + assert response.payload == bytes.fromhex("62F19001") + assert [write for write in transport.writes if write.strip() == b"22F190"] == [b"22F190\r"] + + +def test_elm_pending_timeout_is_explicit(): + transport = FakeElmTransport(default_responses={"22F190": "7F 22 78\r\n>"}) + transport.connect() + with pytest.raises(ElmPendingTimeoutError): + Elm327(transport).request(bytes.fromhex("22F190"), pending_timeout=0.01) + + +def test_elm_repeated_pending_responses_eventually_timeout(monkeypatch): + class RepeatedPendingThenSilentTransport(FakeElmTransport): + def __init__(self, clock): + super().__init__(default_responses={"22F190": "7F 22 78\r\n>"}) + self.clock = clock + self.pending_count = 0 + + def read(self, size=4096): + with self._lock: + if not self._read_buffer: + if self.pending_count < 2: + self.pending_count += 1 + self.clock[0] += 0.09 + self._read_buffer.extend(b"7F 22 78\r\n>") + else: + # Advance beyond the rolled deadline, then remain silent. + self.clock[0] += 0.11 + raise TimeoutError("simulated ECU silence") + return super().read(size) + + clock = [0.0] + monkeypatch.setattr("openpilot.starpilot.system.obdyssey.elm327.time.monotonic", lambda: clock[0]) + transport = RepeatedPendingThenSilentTransport(clock) + transport.connect() + + with pytest.raises(ElmPendingTimeoutError): + Elm327(transport, default_timeout=0.01).request(bytes.fromhex("22F190"), pending_timeout=0.1) + + assert transport.pending_count == 2 + assert [write for write in transport.writes if write.strip() == b"22F190"] == [b"22F190\r"] + + +def test_debug_at_invalidates_context_before_contextless_request(): + transport = FakeElmTransport(default_responses={"010C": "41 0C 1F 40\r\n>"}) + transport.connect() + elm = Elm327(transport) + elm.initialize() + transport.writes.clear() + + elm.debug_at_command("ATSP6") + transport.writes.clear() + response = elm.request(bytes.fromhex("010C")) + + assert response.payload == bytes.fromhex("410C1F40") + baseline = [write.decode("ascii").strip() for write in transport.writes] + assert "ATSP0" in baseline + + +def test_obdb_provider_selects_year_and_rejects_unsafe_archives(tmp_path): + archive_bytes = io.BytesIO() + with zipfile.ZipFile(archive_bytes, "w") as archive: + archive.writestr("Demo-main/signalsets/v3/default.json", json.dumps({"commands": []})) + archive.writestr("Demo-main/signalsets/v3/2012-2020.json", json.dumps({"commands": [{"year": 1}]})) + raw = archive_bytes.getvalue() + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _size): + return raw + + provider = OBDbProvider(tmp_path / "cache", opener=lambda *_args, **_kwargs: Response()) + revision = "a" * 40 + data, metadata = provider.fetch("Demo", revision, 2014) + assert data["commands"][0]["year"] == 1 + assert metadata["signalset"] == "2012-2020.json" + + unsafe = io.BytesIO() + with zipfile.ZipFile(unsafe, "w") as archive: + archive.writestr("Demo-main/signalsets/v3/../../escape.json", "{}") + with pytest.raises(OBDbProviderError, match="unsafe"): + OBDbProvider._extract(unsafe.getvalue()) + + +def test_profile_install_rejects_symlink_target(tmp_path): + manager = ProfileManager(data_dir=tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + target = tmp_path / "linked" + try: + target.symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlinks are unavailable on this platform") + with pytest.raises(ValueError, match="regular directory"): + manager.install_profile_data("linked", _native_profile()) + + +def test_adapter_selection_is_session_only_and_clear_ends_session(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + calls: list[str] = [] + + def factory(address): + calls.append(address) + return FakeElmTransport() + + manager = ProfileManager(data_dir=tmp_path / "profiles", params=params) + controller = OBDysseyController( + params=params, transport_factory=factory, profile_manager=manager, bluetooth_client=_Bluetooth(), sleep=lambda _seconds: None, + ) + assert controller.connect_adapter("AA:BB:CC:DD:EE:FF") + assert controller.status()["adapter_address"] == "AA:BB:CC:DD:EE:FF" + + controller.disconnect() + controller.reconnect_step() + assert calls == ["AA:BB:CC:DD:EE:FF"] + + controller.clear_adapter() + assert controller.status()["configured_adapter"] is False + + +def test_new_controller_never_reconnects_a_previous_adapter(tmp_path): + params = FakeParams( + BluetoothEnabled=True, + IsOffroad=True, + ) + calls: list[str] = [] + controller = OBDysseyController( + params=params, + transport_factory=lambda address: calls.append(address) or FakeElmTransport(), + profile_manager=ProfileManager(data_dir=tmp_path / "profiles", params=params), + bluetooth_client=_Bluetooth(), + ) + controller.reconnect_step() + assert calls == [] + assert controller.status()["state"] == "idle" + + +def test_batch_reads_return_partial_success_and_deduplicate_commands(tmp_path): + params = FakeParams(BluetoothEnabled=True, IsOffroad=True) + transport = FakeElmTransport(default_responses={ + "22A001": "62 A0 01 01 02\r\n>", + "22B001": "NO DATA\r\n>", + }) + manager = ProfileManager(data_dir=tmp_path / "profiles", params=params) + manager.install_profile_data("batch", { + "commands": [ + { + "hdr": "7E0", "rax": "7E8", "cmd": {"22": "A001"}, "freq": 1, + "signals": [ + {"id": "A", "name": "A", "fmt": {"len": 8, "max": 255, "unit": "scalar"}}, + {"id": "A2", "name": "A2", "fmt": {"bix": 8, "len": 8, "max": 255, "unit": "scalar"}}, + ], + }, + { + "hdr": "7E0", "rax": "7E8", "cmd": {"22": "B001"}, "freq": 1, + "signals": [{"id": "B", "name": "B", "fmt": {"len": 8, "max": 255, "unit": "scalar"}}], + }, + ], + }) + manager.select_profile("batch") + controller = OBDysseyController( + params=params, + transport_factory=lambda _address: transport, + profile_manager=manager, + bluetooth_client=_Bluetooth(), + ) + assert controller.connect_adapter("AA:BB:CC:DD:EE:FF") + + result = controller.handle({"command": "read_signals", "ids": ["A", "A2", "B"]}) + assert result["ok"] is True + assert result["signals"] == {"A": 1, "A2": 2} + assert result["errors"]["B"]["type"] == "ElmNoDataError" + assert [write.decode("ascii").strip() for write in transport.writes].count("22A001") == 1 + assert [write.decode("ascii").strip() for write in transport.writes].count("22B001") == 1 diff --git a/starpilot/system/obdyssey/tests/test_transport.py b/starpilot/system/obdyssey/tests/test_transport.py new file mode 100644 index 000000000..22f917716 --- /dev/null +++ b/starpilot/system/obdyssey/tests/test_transport.py @@ -0,0 +1,101 @@ +import socket +import threading + +import pytest +from openpilot.starpilot.system.obdyssey.transport import BluezSppTransport, FakeElmTransport + + +class _FakeProfileClient: + def __init__(self, sock: socket.socket | None = None, error: Exception | None = None): + self.sock = sock + self.error = error + self.closed = False + + def connect_profile(self, _address: str, timeout: float): + assert timeout > 0 + if self.error is not None: + raise self.error + assert self.sock is not None + return self.sock + + def close(self): + self.closed = True + + +def test_bluez_transport_connect_does_not_deadlock_when_replacing_connection(): + peer, sock = socket.socketpair() + profile = _FakeProfileClient(sock=sock) + transport = BluezSppTransport("AA:BB:CC:DD:EE:FF", profile_factory=lambda: profile) + + worker = threading.Thread(target=transport.connect) + worker.start() + worker.join(timeout=1.0) + + assert not worker.is_alive() + assert transport._sock is sock + transport.close() + peer.close() + + +def test_bluez_transport_failed_connect_closes_profile_client_and_socket(): + profile = _FakeProfileClient(error=RuntimeError("profile failed")) + transport = BluezSppTransport("AA:BB:CC:DD:EE:FF", profile_factory=lambda: profile) + + with pytest.raises(RuntimeError, match="profile failed"): + transport.connect() + + assert profile.closed + assert transport._profile_client is None + assert transport._sock is None + + +def test_fake_transport_connect_close(): + transport = FakeElmTransport() + assert not transport.connected + + transport.connect() + assert transport.connected + + transport.write(b"ATI\r") + assert b"ATI\r" in transport.writes + data = transport.read(4096) + assert b"ELM327" in data + + transport.close() + assert not transport.connected + with pytest.raises(ConnectionResetError): + transport.read(4096) + + +def test_fake_transport_disconnect_on_write(): + transport = FakeElmTransport() + transport.connect() + transport.disconnect_on_write = True + + with pytest.raises(ConnectionResetError, match="disconnect on write"): + transport.write(b"ATZ\r") + + +def test_fake_transport_timeout_on_read(): + transport = FakeElmTransport() + transport.connect() + transport.timeout_on_read = True + + with pytest.raises(TimeoutError, match="timeout"): + transport.read(4096) + + +def test_fake_transport_custom_handler(): + def custom_handler(data: bytes) -> bytes: + if b"CUSTOM" in data: + return b"CUSTOM_REPLY\r\n>" + return b"DEFAULT\r\n>" + + transport = FakeElmTransport(handler=custom_handler) + transport.connect() + + transport.write(b"CUSTOM_CMD\r") + assert transport.read(4096) == b"CUSTOM_REPLY\r\n>" + + transport.write(b"OTHER\r") + assert transport.read(4096) == b"DEFAULT\r\n>" diff --git a/starpilot/system/obdyssey/tests/transcripts/29bit_different_response_priority.txt b/starpilot/system/obdyssey/tests/transcripts/29bit_different_response_priority.txt new file mode 100644 index 000000000..6f04bd977 --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/29bit_different_response_priority.txt @@ -0,0 +1,6 @@ +ATCP1D +OK +ATSHDB33F1 +OK +ATCRA1EC6AE80 +OK diff --git a/starpilot/system/obdyssey/tests/transcripts/29bit_standard_priority.txt b/starpilot/system/obdyssey/tests/transcripts/29bit_standard_priority.txt new file mode 100644 index 000000000..84145e863 --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/29bit_standard_priority.txt @@ -0,0 +1,6 @@ +ATCP1D +OK +ATSHDB33F1 +OK +ATCRA1DC6AE80 +OK diff --git a/starpilot/system/obdyssey/tests/transcripts/README.md b/starpilot/system/obdyssey/tests/transcripts/README.md new file mode 100644 index 000000000..d020f5344 --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/README.md @@ -0,0 +1,15 @@ +# OBDyssey diagnostic transcripts + +These small transcripts preserve the wire shapes used by the parser regression +tests. Hardware captures should be added here as soon as an adapter/vehicle +pair is available; each discovered incompatibility belongs in a dedicated +fixture rather than a new parser special case. + +Files are intentionally plain ELM terminal output so they can be replayed by a +future transport fixture without changing the production transaction path. + +For an explicit local field capture, start OBDyssey with `OBDYSSEY_DEBUG=1`. +The debug events include AT traffic, raw ELM responses, diagnostic payloads, +effective addresses, protocol/flow-control state, timing, reconnects, and +typed errors. VIN responses are redacted unless `OBDYSSEY_DEBUG_VIN=1` is also +set for local debugging. Debug logging is disabled by default. diff --git a/starpilot/system/obdyssey/tests/transcripts/atd_reset.txt b/starpilot/system/obdyssey/tests/transcripts/atd_reset.txt new file mode 100644 index 000000000..c0a2ee62c --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/atd_reset.txt @@ -0,0 +1,3 @@ +ATD +OK +> diff --git a/starpilot/system/obdyssey/tests/transcripts/can_error.txt b/starpilot/system/obdyssey/tests/transcripts/can_error.txt new file mode 100644 index 000000000..ee3f18558 --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/can_error.txt @@ -0,0 +1,3 @@ +22A001 +CAN ERROR +> diff --git a/starpilot/system/obdyssey/tests/transcripts/fcm0_singleframe.txt b/starpilot/system/obdyssey/tests/transcripts/fcm0_singleframe.txt new file mode 100644 index 000000000..54de0e250 --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/fcm0_singleframe.txt @@ -0,0 +1,6 @@ +ATCFC1 +OK +ATFCSM0 +OK +62 F1 90 31 47 31 46 +> diff --git a/starpilot/system/obdyssey/tests/transcripts/fcm1_multiframe.txt b/starpilot/system/obdyssey/tests/transcripts/fcm1_multiframe.txt new file mode 100644 index 000000000..0c10169bf --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/fcm1_multiframe.txt @@ -0,0 +1,11 @@ +# Request context: ATSH744 / ATFCSH744 / ATFCSD300000 / ATFCSM1 +ATFCSH744 +OK +ATFCSD300000 +OK +ATFCSM1 +OK +10 14 62 F1 90 31 47 +21 31 46 58 36 53 30 35 +22 48 34 31 30 30 30 30 30 +> diff --git a/starpilot/system/obdyssey/tests/transcripts/generic_clone_init.txt b/starpilot/system/obdyssey/tests/transcripts/generic_clone_init.txt new file mode 100644 index 000000000..1ab6e7f7e --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/generic_clone_init.txt @@ -0,0 +1,12 @@ +ATZ +ELM327 v1.5 +> +ATE0 +OK +> +ATI +ELM327 v1.5 +> +ATRV +13.8V +> diff --git a/starpilot/system/obdyssey/tests/transcripts/gm_mode22_multiframe.txt b/starpilot/system/obdyssey/tests/transcripts/gm_mode22_multiframe.txt new file mode 100644 index 000000000..ddbc9059b --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/gm_mode22_multiframe.txt @@ -0,0 +1,8 @@ +ATSH744 +OK +ATCRA74C +OK +10 14 62 2A 53 31 00 00 +21 00 00 00 00 00 00 00 +22 00 00 00 00 00 00 00 +> diff --git a/starpilot/system/obdyssey/tests/transcripts/incomplete_multiframe.txt b/starpilot/system/obdyssey/tests/transcripts/incomplete_multiframe.txt new file mode 100644 index 000000000..7ac3c68cd --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/incomplete_multiframe.txt @@ -0,0 +1,3 @@ +22F190 +10 0C 62 F1 90 31 47 +> diff --git a/starpilot/system/obdyssey/tests/transcripts/known_good_adapter_init.txt b/starpilot/system/obdyssey/tests/transcripts/known_good_adapter_init.txt new file mode 100644 index 000000000..dd2a69e5e --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/known_good_adapter_init.txt @@ -0,0 +1,12 @@ +ATZ +ELM327 v2.3 +> +ATE0 +OK +> +ATI +STN2230 v4.3.1 +> +ATRV +14.1V +> diff --git a/starpilot/system/obdyssey/tests/transcripts/no_data.txt b/starpilot/system/obdyssey/tests/transcripts/no_data.txt new file mode 100644 index 000000000..647e4d47a --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/no_data.txt @@ -0,0 +1,3 @@ +22A001 +NO DATA +> diff --git a/starpilot/system/obdyssey/tests/transcripts/response_pending.txt b/starpilot/system/obdyssey/tests/transcripts/response_pending.txt new file mode 100644 index 000000000..2adee81d2 --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/response_pending.txt @@ -0,0 +1,7 @@ +22F190 +7F 22 78 +> +7F 22 78 +> +62 F1 90 31 47 31 46 +> diff --git a/starpilot/system/obdyssey/tests/transcripts/stopped.txt b/starpilot/system/obdyssey/tests/transcripts/stopped.txt new file mode 100644 index 000000000..255c8bf50 --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/stopped.txt @@ -0,0 +1,3 @@ +010C +STOPPED +> diff --git a/starpilot/system/obdyssey/tests/transcripts/uds_dtc_019.txt b/starpilot/system/obdyssey/tests/transcripts/uds_dtc_019.txt new file mode 100644 index 000000000..84edacaa6 --- /dev/null +++ b/starpilot/system/obdyssey/tests/transcripts/uds_dtc_019.txt @@ -0,0 +1,3 @@ +19 02 FF +59 02 FF 12 34 56 24 +> diff --git a/starpilot/system/obdyssey/transport.py b/starpilot/system/obdyssey/transport.py new file mode 100644 index 000000000..4b757a986 --- /dev/null +++ b/starpilot/system/obdyssey/transport.py @@ -0,0 +1,239 @@ +import socket +import threading +import time +from collections.abc import Callable +from typing import Protocol + + +class ElmTransport(Protocol): + def connect(self) -> None: + ... + + def close(self) -> None: + ... + + def read(self, size: int = 4096) -> bytes: + ... + + def write(self, data: bytes) -> None: + ... + + +class BluezSppTransport: + def __init__(self, address: str, profile_factory=None, timeout: float = 10.0): + self.address = address + self.timeout = timeout + self._profile_factory = profile_factory + self._profile_client = None + self._sock: socket.socket | None = None + # connect() tears down an existing profile before replacing it. Keep the + # lock re-entrant so that close() can safely be used from that path. + self._lock = threading.RLock() + + def connect(self) -> None: + with self._lock: + self.close() + if self._profile_factory is None: + from openpilot.starpilot.system.bluetooth.bluez import BlueZProfileClient + self._profile_factory = BlueZProfileClient + + profile_client = self._profile_factory() + self._profile_client = profile_client + sock = None + try: + sock = profile_client.connect_profile(self.address, timeout=self.timeout) + sock.settimeout(self.timeout) + self._sock = sock + except Exception: + if sock is not None: + try: + sock.close() + except Exception: + pass + try: + profile_client.close() + except Exception: + pass + self._profile_client = None + self._sock = None + raise + + def close(self) -> None: + with self._lock: + if self._sock is not None: + try: + self._sock.close() + except Exception: + pass + self._sock = None + if self._profile_client is not None: + try: + self._profile_client.close() + except Exception: + pass + self._profile_client = None + + def read(self, size: int = 4096) -> bytes: + with self._lock: + sock = self._sock + if sock is None: + raise ConnectionResetError("Transport is not connected") + try: + chunk = sock.recv(size) + if not chunk: + raise ConnectionResetError("Connection closed by peer") + return chunk + except TimeoutError as err: + raise TimeoutError("Socket read timed out") from err + except (OSError, ConnectionResetError) as err: + raise ConnectionResetError(f"Socket read error: {err}") from err + + def write(self, data: bytes) -> None: + with self._lock: + sock = self._sock + if sock is None: + raise ConnectionResetError("Transport is not connected") + try: + sock.sendall(data) + except TimeoutError as err: + raise TimeoutError("Socket write timed out") from err + except (OSError, ConnectionResetError) as err: + raise ConnectionResetError(f"Socket write error: {err}") from err + + +class FakeElmTransport: + """In-memory transport simulator for testing ELM327 communications without hardware.""" + + def __init__(self, handler: Callable[[bytes], bytes | list[bytes]] | None = None, default_responses: dict[str, str] | None = None): + self.handler = handler + self.default_responses: dict[str, str] = { + "ATZ": "ELM327 v1.5\r\n>", + "ATD": "OK\r\n>", + "ATE0": "OK\r\n>", + "ATL0": "OK\r\n>", + "ATS0": "OK\r\n>", + "ATR1": "OK\r\n>", + "ATI": "ELM327 v1.5\r\n>", + "ATRV": "13.8V\r\n>", + "ATDP": "ISO 15765-4 (CAN 11/500)\r\n>", + "ATDPN": "6\r\n>", + "ATSP0": "OK\r\n>", + "ATSP3": "OK\r\n>", + "ATSP4": "OK\r\n>", + "ATSP5": "OK\r\n>", + "ATSP6": "OK\r\n>", + "ATSP7": "OK\r\n>", + "ATSP8": "OK\r\n>", + "ATSP9": "OK\r\n>", + "ATSPA": "OK\r\n>", + "ATSH": "OK\r\n>", + "ATCRA": "OK\r\n>", + "ATCAF1": "OK\r\n>", + "ATCAF0": "OK\r\n>", + "ATCFC1": "OK\r\n>", + "ATCFC0": "OK\r\n>", + "ATFCSH": "OK\r\n>", + "ATFCSD": "OK\r\n>", + "ATFCSM": "OK\r\n>", + "ATCP": "OK\r\n>", + "ATST": "OK\r\n>", + "ATCEA": "OK\r\n>", + "ATTA": "OK\r\n>", + } + if default_responses: + self.default_responses.update(default_responses) + + self.connected = False + self.writes: list[bytes] = [] + self._read_buffer = bytearray() + self._lock = threading.Lock() + self._write_event = threading.Event() + self.disconnect_on_write = False + self.timeout_on_read = False + + def connect(self) -> None: + with self._lock: + self.connected = True + + def close(self) -> None: + with self._lock: + self.connected = False + self._read_buffer.clear() + + def read(self, size: int = 4096) -> bytes: + with self._lock: + if not self.connected: + raise ConnectionResetError("Transport is not connected") + if self.timeout_on_read: + raise TimeoutError("Simulated read timeout") + if not self._read_buffer: + # If nothing buffered, simulate timeout or wait + time.sleep(0.005) + if not self._read_buffer: + raise TimeoutError("Read buffer is empty") + chunk = bytes(self._read_buffer[:size]) + del self._read_buffer[:size] + return chunk + + def write(self, data: bytes) -> None: + with self._lock: + if not self.connected: + raise ConnectionResetError("Transport is not connected") + if self.disconnect_on_write: + self.connected = False + raise ConnectionResetError("Simulated disconnect on write") + self.writes.append(data) + + # Generate response + response_bytes: bytes = b"" + if self.handler is not None: + res = self.handler(data) + if isinstance(res, list): + response_bytes = b"".join(res) + elif isinstance(res, bytes): + response_bytes = res + else: + cmd_str = data.decode("ascii", errors="ignore").strip().upper() + matched = False + # 1. Exact match first + for key, reply in self.default_responses.items(): + if cmd_str == key.upper(): + response_bytes = reply.encode("ascii") + matched = True + break + # 2. Prefix match for AT commands (e.g. ATSH 7E4 -> ATSH match) + if not matched: + for key, reply in self.default_responses.items(): + if key.startswith("AT") and len(key) > 2 and cmd_str.startswith(key.upper()): + response_bytes = reply.encode("ascii") + matched = True + break + if not matched: + # Default OBD mock reply + if cmd_str.startswith("010C"): # RPM: 2000 rpm ((0x1F * 256 + 0x40) / 4) + response_bytes = b"41 0C 1F 40\r\n>" + elif cmd_str.startswith("010D"): # Speed: 65 km/h (0x41) + response_bytes = b"41 0D 41\r\n>" + elif cmd_str.startswith("0105"): # Coolant: 90 C (0x82 - 40 = 90) + response_bytes = b"41 05 82\r\n>" + elif cmd_str.startswith("0902"): # VIN: 1G1FX6S05H4100000 + response_bytes = b"49 02 01 31 47 31 46\r\n49 02 02 58 36 53 30 35\r\n49 02 03 48 34 31 30 30 30 30 30\r\n>" + elif cmd_str.startswith("03"): # Stored DTCs: P0133, P0300 + response_bytes = b"43 01 33 03 00 00 00\r\n>" + elif cmd_str.startswith("07"): # Pending DTCs: None + response_bytes = b"47 00 00 00 00 00 00\r\n>" + elif cmd_str.startswith("0A"): # Permanent DTCs: None + response_bytes = b"4A 00 00 00 00 00 00\r\n>" + elif cmd_str.startswith("04"): # Clear DTCs: OK + response_bytes = b"44\r\n>" + elif cmd_str.startswith("22"): # UDS ReadDID + response_bytes = b"62 " + cmd_str[2:].encode("ascii") + b" 00 11 22 33\r\n>" + else: + response_bytes = b"NO DATA\r\n>" + + self._read_buffer.extend(response_bytes) + self._write_event.set() + + def queue_response(self, data: bytes) -> None: + with self._lock: + self._read_buffer.extend(data) diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 9d94384ba..cce7f91f3 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -202,6 +202,7 @@ procs = [ # StarPilot variables procs += [ PythonProcess("bluetooth_managerd", "starpilot.system.bluetooth.daemon", bluetooth_enabled, enabled=TICI), + PythonProcess("obdysseyd", "starpilot.system.obdyssey.daemon", always_run, enabled=TICI), PythonProcess("wheel_controlsd", "starpilot.system.wheel_controls.wheel_controlsd", wheel_controls_enabled, enabled=TICI, nice=19), PythonProcess("the_galaxy", "starpilot.system.the_galaxy.the_galaxy", always_run, nice=10), PythonProcess("galaxy", "starpilot.system.galaxy.galaxy", always_run, nice=10), diff --git a/system/ui/widgets/bluetooth.py b/system/ui/widgets/bluetooth.py index def4d6182..4868e7547 100644 --- a/system/ui/widgets/bluetooth.py +++ b/system/ui/widgets/bluetooth.py @@ -5,7 +5,7 @@ from functools import partial import pyray as rl -from openpilot.starpilot.system.bluetooth.protocol import BluetoothDevice, BluetoothStatus +from openpilot.starpilot.system.bluetooth.protocol import BluetoothDevice, BluetoothStatus, looks_like_obd_device from openpilot.system.ui.lib.application import FontWeight, MousePos, gui_app from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager from openpilot.system.ui.lib.multilang import tr @@ -23,6 +23,7 @@ HEADER_HEIGHT = 180 ITEM_HEIGHT = 160 HEADER_PADDING = 40 SCAN_BUTTON_WIDTH = 260 +OBDYSSEY_BUTTON_WIDTH = 260 FORGET_BUTTON_WIDTH = 180 ACTION_GAP = 35 @@ -35,6 +36,10 @@ TEXT_DISABLED = rl.Color(150, 150, 150, 255) TEXT_CONNECTED = rl.Color(113, 209, 135, 255) +def is_obd_device(device: BluetoothDevice) -> bool: + return (device.serial or looks_like_obd_device(device.name)) and not (device.audio or device.controller) + + def device_status_text(device: BluetoothDevice, operation: str, selected_audio: str) -> str: """Return the concise, state-first label shown below a Bluetooth device name.""" if operation: @@ -45,11 +50,15 @@ def device_status_text(device: BluetoothDevice, operation: str, selected_audio: capabilities.append(tr("audio output") if selected_audio.upper() == device.address.upper() else tr("audio")) if device.controller: capabilities.append(tr("controller")) + if device.serial or looks_like_obd_device(device.name): + capabilities.append(tr("OBD-II")) capability_text = " / ".join(capabilities) if device.connected: return tr("Connected") + (f" / {capability_text}" if capability_text else "") if device.paired: + if is_obd_device(device): + return tr("Paired - tap to open OBDyssey") return tr("Paired - tap to connect") return tr("Tap to pair") + (f" / {capability_text}" if capability_text else "") @@ -183,6 +192,7 @@ class BluetoothManagerUI(Widget): self._scroll_panel = GuiScrollPanel() self._power_toggle = Toggle(initial_state=False, callback=self._toggle_power) self._scan_button = Button(tr("Scan"), self._scan, button_style=ButtonStyle.NORMAL, font_size=42) + self._obdyssey_button = Button(tr("OBDyssey"), self._open_obdyssey, button_style=ButtonStyle.PRIMARY, font_size=42) self._device_rows: dict[str, BluetoothDeviceRow] = {} self._pending_power: bool | None = None self._scan_pending = False @@ -192,6 +202,10 @@ class BluetoothManagerUI(Widget): self._last_operation_error = "" self._keyboard = Keyboard(max_text_size=32, min_text_size=1, password_mode=False) + def _open_obdyssey(self, address: str = ""): + from openpilot.system.ui.widgets.obdyssey import OBDysseyScreen + gui_app.push_widget(OBDysseyScreen(adapter_address=address)) + def show_event(self): super().show_event() self._manager.set_active(True) @@ -264,6 +278,8 @@ class BluetoothManagerUI(Widget): return if not device.paired: self._manager.pair(device.address) + elif is_obd_device(device): + self._open_obdyssey(device.address) elif not device.connected: self._manager.connect(device.address) else: @@ -406,11 +422,17 @@ class BluetoothManagerUI(Widget): subtitle += " - " + tr("Limited while driving") gui_label(rl.Rectangle(rect.x + HEADER_PADDING, rect.y + 104, 650, 44), subtitle, font_size=40, color=TEXT_SECONDARY) + has_obd = any((device.connected or device.paired) and (device.serial or looks_like_obd_device(device.name)) for device in status.devices) + toggle_rect = rl.Rectangle(rect.x + rect.width - HEADER_PADDING - 160, rect.y + (rect.height - 80) / 2, 160, 80) self._power_toggle.render(toggle_rect) scan_rect = rl.Rectangle(toggle_rect.x - ACTION_GAP - SCAN_BUTTON_WIDTH, rect.y + (rect.height - 100) / 2, SCAN_BUTTON_WIDTH, 100) self._scan_button.render(scan_rect) + if has_obd: + obdyssey_rect = rl.Rectangle(scan_rect.x - ACTION_GAP - OBDYSSEY_BUTTON_WIDTH, rect.y + (rect.height - 100) / 2, OBDYSSEY_BUTTON_WIDTH, 100) + self._obdyssey_button.render(obdyssey_rect) + def _render_device_list(self, rect: rl.Rectangle, rows: list[BluetoothDeviceRow]): content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(rows) * ITEM_HEIGHT) offset = self._scroll_panel.update(rect, content_rect) diff --git a/system/ui/widgets/obdyssey.py b/system/ui/widgets/obdyssey.py new file mode 100644 index 000000000..de02f0aaf --- /dev/null +++ b/system/ui/widgets/obdyssey.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +import threading +import time +from typing import Any + +import pyray as rl + +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.starpilot.system.obdyssey.protocol import OBDysseyClient, OBDysseyStatus +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel +from openpilot.system.ui.widgets import DialogResult, Widget +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog +from openpilot.system.ui.widgets.label import gui_label + +HEADER_HEIGHT = 180 +HEADER_PADDING = 40 +BUTTON_GAP = 25 +BUTTON_HEIGHT = 100 + +PANEL_BACKGROUND = rl.BLACK +CARD_BACKGROUND = rl.Color(27, 27, 27, 255) +ROW_BORDER = rl.LIGHTGRAY +TEXT_SECONDARY = rl.Color(170, 170, 170, 255) +TEXT_CONNECTED = rl.Color(113, 209, 135, 255) +TEXT_DANGER = rl.Color(255, 100, 100, 255) +TEXT_WARNING = rl.Color(255, 185, 45, 255) + +DTC_STATE_UNAVAILABLE = "unavailable" +DTC_STATE_IN_PROGRESS = "in_progress" +DTC_STATE_CLEAN = "clean" +DTC_STATE_FAULTS = "faults" + +COMMON_SIGNAL_NAMES = { + "RPM": ("Engine RPM", "rpm"), + "VSS": ("Speed", "km/h"), + "ECT": ("Coolant Temp", "°C"), + "LOAD_PCT": ("Engine Load", "%"), + "TP": ("Throttle Position", "%"), + "IAT": ("Intake Air Temp", "°C"), + "MAF": ("MAF Air Flow", "g/s"), + "FLI": ("Fuel Level", "%"), + "VPWR": ("Module Voltage", "V"), + "AAT": ("Ambient Temp", "°C"), + "BAT_SOC": ("EV Battery SOC", "%"), + "SAE_ENGINE_RPM": ("Engine RPM", "rpm"), + "SAE_VEHICLE_SPEED": ("Speed", "km/h"), + "SAE_ENGINE_COOLANT_TEMP": ("Coolant Temp", "°C"), + "SAE_CALCULATED_ENGINE_LOAD": ("Engine Load", "%"), + "SAE_THROTTLE_POSITION": ("Throttle Position", "%"), + "SAE_INTAKE_AIR_TEMP": ("Intake Air Temp", "°C"), + "SAE_MAF_AIR_FLOW": ("MAF Air Flow", "g/s"), + "SAE_FUEL_TANK_LEVEL": ("Fuel Level", "%"), + "SAE_CONTROL_MODULE_VOLTAGE": ("Module Voltage", "V"), + "SAE_AMBIENT_AIR_TEMP": ("Ambient Temp", "°C"), + "SAE_HYBRID_EV_BATTERY_REMAINING": ("EV Battery SOC", "%"), + "BOLT_HVBAT_SOC": ("Battery SOC", "%"), + "BOLT_HVBAT_VOLTAGE": ("Pack Voltage", "V"), + "BOLT_HVBAT_CURRENT": ("Pack Current", "A"), + "BOLT_HVBAT_TEMP": ("Battery Temp", "°C"), + "BOLT_HVBAT_POWER": ("Battery Power", "kW"), + "BOLT_MOTOR_RPM": ("Motor RPM", "rpm"), + "BOLT_MOTOR_TEMP": ("Motor Temp", "°C"), +} + +# The screen is a smoke-test consumer until a demand-driven watch API exists. +# Keep its sample set intentionally small; it must not turn every OBDb signal +# into a continuously polled vehicle request. +SMOKE_TEST_SIGNAL_IDS = ( + "RPM", + "VSS", + "ECT", + "SAE_ENGINE_RPM", + "SAE_VEHICLE_SPEED", + "SAE_ENGINE_COOLANT_TEMP", + "BOLT_HVBAT_SOC", + "BOLT_HVBAT_VOLTAGE", +) + + +class OBDysseyScreen(Widget): + """Full on-device Car Scanner and Diagnostics UI panel backed by obdysseyd.""" + def __init__(self, client: OBDysseyClient | None = None, params: Params | None = None, adapter_address: str = ""): + super().__init__() + self._client = client or OBDysseyClient() + self.params = params or Params() + self._adapter_address = adapter_address + + self._scroll_panel = GuiScrollPanel() + self._back_button = Button(tr("Back"), self._go_back, button_style=ButtonStyle.NORMAL, font_size=42) + self._retry_button = Button(tr("Retry"), self._retry_connection, button_style=ButtonStyle.NORMAL, font_size=42) + self._dtc_button = Button(tr("Scan DTCs"), self._trigger_dtc_scan, button_style=ButtonStyle.NORMAL, font_size=42) + self._clear_button = Button(tr("Clear Codes"), self._confirm_clear_dtcs, button_style=ButtonStyle.DANGER, font_size=42) + + self._stop_event = threading.Event() + self._poller_thread: threading.Thread | None = None + + # Cached state updated by poller thread + self._status: OBDysseyStatus | None = None + self._available_signals: list[dict[str, Any]] = [] + self._live_telemetry: dict[str, Any] = {} + self._dtcs: list[dict[str, Any]] = [] + self._dtc_state = DTC_STATE_UNAVAILABLE + self._dtc_scan_in_progress = False + self._clear_in_progress = False + self._retry_in_progress = False + self._last_error = "" + + def _go_back(self): + gui_app.pop_widget() + + def _diagnostic_ready(self) -> bool: + status = self._status + return bool(status and (getattr(status, "diagnostic_ready", False) or getattr(status, "connected", False) or status.state == "ready")) + + def _retry_connection(self): + if self._retry_in_progress: + return + self._retry_in_progress = True + self._last_error = "" + self._dtc_state = DTC_STATE_UNAVAILABLE + + def _task(): + try: + self._connect_adapter() + except Exception as err: + self._last_error = str(err) + cloudlog.warning(f"OBDysseyScreen error retrying connection: {err}") + finally: + self._retry_in_progress = False + + threading.Thread(target=_task, daemon=True).start() + + def _connect_adapter(self): + address = getattr(self, "_adapter_address", "") + return self._client.connect(address) if address else self._client.connect() + + def _read_smoke_test_signals(self): + if not self._diagnostic_ready(): + return + available = {signal["id"]: signal for signal in self._client.list_signals()} + sample_ids = [signal_id for signal_id in SMOKE_TEST_SIGNAL_IDS if signal_id in available] + self._available_signals = [available[signal_id] for signal_id in sample_ids] + if sample_ids: + readings = self._client.read_signals(sample_ids) + # API v2 separates successful values from per-signal errors. Keep a + # small compatibility path for developer fakes implementing the old + # flat dictionary contract. + if isinstance(readings, dict) and "signals" in readings: + self._live_telemetry = dict(readings.get("signals", {})) + else: + self._live_telemetry = dict(readings or {}) + + def show_event(self): + super().show_event() + self._stop_event.clear() + self._poller_thread = threading.Thread(target=self._worker_loop, daemon=True) + self._poller_thread.start() + + def hide_event(self): + self._stop_event.set() + if self._poller_thread is not None and self._poller_thread.is_alive(): + self._poller_thread.join(timeout=0.2) + self._poller_thread = None + super().hide_event() + + def _trigger_dtc_scan(self): + if not self._diagnostic_ready(): + return + if not self._dtc_scan_in_progress: + self._dtc_scan_in_progress = True + self._dtc_state = DTC_STATE_IN_PROGRESS + self._last_error = "" + threading.Thread(target=self._scan_dtcs_worker, daemon=True).start() + + def _scan_dtcs_worker(self): + try: + dtcs = self._client.read_dtcs() + self._dtcs = dtcs + self._dtc_state = DTC_STATE_FAULTS if dtcs else DTC_STATE_CLEAN + self._last_error = "" + except Exception as err: + self._dtcs = [] + self._dtc_state = DTC_STATE_UNAVAILABLE + self._last_error = str(err) + cloudlog.warning(f"OBDysseyScreen error scanning DTCs: {err}") + finally: + self._dtc_scan_in_progress = False + + def _confirm_clear_dtcs(self): + if not self.params.get_bool("IsOffroad"): + gui_app.push_widget(alert_dialog(tr("Clearing trouble codes is only permitted when vehicle is parked / offroad."))) + return + + def apply(result: DialogResult): + if result == DialogResult.CONFIRM: + self._clear_dtcs_worker() + + gui_app.push_widget(ConfirmDialog( + tr("Clear all diagnostic trouble codes and reset Check Engine Light?"), + tr("Clear Codes"), + callback=apply, + )) + + def _clear_dtcs_worker(self): + self._clear_in_progress = True + self._dtc_state = DTC_STATE_IN_PROGRESS + + def _task(): + try: + self._client.clear_dtcs() + time.sleep(0.5) + # Rescan after clearing + dtcs = self._client.read_dtcs() + self._dtcs = dtcs + self._dtc_state = DTC_STATE_FAULTS if dtcs else DTC_STATE_CLEAN + self._last_error = "" + if dtcs: + gui_app.push_widget(alert_dialog(tr("Clear request completed, but diagnostic trouble codes remain."))) + else: + gui_app.push_widget(alert_dialog(tr("Diagnostic trouble codes cleared successfully."))) + except Exception as err: + self._dtc_state = DTC_STATE_UNAVAILABLE + self._last_error = str(err) + gui_app.push_widget(alert_dialog(tr("Failed to clear trouble codes: {}").format(err))) + finally: + self._clear_in_progress = False + + threading.Thread(target=_task, daemon=True).start() + + def _worker_loop(self): + # Fetch status once upon entry. Auto-connect only through the daemon's + # explicit/validated adapter selection. + try: + self._status = self._client.status() + if not self._diagnostic_ready() and (not self._status or self._status.state in ("idle", "disabled")): + self._connect_adapter() + self._status = self._client.status() + self._read_smoke_test_signals() + except Exception as err: + self._last_error = str(err) + + # Main status loop. Vehicle reads remain demand-driven; this screen takes + # one small sample after connection/recovery rather than polling all PIDs. + while not self._stop_event.is_set(): + try: + was_diagnostic_ready = self._diagnostic_ready() + self._status = self._client.status() + if self._status.last_error: + self._last_error = self._status.last_error + elif self._diagnostic_ready() and not was_diagnostic_ready: + # Clear a connection failure once the daemon has recovered, while + # preserving errors from a diagnostic operation made in ready state. + self._last_error = "" + + if self._diagnostic_ready(): + if not was_diagnostic_ready: + self._read_smoke_test_signals() + # A ready → ready status refresh is not a lifecycle transition. + # Preserve the last successful telemetry and DTC results. + elif was_diagnostic_ready: + # Only clear stale readings when leaving a usable backend state; + # don't repeatedly erase an already-empty/error state on every poll. + self._live_telemetry.clear() + self._dtcs.clear() + self._dtc_state = DTC_STATE_UNAVAILABLE + except Exception as err: + self._last_error = str(err) + + self._stop_event.wait(0.5) + + def _render(self, rect: rl.Rectangle): + header_rect = rl.Rectangle(rect.x, rect.y, rect.width, HEADER_HEIGHT) + self._render_header(header_rect) + + content_rect = rl.Rectangle(rect.x, rect.y + HEADER_HEIGHT, rect.width, rect.height - HEADER_HEIGHT) + self._render_content(content_rect) + + def _render_header(self, rect: rl.Rectangle): + rl.draw_rectangle_rec(rect, PANEL_BACKGROUND) + line_y = int(rect.y + rect.height - 1) + rl.draw_line(int(rect.x), line_y, int(rect.x + rect.width), line_y, ROW_BORDER) + + # Title & Adapter status subtitle + gui_label(rl.Rectangle(rect.x + HEADER_PADDING, rect.y + 26, 600, 68), tr("OBDyssey Diagnostics"), font_size=64, font_weight=FontWeight.BOLD) + + status = self._status + if status and (getattr(status, "diagnostic_ready", False) or getattr(status, "connected", False) or status.state == "ready"): + volt_str = f"{status.adapter_voltage:.1f}V" if status.adapter_voltage is not None else "" + sub_parts = [status.adapter_name or status.elm_identity or "OBDII Adapter"] + if volt_str: + sub_parts.append(volt_str) + if status.profile: + sub_parts.append(f"Profile: {status.profile}") + subtitle = " • ".join(sub_parts) + sub_color = TEXT_CONNECTED + elif status and (getattr(status, "link_connected", False) or status.state == "initializing"): + subtitle = tr("Adapter connected • Preparing diagnostics...") + sub_color = TEXT_WARNING + elif status and status.state in ("connecting", "reconnecting"): + subtitle = tr("Connecting to adapter...") + sub_color = TEXT_WARNING + elif status and status.state == "error": + subtitle = tr("Connection failed") + sub_color = TEXT_DANGER + else: + subtitle = tr("Adapter disconnected") + sub_color = TEXT_SECONDARY + + gui_label(rl.Rectangle(rect.x + HEADER_PADDING, rect.y + 104, 750, 44), subtitle, font_size=38, color=sub_color) + + error_text = (status.last_error if status else "") or self._last_error + if error_text: + # BlueZ errors can include a long diagnostic detail. Keep the status + # readable on the fixed-width header while retaining the full value in + # the daemon status/API and logs. + error_text = error_text.replace("\n", " ") + if len(error_text) > 105: + error_text = error_text[:102] + "..." + gui_label(rl.Rectangle(rect.x + HEADER_PADDING, rect.y + 150, 1000, 25), f"{tr('Error')}: {error_text}", font_size=24, color=TEXT_DANGER) + + # Action buttons on right + btn_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 + cur_x = rect.x + rect.width - HEADER_PADDING + + # Back button + cur_x -= 180 + self._back_button.render(rl.Rectangle(cur_x, btn_y, 180, BUTTON_HEIGHT)) + + # Retry is the only UI-initiated connection action. The daemon remains + # responsible for its background reconnect policy. + show_retry = bool(not status or status.state in ("idle", "error", "disabled")) + if show_retry: + cur_x -= (BUTTON_GAP + 180) + self._retry_button.set_enabled(not self._retry_in_progress) + self._retry_button.set_text(tr("Retrying...") if self._retry_in_progress else tr("Retry")) + self._retry_button.render(rl.Rectangle(cur_x, btn_y, 180, BUTTON_HEIGHT)) + + # Clear DTCs button + cur_x -= (BUTTON_GAP + 260) + self._clear_button.set_enabled(not self._clear_in_progress and self._diagnostic_ready()) + self._clear_button.set_text(tr("Clearing...") if self._clear_in_progress else tr("Clear Codes")) + self._clear_button.render(rl.Rectangle(cur_x, btn_y, 260, BUTTON_HEIGHT)) + + # Scan DTCs button + cur_x -= (BUTTON_GAP + 240) + self._dtc_button.set_enabled(not self._dtc_scan_in_progress and self._diagnostic_ready()) + self._dtc_button.set_text(tr("Scanning...") if self._dtc_scan_in_progress else tr("Scan DTCs")) + self._dtc_button.render(rl.Rectangle(cur_x, btn_y, 240, BUTTON_HEIGHT)) + + def _render_content(self, rect: rl.Rectangle): + # Build list of signals to display + signals_to_render: list[tuple[str, Any, str, str]] = [] + if self._available_signals: + for sig in self._available_signals: + sig_id = sig["id"] + val = self._live_telemetry.get(sig_id, None) + fallback_name = sig_id.replace("SAE_", "").replace("BOLT_", "").replace("_", " ").title() + common_info = COMMON_SIGNAL_NAMES.get(sig_id, (sig.get("name") or fallback_name, sig.get("unit") or "")) + name = sig.get("name") or common_info[0] + unit = sig.get("unit") or common_info[1] + signals_to_render.append((sig_id, val, name, unit)) + elif self._live_telemetry: + for sig_id, val in self._live_telemetry.items(): + name, unit = COMMON_SIGNAL_NAMES.get(sig_id, (sig_id.replace("SAE_", "").replace("BOLT_", "").replace("_", " ").title(), "")) + signals_to_render.append((sig_id, val, name, unit)) + + num_cards = len(signals_to_render) + cols = 3 + card_margin = 25 + card_width = (rect.width - 2 * HEADER_PADDING - (cols - 1) * card_margin) / cols + card_height = 150 + rows_count = (num_cards + cols - 1) // cols + + telemetry_height = rows_count * (card_height + card_margin) + 70 if num_cards > 0 else 140 + dtc_height = 120 + max(1, len(self._dtcs)) * 140 + total_height = max(rect.height + 10, telemetry_height + dtc_height + 100) + + content_rect = rl.Rectangle(rect.x, rect.y, rect.width, total_height) + offset = self._scroll_panel.update(rect, content_rect) + + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + cur_y = rect.y + HEADER_PADDING + offset + + # Section 1: Live Telemetry Grid + gui_label(rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 50), + tr("Sample Sensor Readings"), font_size=46, font_weight=FontWeight.BOLD) + cur_y += 65 + + if signals_to_render: + for idx, (_sig_id, val, name, unit) in enumerate(signals_to_render): + col = idx % cols + row = idx // cols + card_x = rect.x + HEADER_PADDING + col * (card_width + card_margin) + card_y = cur_y + row * (card_height + card_margin) + card_rect = rl.Rectangle(card_x, card_y, card_width, card_height) + + if rl.check_collision_recs(card_rect, rect): + rl.draw_rectangle_rounded(card_rect, 0.12, 16, CARD_BACKGROUND) + + # Name + gui_label(rl.Rectangle(card_x + 20, card_y + 15, card_width - 40, 36), name, font_size=32, color=TEXT_SECONDARY) + + # Value + if val is None: + val_str = "--" + val_color = TEXT_SECONDARY + elif isinstance(val, (int, float)): + val_str = f"{val:,.1f}" if isinstance(val, float) and not val.is_integer() else f"{int(val):,}" + if unit: + val_str += f" {unit}" + val_color = TEXT_CONNECTED + else: + val_str = str(val) + val_color = rl.WHITE + + gui_label(rl.Rectangle(card_x + 20, card_y + 60, card_width - 40, 65), val_str, font_size=52, font_weight=FontWeight.BOLD, color=val_color) + + cur_y += rows_count * (card_height + card_margin) + 30 + else: + state_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 100) + if rl.check_collision_recs(state_rect, rect): + rl.draw_rectangle_rounded(state_rect, 0.12, 16, CARD_BACKGROUND) + connecting = self._status and self._status.state in ("connecting", "reconnecting", "initializing") + msg = tr("Connecting to adapter to load sensor telemetry...") if connecting else tr( + "Connect to adapter to view live sensor telemetry." + ) + gui_label(rl.Rectangle(state_rect.x + 30, state_rect.y + 30, state_rect.width - 60, 40), + msg, font_size=36, color=TEXT_SECONDARY) + cur_y += 130 + + # Section 2: DTC Fault Codes + gui_label(rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 50), + tr("Diagnostic Trouble Codes (DTC)"), font_size=46, font_weight=FontWeight.BOLD) + cur_y += 65 + + dtc_state = self._dtc_state + if self._dtcs: + dtc_state = DTC_STATE_FAULTS + + if dtc_state == DTC_STATE_IN_PROGRESS: + state_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 120) + if rl.check_collision_recs(state_rect, rect): + rl.draw_rectangle_rounded(state_rect, 0.12, 16, CARD_BACKGROUND) + gui_label(rl.Rectangle(state_rect.x + 30, state_rect.y + 35, state_rect.width - 60, 50), + tr("Scanning diagnostic trouble codes..."), font_size=42, color=TEXT_WARNING) + cur_y += 140 + elif dtc_state == DTC_STATE_UNAVAILABLE: + state_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 120) + if rl.check_collision_recs(state_rect, rect): + rl.draw_rectangle_rounded(state_rect, 0.12, 16, CARD_BACKGROUND) + gui_label(rl.Rectangle(state_rect.x + 30, state_rect.y + 25, state_rect.width - 60, 40), + tr("DTC status unavailable"), font_size=42, color=TEXT_SECONDARY) + gui_label(rl.Rectangle(state_rect.x + 30, state_rect.y + 72, state_rect.width - 60, 30), + tr("Connect to the adapter and scan to check vehicle codes."), font_size=26, color=TEXT_SECONDARY) + cur_y += 140 + elif dtc_state == DTC_STATE_CLEAN and not self._dtcs: + # Clean state card, only after a successful scan. + clean_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 120) + if rl.check_collision_recs(clean_rect, rect): + rl.draw_rectangle_rounded(clean_rect, 0.12, 16, CARD_BACKGROUND) + gui_label(rl.Rectangle(clean_rect.x + 30, clean_rect.y + 35, clean_rect.width - 60, 50), + tr("✓ No diagnostic trouble codes detected. System normal."), font_size=42, color=TEXT_CONNECTED) + cur_y += 140 + else: + for dtc in self._dtcs: + dtc_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 120) + if rl.check_collision_recs(dtc_rect, rect): + rl.draw_rectangle_rounded(dtc_rect, 0.12, 16, CARD_BACKGROUND) + + code = dtc.get("code", "DTC") + source = dtc.get("source", "OBD") + ecu = dtc.get("ecu") + desc = dtc.get("description") or tr("Diagnostic trouble code reported by vehicle ECU") + + # Code badge + badge_rect = rl.Rectangle(dtc_rect.x + 25, dtc_rect.y + 25, 180, 70) + rl.draw_rectangle_rounded(badge_rect, 0.2, 12, rl.Color(80, 20, 20, 255)) + gui_label(badge_rect, code, font_size=44, font_weight=FontWeight.BOLD, color=TEXT_DANGER, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + + # Description & ECU info + gui_label(rl.Rectangle(dtc_rect.x + 230, dtc_rect.y + 20, dtc_rect.width - 250, 42), desc, font_size=38, font_weight=FontWeight.BOLD) + info_str = f"Status: Confirmed • Source: {source}" + (f" • ECU: {ecu}" if ecu else "") + gui_label(rl.Rectangle(dtc_rect.x + 230, dtc_rect.y + 65, dtc_rect.width - 250, 36), info_str, font_size=32, color=TEXT_SECONDARY) + + cur_y += 140 + + rl.end_scissor_mode() diff --git a/third_party/obdb_saej1979/LICENSE b/third_party/obdb_saej1979/LICENSE new file mode 100644 index 000000000..d848da9eb --- /dev/null +++ b/third_party/obdb_saej1979/LICENSE @@ -0,0 +1,15 @@ +Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) + +This profile is sourced from the Open On-Board Diagnostics Database (OBDb) +Repository: https://github.com/OBDb/SAEJ1979 +Pinned revision: d3259214a9e0340c4a6cff9ec5f8ff5953eee6f2 +Path: signalsets/v3/default.json +License: CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/) + +You are free to: +- Share — copy and redistribute the material in any medium or format +- Adapt — remix, transform, and build upon the material for any purpose, even commercially. + +Under the following terms: +- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. +- ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original. diff --git a/third_party/obdb_saej1979/profile.json b/third_party/obdb_saej1979/profile.json new file mode 100644 index 000000000..5e70d836f --- /dev/null +++ b/third_party/obdb_saej1979/profile.json @@ -0,0 +1,761 @@ +{ "commands": [ +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "01"}, "freq": 10, + "signals": [ + {"id": "MIL", "path": "DTCs.Generic", "fmt": { "len": 1, "max": 1, "unit": "offon" }, "name": "Malfunction indicator lamp", "description": "The MIL status is OFF during key-on, engine-off bulb check unless MIL has also been commanded ON for a detected malfunction. The status reflects whether there are confirmed DTC(s) stored that are illuminating the MIL. It should not reflect the status of the MIL, which could be on for a function check, flashing I/M readiness or flashing for misfire."}, + {"id": "DTC_CNT", "path": "DTCs.Generic", "fmt": {"bix": 1, "len": 7, "max": 127, "unit": "scalar" }, "name": "Number of DTCs stored in this ECU", "description": "Number of confirmed emission-related DTCs stored in the ECU available for display using Service $03."}, + {"id": "CCM_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 9, "len": 1, "max": 1, "unit": "yesno" }, "name": "Comprehensive component monitoring ready"}, + {"id": "FUEL_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 10, "len": 1, "max": 1, "unit": "yesno" }, "name": "Fuel system monitoring ready"}, + {"id": "MIS_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 11, "len": 1, "max": 1, "unit": "yesno" }, "name": "Misfire monitoring ready"}, + {"id": "CCM_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 13, "len": 1, "max": 1, "unit": "noyes" }, "name": "Comprehensive component monitoring supported"}, + {"id": "FUEL_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 14, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system monitoring supported"}, + {"id": "MIS_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 15, "len": 1, "max": 1, "unit": "noyes" }, "name": "Misfire monitoring supported"}, + {"id": "EGR_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 16, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR system monitoring supported"}, + {"id": "HTR_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 17, "len": 1, "max": 1, "unit": "noyes" }, "name": "Oxygen sensor heater monitoring supported"}, + {"id": "O2S_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 18, "len": 1, "max": 1, "unit": "noyes" }, "name": "Oxygen sensor monitoring supported"}, + {"id": "ACRF_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 19, "len": 1, "max": 1, "unit": "noyes" }, "name": "A/C system refrigerant monitoring supported"}, + {"id": "AIR_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 20, "len": 1, "max": 1, "unit": "noyes" }, "name": "Secondary air system monitoring supported"}, + {"id": "EVAP_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 21, "len": 1, "max": 1, "unit": "noyes" }, "name": "Evaporative system monitoring supported"}, + {"id": "HCAT_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 22, "len": 1, "max": 1, "unit": "noyes" }, "name": "Heated catalyst monitoring supported"}, + {"id": "CAT_SUP", "path": "DTCs.Generic.Support", "fmt": {"bix": 23, "len": 1, "max": 1, "unit": "noyes" }, "name": "Catalyst monitoring supported"}, + {"id": "EGR_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 24, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR system monitoring ready"}, + {"id": "HTR_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 25, "len": 1, "max": 1, "unit": "noyes" }, "name": "Oxygen sensor heater monitoring ready"}, + {"id": "O2S_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 26, "len": 1, "max": 1, "unit": "noyes" }, "name": "Oxygen sensor monitoring ready"}, + {"id": "ACRF_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 27, "len": 1, "max": 1, "unit": "noyes" }, "name": "A/C system refrigerant monitoring ready"}, + {"id": "AIR_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 28, "len": 1, "max": 1, "unit": "noyes" }, "name": "Secondary air system monitoring ready"}, + {"id": "EVAP_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 29, "len": 1, "max": 1, "unit": "noyes" }, "name": "Evaporative system monitoring ready"}, + {"id": "HCAT_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 30, "len": 1, "max": 1, "unit": "noyes" }, "name": "Heated catalyst monitoring ready"}, + {"id": "CAT_RDY", "path": "DTCs.Generic.Status", "fmt": {"bix": 31, "len": 1, "max": 1, "unit": "noyes" }, "name": "Catalyst monitoring ready"}, + {"id": "CIM_SUP", "path": "DTCs.Generic.Support", "name": "Compression ignition monitoring supported", "description": "Indicates support of spark ignition or compression ignition monitors.", "hidden": true, "fmt": {"bix": 12, "len": 1, "map": { + "0": { "description": "Spark ignition monitors supported", "value": "SPARK" }, + "1": { "description": "Compression ignition monitors supported", "value": "COMPRESSION" } + }} + } + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "02"}, "freq": 60, + "signals": [ + {"id": "DTCFRZF", "path": "DTCs.Generic", "fmt": { "len": 16, "max": 65535, "unit": "hex" }, "name": "DTC that caused required freeze frame data storage", "description": "0 indicates no freeze frame data."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "03"}, "freq": 0.25, + "signals": [ + {"id": "FUELSYS1", "path": "Fuel.Generic", "name": "Fuel system 1 status", "fmt": {"len": 8, "map": { + "0": { "description": "Engine is off", "value": "OFF" }, + "1": { "description": "Open loop - has not yet satisfied conditions to go closed loop", "value": "OL" }, + "2": { "description": "Closed loop - using oxygen sensor(s) as feedback for fuel control", "value": "CL" }, + "4": { "description": "Open loop due to driving conditions (e.g. power enrichment, deceleration enleanment)", "value": "OL-Drive" }, + "8": { "description": "Open loop - due to detected system fault", "value": "OL-Fault" }, + "16": { "description": "Closed loop, but fault with at least one oxygen sensor - may be using single oxygen sensor for fuel control", "value": "CL-Fault" }, + "32": { "description": "Open loop - has not yet satisfied conditions to go closed loop (Bank 2)", "value": "OL B2" }, + "64": { "description": "Open loop due to driving conditions (Bank 2) (e.g. power enrichment, deceleration enleanment, cylinder deactivation)", "value": "OL-Drive B2" }, + "128": { "description": "Open loop - due to detected system fault (Bank 2)", "value": "OL-Fault B2" } + }} + }, + {"id": "FUELSYS2", "path": "Fuel.Generic", "name": "Fuel system 2 status", "fmt": {"bix": 8, "len": 8, "map": { + "0": { "description": "Engine is off", "value": "OFF" }, + "1": { "description": "Open loop - has not yet satisfied conditions to go closed loop", "value": "OL" }, + "2": { "description": "Closed loop - using oxygen sensor(s) as feedback for fuel control", "value": "CL" }, + "4": { "description": "Open loop due to driving conditions (e.g. power enrichment, deceleration enleanment)", "value": "OL-Drive" }, + "8": { "description": "Open loop - due to detected system fault", "value": "OL-Fault" }, + "16": { "description": "Closed loop, but fault with at least one oxygen sensor - may be using single oxygen sensor for fuel control", "value": "CL-Fault" }, + "32": { "description": "Open loop - has not yet satisfied conditions to go closed loop (Bank 2)", "value": "OL B2" }, + "64": { "description": "Open loop due to driving conditions (Bank 2) (e.g. power enrichment, deceleration enleanment, cylinder deactivation)", "value": "OL-Drive B2" }, + "128": { "description": "Open loop - due to detected system fault (Bank 2)", "value": "OL-Fault B2" } + }} + } + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "04"}, "freq": 0.25, + "signals": [ + {"id": "LOAD_PCT", "path": "Engine.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Calculated engine load", "suggestedMetric": "engineLoad", "description": "Represents the amount of work the engine is doing. Expected to reach 100% at wide open throttle/wide open pedal at any altitude, temperature or rpm for both naturally aspirated and boosted engines. If engine load is limited for powertrain protection e.g. engine/turbocharger protection, this value may not reach 100%. For hybrid vehicles, indicates the torque produced only by the internal combustion engine, not the torque being delivered by the entire powertrain. For electric vehicles, the meaning of this parameter is undefined."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "05"}, "freq": 0.5, + "signals": [ + {"id": "ECT", "path": "Engine.Generic", "fmt": { "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Engine coolant temperature", "suggestedMetric": "engineCoolantTemperature", "description": "Your engine temperature must operate within a certain temperature range to operate efficiently and safely. If it runs too hot, then your engine could be permanently damaged. If it runs too cold, then your engine will use more fuel than necessary."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "06"}, "freq": 0.25, + "signals": [ + {"id": "SHRTFT1", "path": "Engine.Generic", "fmt": { "len": 8, "max": 99.2, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Short term fuel trim (bank 1)", "suggestedMetric": "shortTermFuelTrim", "description": "Correction being used by the closed-loop fuel algorithm."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "07"}, "freq": 1, + "signals": [ + {"id": "LONGFT1", "path": "Engine.Generic", "fmt": { "len": 8, "max": 99.2, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Long term fuel trim (bank 1)"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "08"}, "freq": 1, + "signals": [ + {"id": "SHRTFT2", "path": "Engine.Generic", "fmt": { "len": 8, "max": 99.2, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Short term fuel trim (bank 2)", "suggestedMetric": "shortTermFuelTrim", "description": "Correction being used by the closed-loop fuel algorithm."}, + {"id": "SHRTFT4", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 8, "max": 99.2, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Short term fuel trim (bank 4)", "description": "Correction being used by the closed-loop fuel algorithm."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "09"}, "freq": 1, + "signals": [ + {"id": "LONGFT2", "path": "Engine.Generic", "fmt": { "len": 8, "max": 99.2, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Long term fuel trim (bank 2)"}, + {"id": "LONGFT4", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 8, "max": 99.2, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Long term fuel trim (bank 4)"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "0A"}, "freq": 1, + "signals": [ + {"id": "FP", "path": "Engine.Generic", "fmt": { "len": 8, "max": 765, "mul": 3, "unit": "kilopascal" }, "name": "Fuel pressure"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "0B"}, "freq": 1, + "signals": [ + {"id": "MAP", "path": "Engine.Generic", "fmt": { "len": 8, "max": 255, "unit": "kilopascal" }, "name": "Intake manifold absolute pressure"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "0C"}, "freq": 0.25, + "signals": [ + {"id": "RPM", "path": "Engine.Generic", "fmt": { "len": 16, "max": 16383.75, "div": 4, "unit": "rpm" }, "name": "Engine RPM"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "0D"}, "freq": 0.25, + "signals": [ + {"id": "VSS", "path": "Movement.Generic", "fmt": { "len": 8, "max": 255, "nullmax": 255, "unit": "kilometersPerHour" }, "name": "Vehicle speed", "suggestedMetric": "speed"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "0E"}, "freq": 0.25, + "signals": [ + {"id": "SPARKADV", "path": "Engine.Generic", "fmt": { "len": 8, "max": 63.5, "min": -64, "div": 2, "add": -64, "unit": "degrees" }, "name": "Timing advance", "description": "Ignition timing spark advance for the first cylinder. Measured in degrees before dead center. Minus is degrees before dead center, positive is degrees after."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "0F"}, "freq": 0.25, + "signals": [ + {"id": "IAT", "path": "Engine.Generic", "fmt": { "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Intake air temperature"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "10"}, "freq": 0.25, + "signals": [ + {"id": "MAF", "path": "Engine.Generic", "fmt": { "len": 16, "max": 655.35, "div": 100, "unit": "gramsPerSecond" }, "name": "Air flow rate from mass air flow sensor", "suggestedMetric": "massAirFlow"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "11"}, "freq": 0.25, + "signals": [ + {"id": "TP", "path": "Control.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Absolute throttle position", "suggestedMetric": "throttlePosition", "description": "Throttle position at idle will usually be more than 0%, and throttle position at wide open throttle will usually be less than 100%."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "12"}, "freq": 60, + "signals": [ + {"id": "AIR_STAT", "path": "Engine.Generic", "name": "Commanded secondary air status", "fmt": {"bix": 5, "len": 3, "map": { + "1": { "description": "Upstream of first catalytic converter", "value": "UPS" }, + "2": { "description": "Downstream of first catalytic converter inlet", "value": "DNS" }, + "4": { "description": "Atmosphere / off", "value": "OFF" }, + "8": { "description": "Pump commanded on for diagnostics", "value": "DIAG" } + }} + } + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "13"}, "freq": 3600, + "signals": [ + {"id": "O2S24_EXISTS", "path": "Engine.Generic.OxygenSensors", "fmt": { "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 2, Sensor 4 present"}, + {"id": "O2S23_EXISTS", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 1, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 2, Sensor 3 present"}, + {"id": "O2S22_EXISTS", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 2, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 2, Sensor 2 present"}, + {"id": "O2S21_EXISTS", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 3, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 2, Sensor 1 present"}, + {"id": "O2S14_EXISTS", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 1, Sensor 4 present"}, + {"id": "O2S13_EXISTS", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 1, Sensor 3 present"}, + {"id": "O2S12_EXISTS", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 1, Sensor 2 present"}, + {"id": "O2S11_EXISTS", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 1, Sensor 1 present"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "14"}, "freq": 1, + "signals": [ + {"id": "O2S11", "path": "Engine.Generic.OxygenSensors", "fmt": { "len": 8, "max": 1.275, "div": 200, "unit": "volts" }, "name": "O2S Output Voltage Bank 1, Sensor 1"}, + {"id": "SHRTFT11", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 8, "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -128, "unit": "percent" }, "name": "SHRTFT associated with O2S11"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "15"}, "freq": 1, + "signals": [ + {"id": "O2S12", "path": "Engine.Generic.OxygenSensors", "fmt": { "len": 8, "max": 1.275, "div": 200, "unit": "volts" }, "name": "O2S Output Voltage Bank 1, Sensor 2"}, + {"id": "SHRTFT11", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 8, "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -128, "unit": "percent" }, "name": "SHRTFT associated with O2S12"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "18"}, "freq": 1, + "signals": [ + {"id": "O2S21", "path": "Engine.Generic.OxygenSensors", "fmt": { "len": 8, "max": 1.275, "div": 200, "unit": "volts" }, "name": "O2S Output Voltage Bank 2, Sensor 1"}, + {"id": "SHRTFT21", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 8, "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -128, "unit": "percent" }, "name": "SHRTFT associated with O2S21"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "19"}, "freq": 1, + "signals": [ + {"id": "O2S22", "path": "Engine.Generic.OxygenSensors", "fmt": { "len": 8, "max": 1.275, "div": 200, "unit": "volts" }, "name": "O2S Output Voltage Bank 2, Sensor 2"}, + {"id": "SHRTFT22", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 8, "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -128, "unit": "percent" }, "name": "SHRTFT associated with O2S22"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "1C"}, "freq": 3600, + "signals": [ + {"id": "OBDSUP", "path": "OBD.Generic", "name": "OBD requirements to which vehicle is designed", "fmt": {"len": 8, "map": { + "1": { "description": "OBD II (California ARB)", "value": "OBD II" }, + "2": { "description": "OBD (Federal EPA)", "value": "OBD" }, + "3": { "description": "OBD & OBD II", "value": "OBD & OBD II" }, + "4": { "description": "OBD I", "value": "OBD I" }, + "5": { "description": "Not OBD compliant", "value": "NO OBD" }, + "6": { "description": "EOBD", "value": "EOBD" }, + "7": { "description": "EOBD & OBD II", "value": "EOBD & OBD II" }, + "8": { "description": "EOBD & OBD", "value": "EOBD & OBD" }, + "9": { "description": "EOBD, OBD, OBD II", "value": "EOBD, OBD, OBD II" }, + "10": { "description": "JOBD", "value": "JOBD" }, + "11": { "description": "JOBD & OBD II", "value": "JOBD & OBD II" }, + "12": { "description": "JOBD & EOBD", "value": "JOBD & EOBD" }, + "13": { "description": "JOBD, EOBD, OBD II", "value": "JOBD, EOBD, OBD II" }, + "14": { "description": "Heavy duty vehicles (EURO IV) B1", "value": "EURO IV B1" }, + "15": { "description": "Heavy duty vehicles (EURO V) B2", "value": "EURO V B2" }, + "16": { "description": "Heavy duty vehicles (EURO EEC) C (gas engines)", "value": "EURO C" }, + "17": { "description": "Engine manufacturer diagnostics (EMD)", "value": "EMD" }, + "18": { "description": "Engine Manufacturer Diagnostics Enhanced (EMD+)", "value": "EMD+" }, + "19": { "description": "Heavy Duty On-Board Diagnostics (Child/Partial)", "value": "HD OBD-C" }, + "20": { "description": "Heavy Duty On-Board Diagnostics", "value": "HD OBD" }, + "21": { "description": "World Wide Harmonized OBD", "value": "WWH OBD" }, + "23": { "description": "Heavy Duty Euro OBD Stage I without NOx Control", "value": "HD EOBD-I" }, + "24": { "description": "Heavy Duty Euro OBD Stage I with NOx Control", "value": "HD EOBD-I N" }, + "25": { "description": "Heavy Duty Euro OBD Stage II without NOx Control", "value": "HD EOBD-II" }, + "26": { "description": "Heavy Duty Euro OBD Stage II with NOx Control", "value": "HD EOBD-II N" }, + "27": { "description": "Heavy Duty ZEV", "value": "HD-ZEV" }, + "28": { "description": "Brazil OBD Phase 1", "value": "OBDBr-1" }, + "29": { "description": "Brazil OBD Phase 2 and Phase 2+", "value": "OBDBr-2" }, + "30": { "description": "Korean OBD", "value": "KOBD" }, + "31": { "description": "India BS4 OBD I", "value": "IOBD-I-BS4" }, + "32": { "description": "India BS4 OBD II", "value": "IOBD-II-BS4" }, + "33": { "description": "Euro VI", "value": "HD EOBD-VI" }, + "34": { "description": "OBD, OBD II and HD OBD", "value": "OBD, OBD II and HD OBD" }, + "35": { "description": "Brazil OBD Phase 3", "value": "OBDBr-3" }, + "36": { "description": "Motorcycle, Euro OBD-I", "value": "MC EOBD-I" }, + "37": { "description": "Motorcycle, Euro OBD-II", "value": "MC EOBD-II" }, + "38": { "description": "Motorcycle, China OBD-I", "value": "MC COBD-I" }, + "39": { "description": "Motorcycle, Taiwan OBD-I", "value": "MC TOBD-I" }, + "40": { "description": "Motorcycle, Japan OBD-I", "value": "MC JOBD-I" }, + "41": { "description": "China Nationwide Stage 6", "value": "CN-OBD-6" }, + "42": { "description": "Brazil OBD Phase 7", "value": "OBDBr-P7" }, + "43": { "description": "China Heavy Duty VI", "value": "CN-HDOBD-VI" }, + "44": { "description": "India BS6 OBD I", "value": "IOBD-I-BS6" }, + "45": { "description": "India BS6 OBD II", "value": "IOBD-II-BS6" }, + "46": { "description": "India BSVI HD OBD", "value": "IHDOBD-BSVI" }, + "47": { "description": "Brazil OBD Phase 8", "value": "OBDBr-P8" }, + "48": { "description": "Japan Heavy Duty OBD-II", "value": "HD-JOBD-II" }, + "49": { "description": "Korea Heavy Duty OBD-II", "value": "HD-KOBD-II" }, + "50": { "description": "China Off-Road IV OBD", "value": "CN-OROBD-IV" }, + "51": { "description": "Light Duty ZEV, ACC-II", "value": "CARB ACC-II" }, + "52": { "description": "Motorcycle, Japan OBD-II", "value": "MC JOBD-II" }, + "53": { "description": "Motorcycle, California (CARB) OBD", "value": "MC CARB OBD" }, + "54": { "description": "Motorcycle, Federal (EPA) OBD", "value": "MC EPA OBD" }, + "55": { "description": "Motorcycle, 50-State (CARB & EPA) OBD", "value": "MC CARB & EPA OBD" }, + "56": { "description": "Heavy Duty ZEV, CARB ZEP", "value": "HD ZEV CARB ZEP" }, + "57": { "description": "Light Duty ZEV, CARB ACC-II and EPA Tier 4 (GTR 22)", "value": "CARB ACC-II & EPA TIER4" }, + "58": { "description": "Light Duty ZEV, EPA Tier 4 (GTR 22)", "value": "EPA TIER4" }, + "59": { "description": "EPA HD OBD", "value": "EPA HD" } + }} + } + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "1D"}, "freq": 3600, + "signals": [ + {"id": "O2S42_EXISTS", "path": "Engine.Generic", "fmt": { "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 4, Sensor 2 present"}, + {"id": "O2S41_EXISTS", "path": "Engine.Generic", "fmt": {"bix": 1, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 4, Sensor 1 present"}, + {"id": "O2S32_EXISTS", "path": "Engine.Generic", "fmt": {"bix": 2, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 3, Sensor 2 present"}, + {"id": "O2S31_EXISTS", "path": "Engine.Generic", "fmt": {"bix": 3, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 3, Sensor 1 present"}, + {"id": "O2S22_EXISTS", "path": "Engine.Generic", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 2, Sensor 2 present"}, + {"id": "O2S21_EXISTS", "path": "Engine.Generic", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 2, Sensor 1 present"}, + {"id": "O2S12_EXISTS", "path": "Engine.Generic", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 1, Sensor 2 present"}, + {"id": "O2S11_EXISTS", "path": "Engine.Generic", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "02S Bank 1, Sensor 1 present"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "1E"}, "freq": 1, + "signals": [ + {"id": "PTO_STAT", "path": "Engine.Generic", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "offon" }, "name": "Power take off (PTO) status"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "1F"}, "freq": 1, + "signals": [ + {"id": "RUNTM", "path": "Clocks.Generic", "fmt": { "len": 16, "max": 65535, "unit": "seconds" }, "name": "Time since engine start", "description": "Increments while the engine is running. Freezes if the engine stalls. Resets to zero during every control module power-up and when entering the key-on, engine off position. Limited to 65,535 seconds and will not wrap around to zero."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "21"}, "freq": 5, + "signals": [ + {"id": "MIL_DIST", "path": "DTCs.Generic", "fmt": { "len": 16, "max": 65535, "unit": "kilometers" }, "name": "Distance traveled while MIL was activated", "description": "Resets to zero when MIL changes from deactivated to activated, if diagnostic information is cleared, or if at least 40 warm-up cycles occur without MIL being activated."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "22"}, "freq": 0.25, + "signals": [ + {"id": "FRP_REL", "path": "Engine.Generic", "fmt": { "len": 16, "max": 5177.27, "mul": 0.079, "unit": "kilopascal" }, "name": "Fuel pressure relative to manifold vacuum", "description": "Fuel rail pressure at the engine when the reading is referenced to manifold vacuum (relative pressure)."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "23"}, "freq": 0.25, + "signals": [ + {"id": "FRP", "path": "Engine.Generic", "fmt": { "len": 16, "max": 655350, "mul": 10, "unit": "kilopascal" }, "name": "Fuel rail pressure", "description": "Fuel rail pressure at the engine when the reading is referenced to atmosphere (gage pressure)."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "24"}, "freq": 0.25, + "signals": [ + {"id": "LAMBDA11_VOLT", "path": "Engine.Generic.OxygenSensors", "fmt": { "len": 16, "max": 1.99, "div": 32768, "unit": "scalar" }, "name": "Lambda value, Equivalence Ratio Bank 1, Sensor 1", "suggestedMetric": "o2Lambda"}, + {"id": "O2S11_VOLT", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 16, "len": 16, "max": 7.999, "div": 8196, "unit": "volts" }, "name": "Wide Range O2S Voltage, O2 Sensor Bank 1, Sensor 1"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "2C"}, "freq": 1, + "signals": [ + {"id": "EGR_PCT", "path": "Engine.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Commanded EGR"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "2D"}, "freq": 1, + "signals": [ + {"id": "EGR_ERR", "path": "Engine.Generic", "fmt": { "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "EGR error"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "2E"}, "freq": 1, + "signals": [ + {"id": "EVAP_PCT", "path": "Engine.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Commanded evaporative purge"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "2F"}, "freq": 1, + "signals": [ + {"id": "FLI", "path": "Fuel.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "nullmin": 0, "unit": "percent" }, "name": "Fuel tank level", "suggestedMetric": "fuelTankLevel"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "30"}, "freq": 60, + "signals": [ + {"id": "WARM_UPS", "path": "DTCs.Generic", "fmt": { "len": 8, "max": 255, "unit": "scalar" }, "name": "Number of warm-ups since diagnostic trouble codes cleared", "description": "Number of OBD warm-up cycles since all DTCs were cleared (via an external test equipment or possibly, a battery disconnect). A warm-up is defined in the OBD regulations to be sufficient vehicle operation such that coolant temperature rises by at least 22°C (40°F) from engine starting and reaches a minimum temperature of 70°C (160°F) (60°C (140°F) for diesels). This PID is not associated with any particular DTC. It is simply an indication for inspection/maintenance, of the last time an external test equipment was used to clear DTCs. If greater than 255 warm-ups have occurred, this parameter will remain at 255 and not wrap to zero."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "31"}, "freq": 15, + "signals": [ + {"id": "CLR_DIST", "path": "DTCs.Generic", "fmt": { "len": 16, "max": 65535, "unit": "kilometers" }, "name": "Distance traveled since diagnostic trouble codes cleared", "suggestedMetric": "distanceSinceDTCsCleared", "description": "Distance accumulated since DTCs were cleared (via an external test equipment or possibly, a battery disconnect). This parameter is not associated with any particular DTC. It is simply an indication for inspection/maintenance, of the last time an external test equipment was used to clear DTCs. If greater than 65,535 km have occurred, will remain at 65,535 km and not wrap to zero."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "32"}, "freq": 0.25, + "signals": [ + {"id": "EVAP_VP", "path": "Engine.Generic", "fmt": { "len": 16, "max": 8191.75, "min": -8192, "div": 4000, "sign": true, "unit": "kilopascal" }, "name": "Evap system vapor pressure", "description": "The pressure signal is normally obtained from a sensor located in the fuel tank or a sensor in an evaporative system vapor line."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "33"}, "freq": 0.25, + "signals": [ + {"id": "BARO", "path": "Engine.Generic", "fmt": { "len": 8, "max": 255, "unit": "kilopascal" }, "name": "Barometric pressure", "description": "Normally obtained from one of a dedicated barometric sensor, a MAP sensor at key-on and during certain modes of driving, or inferred from a MAF sensor and other inputs during certain modes of driving. The control module reports BARO from whatever source it is derived from."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "34"}, "freq": 0.25, + "signals": [ + {"id": "LAMBDA11_CURRENT", "path": "Engine.Generic.OxygenSensors", "fmt": { "len": 16, "max": 1.99, "div": 32768, "unit": "scalar" }, "name": "Lambda value, Equivalence Ratio Bank 1, Sensor 1", "suggestedMetric": "o2Lambda"}, + {"id": "O2S11_CURRENT", "path": "Engine.Generic.OxygenSensors", "fmt": {"bix": 16, "len": 16, "max": 127.996, "min": -128, "div": 256, "add": -128, "unit": "milliamps" }, "name": "Wide Range O2S Current, O2 Sensor Bank 1, Sensor 1"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "3C"}, "freq": 0.25, + "signals": [ + {"id": "CATEMP11", "path": "Engine.Generic", "fmt": { "len": 16, "max": 6513.5, "min": -40, "div": 10, "add": -40, "unit": "celsius" }, "name": "Catalyst temperature bank 1, sensor 1"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "3D"}, "freq": 0.25, + "signals": [ + {"id": "CATEMP21", "path": "Engine.Generic", "fmt": { "len": 16, "max": 6513.5, "min": -40, "div": 10, "add": -40, "unit": "celsius" }, "name": "Catalyst temperature bank 2, sensor 1"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "3E"}, "freq": 0.25, + "signals": [ + {"id": "CATEMP12", "path": "Engine.Generic", "fmt": { "len": 16, "max": 6513.5, "min": -40, "div": 10, "add": -40, "unit": "celsius" }, "name": "Catalyst temperature bank 1, sensor 2"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "3F"}, "freq": 0.25, + "signals": [ + {"id": "CATEMP22", "path": "Engine.Generic", "fmt": { "len": 16, "max": 6513.5, "min": -40, "div": 10, "add": -40, "unit": "celsius" }, "name": "Catalyst temperature bank 2, sensor 2"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "41"}, "freq": 1, + "signals": [ + {"id": "CCM_CMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 9, "len": 1, "max": 1, "unit": "yesno" }, "name": "Comprehensive component monitoring completed"}, + {"id": "FUEL_CMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 10, "len": 1, "max": 1, "unit": "yesno" }, "name": "Fuel system monitoring completed"}, + {"id": "MIS_CMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 11, "len": 1, "max": 1, "unit": "yesno" }, "name": "Misfire monitoring completed"}, + {"id": "CCM_ENA", "path": "DTCs.Generic.Support", "fmt": {"bix": 13, "len": 1, "max": 1, "unit": "noyes" }, "name": "Comprehensive component monitoring enabled"}, + {"id": "FUEL_ENA", "path": "DTCs.Generic.Support", "fmt": {"bix": 14, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system monitoring enabled"}, + {"id": "MIS_ENA", "path": "DTCs.Generic.Support", "fmt": {"bix": 15, "len": 1, "max": 1, "unit": "noyes" }, "name": "Misfire monitoring enabled"}, + {"id": "EGR_ENA", "path": "DTCs.Generic.Status", "fmt": {"bix": 16, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR system monitoring"}, + {"id": "HTR_ENA", "path": "DTCs.Generic.Status", "fmt": {"bix": 17, "len": 1, "max": 1, "unit": "noyes" }, "name": "Oxygen sensor heater monitoring"}, + {"id": "O2S_ENA", "path": "DTCs.Generic.Status", "fmt": {"bix": 18, "len": 1, "max": 1, "unit": "noyes" }, "name": "Oxygen sensor monitoring"}, + {"id": "ACRF_ENA", "path": "DTCs.Generic.Status", "fmt": {"bix": 19, "len": 1, "max": 1, "unit": "noyes" }, "name": "A/C system refrigerant monitoring"}, + {"id": "AIR_ENA", "path": "DTCs.Generic.Status", "fmt": {"bix": 20, "len": 1, "max": 1, "unit": "noyes" }, "name": "Secondary air system monitoring"}, + {"id": "EVAP_ENA", "path": "DTCs.Generic.Status", "fmt": {"bix": 21, "len": 1, "max": 1, "unit": "noyes" }, "name": "Evaporative system monitoring"}, + {"id": "HCAT_ENA", "path": "DTCs.Generic.Status", "fmt": {"bix": 22, "len": 1, "max": 1, "unit": "noyes" }, "name": "Heated catalyst monitoring"}, + {"id": "CAT_ENA", "path": "DTCs.Generic.Status", "fmt": {"bix": 23, "len": 1, "max": 1, "unit": "noyes" }, "name": "Catalyst monitoring"}, + {"id": "EGR_CMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 24, "len": 1, "max": 1, "unit": "yesno" }, "name": "EGR system monitoring completed"}, + {"id": "HTR_CMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 25, "len": 1, "max": 1, "unit": "yesno" }, "name": "Oxygen sensor heater monitoring completed"}, + {"id": "O2S_CMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 26, "len": 1, "max": 1, "unit": "yesno" }, "name": "Oxygen sensor monitoring completed"}, + {"id": "ACRFCMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 27, "len": 1, "max": 1, "unit": "yesno" }, "name": "A/C system refrigerant monitoring completed"}, + {"id": "AIR_CMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 28, "len": 1, "max": 1, "unit": "yesno" }, "name": "Secondary air system monitoring completed"}, + {"id": "EVAPCMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 29, "len": 1, "max": 1, "unit": "yesno" }, "name": "Evaporative system monitoring completed"}, + {"id": "HCATCMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 30, "len": 1, "max": 1, "unit": "yesno" }, "name": "Heated catalyst monitoring completed"}, + {"id": "CAT_CMPL", "path": "DTCs.Generic.Status", "fmt": {"bix": 31, "len": 1, "max": 1, "unit": "yesno" }, "name": "Catalyst monitoring completed"}, + {"id": "CIM_SUP_FLAG", "path": "DTCs.Generic.Support", "name": "Compression ignition monitoring supported", "hidden": true, "fmt": {"bix": 12, "len": 1, "map": { + "0": { "description": "Spark ignition monitors supported", "value": "SPARK" }, + "1": { "description": "Compression ignition monitors supported", "value": "COMPRESSION" } + }} + } + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "42"}, "freq": 10, + "signals": [ + {"id": "VPWR", "path": "Battery.Generic", "fmt": { "len": 16, "max": 65535, "div": 1000, "unit": "volts" }, "name": "Control module voltage", "suggestedMetric": "starterBatteryVoltage", "description": "Power input to the OBD control module. VPWR is normally battery voltage, less any voltage drop in the circuit between the battery and the control module."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "43"}, "freq": 0.25, + "signals": [ + {"id": "LOAD_ABS", "path": "Engine.Generic", "fmt": { "len": 16, "max": 25700, "mul": 100, "div": 255, "unit": "percent" }, "name": "Absolute load value"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "44"}, "freq": 0.25, + "signals": [ + {"id": "EQ_RAT", "path": "Engine.Generic", "fmt": { "len": 16, "max": 1.99, "mul": 2, "div": 65535, "unit": "scalar" }, "name": "Commanded equivalence ratio", "suggestedMetric": "commandedLambda", "description": "Fuel systems that utilize conventional oxygen sensor display the inverse of the commanded open loop equivalence ratio (also known as lambda) while the fuel control system is in open loop. Will indicate 1.000 while in closed-loop fuel."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "45"}, "freq": 0.25, + "signals": [ + {"id": "TP_R", "path": "Control.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Relative throttle position", "description": "Relative or 'learned' throttle position."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "46"}, "freq": 15, + "signals": [ + {"id": "AAT", "path": "Climate.Generic", "fmt": { "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Ambient air temperature"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "47"}, "freq": 0.25, + "signals": [ + {"id": "TP_B", "path": "Control.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Absolute throttle position B", "suggestedMetric": "throttlePosition"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "48"}, "freq": 0.25, + "signals": [ + {"id": "TP_C", "path": "Control.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Absolute throttle position C", "suggestedMetric": "throttlePosition"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "49"}, "freq": 0.25, + "signals": [ + {"id": "APP_D", "path": "Control.Pedals.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Accelerator pedal position D"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "4A"}, "freq": 0.25, + "signals": [ + {"id": "APP_E", "path": "Control.Pedals.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Accelerator pedal position E"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "4B"}, "freq": 0.25, + "signals": [ + {"id": "APP_F", "path": "Control.Pedals.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Accelerator pedal position F"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "4C"}, "freq": 0.25, + "signals": [ + {"id": "TAC_PCT", "path": "Engine.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Commanded throttle actuator control"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "4D"}, "freq": 60, + "signals": [ + {"id": "MIL_TIME", "path": "DTCs.Generic", "fmt": { "len": 16, "max": 65535, "unit": "minutes" }, "name": "Time run by the engine while MIL was activated", "description": "Resets to zero when MIL changes from deactivated to activated, if diagnostic information is cleared, or if at least 40 warm-up cycles occur without MIL being activated."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "4E"}, "freq": 60, + "signals": [ + {"id": "CLR_TIME", "path": "DTCs.Generic", "fmt": { "len": 16, "max": 65535, "unit": "minutes" }, "name": "Engine run time since diagnostic trouble codes cleared", "description": "Time accumulated since DTCs were cleared (via an external test equipment or possibly, a battery disconnect). This parameter is not associated with any particular DTC. It is simply an indication for inspection/maintenance, of the last time an external test equipment was used to clear DTCs. If greater than 65,535 km have occurred, will remain at 65,535 minutes and not wrap to zero."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "4F"}, "freq": 3600, + "signals": [ + {"id": "MAX_EQ_RAT", "path": "Engine.Generic.Internal", "fmt": { "len": 8, "max": 255, "unit": "scalar" }, "name": "Maximum value for equivalence ratio", "hidden": true}, + {"id": "MAX_O2_VOLT", "path": "Engine.Generic.Internal", "fmt": {"bix": 8, "len": 8, "max": 255, "unit": "volts" }, "name": "Maximum value for oxygen sensor voltage", "hidden": true}, + {"id": "MAX_O2_CURR", "path": "Engine.Generic.Internal", "fmt": {"bix": 16, "len": 8, "max": 255, "unit": "milliamps" }, "name": "Maximum value for oxygen sensor current", "hidden": true}, + {"id": "MAX_MAP", "path": "Engine.Generic.Internal", "fmt": {"bix": 24, "len": 8, "max": 2550, "mul": 10, "unit": "kilopascal" }, "name": "Maximum value for intake manifold absolute pressure (MAP)", "hidden": true} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "50"}, "freq": 3600, + "signals": [ + {"id": "MAX_MAF", "path": "Engine.Generic.Internal", "fmt": { "len": 8, "max": 2550, "mul": 10, "unit": "gramsPerSecond" }, "name": "Maximum value for air flow rate from mass air flow sensor", "hidden": true} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "51"}, "freq": 0.25, + "signals": [ + {"id": "FUEL_TYP", "path": "Engine.Generic", "name": "Type of fuel currently being used by the vehicle", "fmt": {"len": 8, "map": { + "1": { "description": "Gasoline/petrol", "value": "GAS" }, + "2": { "description": "Methanol", "value": "METH" }, + "3": { "description": "Ethanol", "value": "ETH" }, + "4": { "description": "Diesel", "value": "DSL" }, + "5": { "description": "Liquefied petroleum gas", "value": "LPG" }, + "6": { "description": "Compressed natural gas", "value": "CNG" }, + "7": { "description": "Propane", "value": "PROP" }, + "8": { "description": "Battery/electric", "value": "ELEC" }, + "9": { "description": "Bi-fuel vehicle using gasoline/petrol", "value": "BI_GAS" }, + "10": { "description": "Bi-fuel vehicle using methanol", "value": "BI_METH" }, + "11": { "description": "Bi-fuel vehicle using ethanol", "value": "BI_ETH" }, + "12": { "description": "Bi-fuel vehicle using LPG", "value": "BI_LPG" }, + "13": { "description": "Bi-fuel vehicle using CNG", "value": "BI_CNG" }, + "14": { "description": "Bi-fuel vehicle using propane", "value": "BI_PROP" }, + "15": { "description": "Bi-fuel vehicle using battery", "value": "BI_ELEC" }, + "16": { "description": "Bi-fuel vehicle using battery and combustion engine for propulsion", "value": "BI_MIX" }, + "17": { "description": "Hybrid vehicle using gasoline engine for propulsion", "value": "HYB_GAS" }, + "18": { "description": "Hybrid vehicle using gasoline engine on ethanol for propulsion", "value": "HYB_ETH" }, + "19": { "description": "Hybrid vehicle using diesel engine for propulsion", "value": "HYB_DSL" }, + "20": { "description": "Hybrid vehicle using battery for propulsion", "value": "HYB_ELEC" }, + "21": { "description": "Hybrid vehicle using battery and combustion engine for propulsion", "value": "HYB_MIX" }, + "22": { "description": "Hybrid vehicle in regeneration mode", "value": "HYB_REG" }, + "23": { "description": "Bi-fuel vehicle using diesel", "value": "BI_DSL" }, + "24": { "description": "Bi-fuel vehicle using natural gas", "value": "BI_NG" }, + "25": { "description": "Bi-fuel vehicle using diesel", "value": "BI_DSL" }, + "26": { "description": "Natural gas", "value": "NG" }, + "27": { "description": "Dual fuel vehicle using diesel and CNG", "value": "DSL_CNG" }, + "28": { "description": "Dual fuel vehicle using diesel and LNG", "value": "DSL_LNG" }, + "29": { "description": "Fuel cell utilizing hydrogen", "value": "FC_H2" }, + "30": { "description": "Hydrogen Internal Combustion Engine", "value": "HICE_HHO" }, + "31": { "description": "Kerosene", "value": "KERO" }, + "32": { "description": "Heavy Fuel Oil", "value": "HFO" } + }} + } + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "52"}, "freq": 1, + "signals": [ + {"id": "ALCH_PCT", "path": "Fuel.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Alcohol fuel percentage", "description": "Indicates the percentage of alcohol contained in ethanol or methanol fuels, if used. For example, ethanol fuel (E85) normally contains 85% ethanol, in which case this parameter will display 85%"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "53"}, "freq": 1, + "signals": [ + {"id": "EVAP_VPA", "path": "Engine.Generic", "fmt": { "len": 16, "max": 327.675, "div": 200, "unit": "kilopascal" }, "name": "Absolute evap system vapor pressure", "description": "Normally obtained from a sensor located in the fuel tank or a sensor in an evaporative system vapor line."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "54"}, "freq": 0.25, + "signals": [ + {"id": "EVAP_VP_WIDE", "path": "Engine.Generic", "fmt": { "len": 16, "max": 32768, "min": -32767, "div": 1000, "sign": true, "unit": "kilopascal" }, "name": "Evap system vapor pressure (wide)", "description": "The pressure signal is normally obtained from a sensor located in the fuel tank or a sensor in an evaporative system vapor line."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "55"}, "freq": 0.25, + "signals": [ + {"id": "STSO2FT1", "path": "Engine.Generic", "fmt": { "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Short term secondary O2 sensor fuel trim (bank 1)"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "56"}, "freq": 0.25, + "signals": [ + {"id": "LGSO2FT1", "path": "Engine.Generic", "fmt": { "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Long term secondary O2 sensor fuel trim (bank 1)"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "57"}, "freq": 0.25, + "signals": [ + {"id": "STSO2FT2", "path": "Engine.Generic", "fmt": { "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Short term secondary O2 sensor fuel trim (bank 2)"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "58"}, "freq": 0.25, + "signals": [ + {"id": "LGSO2FT2", "path": "Engine.Generic", "fmt": { "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "Long term secondary O2 sensor fuel trim (bank 2)"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "59"}, "freq": 0.25, + "signals": [ + {"id": "FRP_ABS", "path": "Engine.Generic", "fmt": { "len": 16, "max": 655350, "mul": 10, "unit": "kilopascal" }, "name": "Fuel rail pressure (absolute)", "description": "Fuel rail pressure at the engine when the reading is referenced to atmosphere (gage pressure)."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "5A"}, "freq": 0.25, + "signals": [ + {"id": "APP_R", "path": "Control.Pedals.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Accelerator pedal position (relative)"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "5B"}, "freq": 1, + "signals": [ + {"id": "BAT_SOC", "path": "Battery.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Hybrid battery pack remaining charge", "suggestedMetric": "stateOfCharge", "description": "The percent remaining level of charge for a battery pack used for propulsion, expressed as a percentage of total useable battery energy, commonly referred to as State Of Charge (SOC)."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "5C"}, "freq": 1, + "signals": [ + {"id": "EOT", "path": "Engine.Generic", "fmt": { "len": 8, "max": 210, "min": -40, "add": -40, "unit": "celsius" }, "name": "Engine oil temperature", "suggestedMetric": "engineOilTemperature"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "5D"}, "freq": 0.25, + "signals": [ + {"id": "FUEL_TIMING", "path": "Engine.Generic", "fmt": { "len": 16, "max": 301.992, "min": -210, "div": 128, "add": -38665, "unit": "degrees" }, "name": "Fuel injection timing", "description": "Start of main fuel injection relative to Top Dead Center (TDC). Positive degrees indicate before TDC, negative degrees indicate after TDC."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "5E"}, "freq": 0.25, + "signals": [ + {"id": "FUEL_RATE", "path": "Fuel.Generic", "fmt": { "len": 16, "max": 3212.75, "div": 20, "unit": "litersPerHour" }, "name": "Engine fuel rate", "suggestedMetric": "fuelRate", "description": "Measured in units per hour"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "5F"}, "freq": 60, + "signals": [ + {"id": "EMIS_SUP", "path": "Emissions.Generic", "name": "Emission requirements to which vehicle is designed", "fmt": {"len": 8, "map": { + "14": { "description": "Heavy duty vehicles (EURO IV) B1", "value": "EURO IV B1" }, + "15": { "description": "Heavy duty vehicles (EURO V) B2", "value": "EURO V B2" }, + "16": { "description": "Heavy duty vehicles (EURO EEV) C", "value": "EURO C" }, + "17": { "description": "Heavy Duty Vehicles (Euro VI)", "value": "EURO VI" } + }} + } + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "61"}, "freq": 0.25, + "signals": [ + {"id": "TQ_DD", "path": "Engine.Generic", "fmt": { "len": 8, "max": 130, "min": -125, "add": -125, "unit": "percent" }, "name": "Driver's demand engine torque"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "62"}, "freq": 0.25, + "signals": [ + {"id": "TQ_ACT", "path": "Engine.Generic", "fmt": { "len": 8, "max": 130, "min": -125, "add": -125, "unit": "percent" }, "name": "Actual engine torque"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "63"}, "freq": 86400, + "signals": [ + {"id": "TQ_REF", "path": "Engine.Generic", "fmt": { "len": 16, "max": 65535, "unit": "newtonMeters" }, "name": "Engine reference torque"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "64"}, "freq": 3600, + "signals": [ + {"id": "TQ_MAX1", "path": "Engine.Generic.Internal", "fmt": { "len": 8, "max": 130, "min": -125, "add": -125, "unit": "percent" }, "name": "Engine Percent Torque At Idle, Point 1", "hidden": true}, + {"id": "TQ_MAX2", "path": "Engine.Generic.Internal", "fmt": {"bix": 8, "len": 8, "max": 130, "min": -125, "add": -125, "unit": "percent" }, "name": "Engine Percent Torque At Point 2", "hidden": true}, + {"id": "TQ_MAX3", "path": "Engine.Generic.Internal", "fmt": {"bix": 16, "len": 8, "max": 130, "min": -125, "add": -125, "unit": "percent" }, "name": "Engine Percent Torque At Point 3", "hidden": true}, + {"id": "TQ_MAX4", "path": "Engine.Generic.Internal", "fmt": {"bix": 24, "len": 8, "max": 130, "min": -125, "add": -125, "unit": "percent" }, "name": "Engine Percent Torque At Point 4", "hidden": true}, + {"id": "TQ_MAX5", "path": "Engine.Generic.Internal", "fmt": {"bix": 32, "len": 8, "max": 130, "min": -125, "add": -125, "unit": "percent" }, "name": "Engine Percent Torque At Point 5", "hidden": true} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "65"}, "freq": 0.25, + "signals": [ + {"id": "GEAR_SUP", "path": "Engine.Generic.AuxInputs.Internal", "fmt": {"bix": 3, "len": 1, "max": 1, "unit": "noyes" }, "name": "Recommended gear supported", "hidden": true}, + {"id": "GPL_SUP", "path": "Engine.Generic.AuxInputs.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "Glow plug lamp status supported", "hidden": true}, + {"id": "N/G_SUP", "path": "Engine.Generic.AuxInputs.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "Manual trans neutral gear status supported", "hidden": true}, + {"id": "N/D_SUP", "path": "Engine.Generic.AuxInputs.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "Auto trans neutral drive status supported", "hidden": true}, + {"id": "PTO_SUP", "path": "Engine.Generic.AuxInputs.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "Power Take Off (PTO) output status supported", "hidden": true}, + {"id": "GEAR_RCMD", "path": "Engine.Generic.AuxInputs", "fmt": {"bix": 8, "len": 4, "max": 15, "unit": "noyes" }, "name": "Recommended gear"}, + {"id": "GPL_STAT", "path": "Engine.Generic.AuxInputs", "fmt": {"bix": 12, "len": 1, "max": 1, "unit": "noyes" }, "name": "Glow plug lamp status"}, + {"id": "N/G_STAT", "path": "Engine.Generic.AuxInputs", "fmt": {"bix": 13, "len": 1, "max": 1, "unit": "noyes" }, "name": "Manual Trans Neutral Gear Status"}, + {"id": "N/D_STAT", "path": "Engine.Generic.AuxInputs", "fmt": {"bix": 14, "len": 1, "max": 1, "unit": "noyes" }, "name": "Auto Trans Neutral Drive Status"}, + {"id": "PTO_STAT_AUX", "path": "Engine.Generic.AuxInputs", "fmt": {"bix": 15, "len": 1, "max": 1, "unit": "noyes" }, "name": "Power Take Off (PTO) Output Status"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "66"}, "freq": 0.25, + "signals": [ + {"id": "MAFB_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "MAF Sensor B supported", "hidden": true}, + {"id": "MAFA_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "MAF Sensor A supported", "hidden": true}, + {"id": "MAFA", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 16, "max": 2047.96875, "div": 32, "unit": "gramsPerSecond" }, "name": "Mass Air Flow Sensor A", "suggestedMetric": "massAirFlow"}, + {"id": "MAFB", "path": "Engine.Generic", "fmt": {"bix": 24, "len": 16, "max": 2047.96875, "div": 32, "unit": "gramsPerSecond" }, "name": "Mass Air Flow Sensor B", "suggestedMetric": "massAirFlow"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "67"}, "freq": 1, + "signals": [ + {"id": "ECT_2_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "scalar" }, "name": "Is ECT sensor 2 supported?", "hidden": true}, + {"id": "ECT_1_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "scalar" }, "name": "Is ECT sensor 1 supported?", "hidden": true}, + {"id": "ECT_1", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Engine coolant temperature 1"}, + {"id": "ECT_2", "path": "Engine.Generic", "fmt": {"bix": 16, "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Engine coolant temperature 2"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "68"}, "freq": 0.25, + "signals": [ + {"id": "IAT_23_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 2, "len": 1, "max": 1, "unit": "noyes" }, "name": "IAT Bank 2, Sensor 3 supported", "hidden": true}, + {"id": "IAT_22_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 3, "len": 1, "max": 1, "unit": "noyes" }, "name": "IAT Bank 2, Sensor 2 supported", "hidden": true}, + {"id": "IAT_21_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "IAT Bank 1, Sensor 1 supported", "hidden": true}, + {"id": "IAT_13_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "IAT Bank 1, Sensor 3 supported", "hidden": true}, + {"id": "IAT_12_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "IAT Bank 1, Sensor 2 supported", "hidden": true}, + {"id": "IAT_11_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "IAT Bank 1, Sensor 1 supported", "hidden": true}, + {"id": "IAT_11", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 8, "max": 215, "min": -40, "add": -40, "nullmin": -40, "unit": "celsius" }, "name": "Intake air temperature, bank 1, sensor 1"}, + {"id": "IAT_12", "path": "Engine.Generic", "fmt": {"bix": 16, "len": 8, "max": 215, "min": -40, "add": -40, "nullmin": -40, "unit": "celsius" }, "name": "Intake air temperature, bank 1, sensor 2"}, + {"id": "IAT_13", "path": "Engine.Generic", "fmt": {"bix": 24, "len": 8, "max": 215, "min": -40, "add": -40, "nullmin": -40, "unit": "celsius" }, "name": "Intake air temperature, bank 1, sensor 3"}, + {"id": "IAT_21", "path": "Engine.Generic", "fmt": {"bix": 32, "len": 8, "max": 215, "min": -40, "add": -40, "nullmin": -40, "unit": "celsius" }, "name": "Intake air temperature, bank 2, sensor 1"}, + {"id": "IAT_22", "path": "Engine.Generic", "fmt": {"bix": 40, "len": 8, "max": 215, "min": -40, "add": -40, "nullmin": -40, "unit": "celsius" }, "name": "Intake air temperature, bank 2, sensor 2"}, + {"id": "IAT_23", "path": "Engine.Generic", "fmt": {"bix": 48, "len": 8, "max": 215, "min": -40, "add": -40, "nullmin": -40, "unit": "celsius" }, "name": "Intake air temperature, bank 2, sensor 3"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "69"}, "freq": 0.25, + "signals": [ + {"id": "EGR_B_ERR_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 2, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR A error supported", "hidden": true}, + {"id": "EGR_B_ACT_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 3, "len": 1, "max": 1, "unit": "noyes" }, "name": "Actual EGR A supported", "hidden": true}, + {"id": "EGR_B_CMD_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded EGR A supported", "hidden": true}, + {"id": "EGR_A_ERR_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR A error supported", "hidden": true}, + {"id": "EGR_A_ACT_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "Actual EGR A supported", "hidden": true}, + {"id": "EGR_A_CMD_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded EGR A supported", "hidden": true}, + {"id": "EGR_A_CMD", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Commanded EGR A duty cycle/position"}, + {"id": "EGR_A_ACT", "path": "Engine.Generic", "fmt": {"bix": 16, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Actual EGR A duty cycle/position"}, + {"id": "EGR_A_ERR", "path": "Engine.Generic", "fmt": {"bix": 24, "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "EGR A error"}, + {"id": "EGR_B_CMD", "path": "Engine.Generic", "fmt": {"bix": 32, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Commanded EGR B duty cycle/position"}, + {"id": "EGR_B_ACT", "path": "Engine.Generic", "fmt": {"bix": 40, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Actual EGR B duty cycle/position"}, + {"id": "EGR_B_ERR", "path": "Engine.Generic", "fmt": {"bix": 48, "len": 8, "max": 99.22, "min": -100, "mul": 100, "div": 128, "add": -100, "unit": "percent" }, "name": "EGR B error"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "6B"}, "freq": 0.25, + "signals": [ + {"id": "EGRTD_WR_SUP", "path": "Engine.Generic.Internal", "fmt": { "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR Temperature Sensor D (Bank 2, Sensor 2) Wide Range supported?", "hidden": true}, + {"id": "EGRTB_WR_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 1, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR Temperature Sensor B (Bank 2, Sensor 1) Wide Range supported?", "hidden": true}, + {"id": "EGRTC_WR_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 2, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR Temperature Sensor C (Bank 1, Sensor 2) Wide Range supported?", "hidden": true}, + {"id": "EGRTA_WR_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 3, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR Temperature Sensor A (Bank 1, Sensor 1) Wide Range supported?", "hidden": true}, + {"id": "EGRTD_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR Temperature Sensor D (Bank 2, Sensor 2) supported?", "hidden": true}, + {"id": "EGRTB_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR Temperature Sensor B (Bank 2, Sensor 1) supported?", "hidden": true}, + {"id": "EGRTC_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR Temperature Sensor C (Bank 1, Sensor 2) supported?", "hidden": true}, + {"id": "EGRTA_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "EGR Temperature Sensor A (Bank 1, Sensor 1) supported?", "hidden": true}, + {"id": "EGRTA", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Exhaust Gas Recirculation Temp Sensor A (Bank 1, Sensor 1)"}, + {"id": "EGRTC", "path": "Engine.Generic", "fmt": {"bix": 16, "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Exhaust Gas Recirculation Temp Sensor C (Bank 1, Sensor 2)"}, + {"id": "EGRTB", "path": "Engine.Generic", "fmt": {"bix": 24, "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Exhaust Gas Recirculation Temp Sensor B (Bank 2, Sensor 1)"}, + {"id": "EGRTD", "path": "Engine.Generic", "fmt": {"bix": 32, "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Exhaust Gas Recirculation Temp Sensor D (Bank 2, Sensor 2)"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "6C"}, "freq": 0.25, + "signals": [ + {"id": "RTP_B_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "Relative Throttle B Position data supported?", "hidden": true}, + {"id": "CTAC_B_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded Throttle Actuator B Control supported?", "hidden": true}, + {"id": "RTP_A_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "Relative Throttle A Position data supported?", "hidden": true}, + {"id": "CTAC_A_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded Throttle Actuator A Control supported?", "hidden": true}, + {"id": "TAC_A_CMD", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Commanded Throttle Actuator A Control"}, + {"id": "TP_A_REL", "path": "Engine.Generic", "fmt": {"bix": 16, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Relative Throttle A Position"}, + {"id": "TAC_B_CMD", "path": "Engine.Generic", "fmt": {"bix": 24, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Commanded Throttle Actuator B Control"}, + {"id": "TP_B_REL", "path": "Engine.Generic", "fmt": {"bix": 32, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Relative Throttle B Position"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "6D"}, "freq": 0.25, + "signals": [ + {"id": "FRT_B_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 2, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel Temperature B data supported?", "hidden": true}, + {"id": "FRP_B_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 3, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel Rail Pressure B data supported?", "hidden": true}, + {"id": "FRP_B_CMD_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded Fuel Rail Pressure B data supported?", "hidden": true}, + {"id": "FRT_A_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel Temperature A data supported?", "hidden": true}, + {"id": "FRP_A_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel Rail Pressure A data supported?", "hidden": true}, + {"id": "FRP_A_CMD_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded Fuel Rail Pressure A data supported?", "hidden": true}, + {"id": "FRP_A_CMD", "path": "Fuel.Generic", "fmt": {"bix": 8, "len": 16, "max": 655350, "mul": 10, "unit": "kilopascal" }, "name": "Commanded Fuel Rail Pressure A", "description": "Displays commanded fuel rail pressure when the reading is referenced to atmosphere (gage pressure)"}, + {"id": "FRP_A", "path": "Fuel.Generic", "fmt": {"bix": 24, "len": 16, "max": 655350, "mul": 10, "unit": "kilopascal" }, "name": "Fuel Rail Pressure A", "description": "Displays fuel rail pressure when the reading is referenced to atmosphere (gage pressure)."}, + {"id": "FRT_A", "path": "Fuel.Generic", "fmt": {"bix": 40, "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Fuel Rail Temperature A"}, + {"id": "FRP_B_CMD", "path": "Fuel.Generic", "fmt": {"bix": 48, "len": 16, "max": 655350, "mul": 10, "unit": "kilopascal" }, "name": "Commanded Fuel Rail Pressure B", "description": "Displays commanded fuel rail pressure when the reading is referenced to atmosphere (gage pressure)"}, + {"id": "FRP_B", "path": "Fuel.Generic", "fmt": {"bix": 64, "len": 16, "max": 655350, "mul": 10, "unit": "kilopascal" }, "name": "Fuel Rail Pressure B", "description": "Displays fuel rail pressure when the reading is referenced to atmosphere (gage pressure)."}, + {"id": "FRT_B", "path": "Fuel.Generic", "fmt": {"bix": 80, "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Fuel Rail Temperature B"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "70"}, "freq": 0.25, + "signals": [ + {"id": "BP_B_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 2, "len": 1, "max": 1, "unit": "noyes" }, "name": "Boost pressure B control status supported", "hidden": true}, + {"id": "BP_B_ACT_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 3, "len": 1, "max": 1, "unit": "noyes" }, "name": "Boost pressure B supported", "hidden": true}, + {"id": "BP_B_CMD_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded boost pressure B supported", "hidden": true}, + {"id": "BP_A_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "Boost pressure A control status supported", "hidden": true}, + {"id": "BP_A_ACT_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "Boost pressure A supported", "hidden": true}, + {"id": "BP_A_CMD_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded boost pressure A supported", "hidden": true}, + {"id": "BP_A_CMD", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 16, "max": 2047.96875, "div": 32, "unit": "kilopascal" }, "name": "Commanded boost pressure A", "description": "Turbocharger/supercharger A commanded boost pressure."}, + {"id": "BP_A_ACT", "path": "Engine.Generic", "fmt": {"bix": 24, "len": 16, "max": 2047.96875, "div": 32, "unit": "kilopascal" }, "name": "Boost pressure sensor A", "description": "Actual turbocharger/supercharger A boost pressure."}, + {"id": "BP_B_CMD", "path": "Engine.Generic", "fmt": {"bix": 40, "len": 16, "max": 2047.96875, "div": 32, "unit": "kilopascal" }, "name": "Commanded boost pressure B", "description": "Turbocharger/supercharger B commanded boost pressure."}, + {"id": "BP_B_ACT", "path": "Engine.Generic", "fmt": {"bix": 56, "len": 16, "max": 2047.96875, "div": 32, "unit": "kilopascal" }, "name": "Boost pressure sensor B", "description": "Actual turbocharger/supercharger B boost pressure."}, + {"id": "BP_B", "path": "Engine.Generic.Internal", "name": "Boost bressure A control status", "fmt": {"bix": 76, "len": 2, "map": { + "1": { "description": "Open loop", "value": "BP_B_OL" }, + "2": { "description": "Closed loop", "value": "BP_B_CL" }, + "3": { "description": "Fault", "value": "BP_B_FAULT" } + }} + }, + {"id": "BP_A", "path": "Engine.Generic.Internal", "name": "Boost bressure A control status", "fmt": {"bix": 78, "len": 2, "map": { + "1": { "description": "Open loop", "value": "BP_A_OL" }, + "2": { "description": "Closed loop", "value": "BP_A_CL" }, + "3": { "description": "Fault", "value": "BP_A_FAULT" } + }} + } + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "72"}, "freq": 0.25, + "signals": [ + {"id": "WG_B_ACT_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "Wastegate B position supported", "hidden": true}, + {"id": "WG_B_CMD_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded wastegate B position supported", "hidden": true}, + {"id": "WG_A_ACT_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "Wastegate A position supported", "hidden": true}, + {"id": "WG_A_CMD_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "Commanded wastegate A position supported", "hidden": true}, + {"id": "WG_A_CMD", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Commanded wastegate A position", "description": "If an on/off solenoid is used, is 0% when the wastegate is commanded off (allow full boost), 100% when commanded on (dump boost). If a vacuum solenoid is duty cycled, the duty cycle from 0 to 100% is displayed. If a linear or stepper motor valve is used, the fully closed position (full boost) is displayed as 0%, the fully open position (dump boost) is displayed as 100%. Intermediate positions are displayed as a percent of the full-open position. Any other actuation method is normalized to display 0% when the WG is commanded off and 100% when the WG is commanded on."}, + {"id": "WG_A_ACT", "path": "Engine.Generic", "fmt": {"bix": 16, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Wastegate A position", "description": "If an on/off solenoid is used, is 0% when the wastegate is commanded off (allow full boost), 100% when commanded on (dump boost). If a vacuum solenoid is duty cycled, the duty cycle from 0 to 100% is displayed. If a linear or stepper motor valve is used, the fully closed position (full boost) is displayed as 0%, the fully open position (dump boost) is displayed as 100%. Intermediate positions are displayed as a percent of the full-open position. Any other actuation method is normalized to display 0% when the WG is commanded off and 100% when the WG is commanded on."}, + {"id": "WG_B_CMD", "path": "Engine.Generic", "fmt": {"bix": 24, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Commanded wastegate B position", "description": "If an on/off solenoid is used, is 0% when the wastegate is commanded off (allow full boost), 100% when commanded on (dump boost). If a vacuum solenoid is duty cycled, the duty cycle from 0 to 100% is displayed. If a linear or stepper motor valve is used, the fully closed position (full boost) is displayed as 0%, the fully open position (dump boost) is displayed as 100%. Intermediate positions are displayed as a percent of the full-open position. Any other actuation method is normalized to display 0% when the WG is commanded off and 100% when the WG is commanded on."}, + {"id": "WG_B_ACT", "path": "Engine.Generic", "fmt": {"bix": 32, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Wastegate B position", "description": "If an on/off solenoid is used, is 0% when the wastegate is commanded off (allow full boost), 100% when commanded on (dump boost). If a vacuum solenoid is duty cycled, the duty cycle from 0 to 100% is displayed. If a linear or stepper motor valve is used, the fully closed position (full boost) is displayed as 0%, the fully open position (dump boost) is displayed as 100%. Intermediate positions are displayed as a percent of the full-open position. Any other actuation method is normalized to display 0% when the WG is commanded off and 100% when the WG is commanded on."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "7F"}, "freq": 1, + "signals": [ + {"id": "PTO_TIME_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "Total Run Time With PTO Active supported?", "hidden": true}, + {"id": "IDLE_TIME_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "Total Idle Run Time supported?", "hidden": true}, + {"id": "RUN_TIME_SUP", "path": "Engine.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "Total Engine Run Time supported?", "hidden": true}, + {"id": "RUN_TIME", "path": "Engine.Generic", "fmt": {"bix": 8, "len": 32, "max": 4294967295, "unit": "seconds" }, "name": "Total Engine Run Time"}, + {"id": "IDLE_TIME", "path": "Engine.Generic", "fmt": {"bix": 40, "len": 32, "max": 4294967295, "unit": "seconds" }, "name": "Total Idle Run Time"}, + {"id": "PTO_TIME", "path": "Engine.Generic", "fmt": {"bix": 72, "len": 32, "max": 4294967295, "unit": "seconds" }, "name": "Total Run Time With PTO Active"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "84"}, "freq": 1, + "signals": [ + {"id": "MST", "path": "Engine.Generic", "fmt": { "len": 8, "max": 215, "min": -40, "add": -40, "unit": "celsius" }, "name": "Manifold surface temperature"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "8D"}, "freq": 0.25, + "signals": [ + {"id": "TP_G", "path": "Engine.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Absolute Throttle Position G", "suggestedMetric": "throttlePosition"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "8E"}, "freq": 0.25, + "signals": [ + {"id": "TQ_FR", "path": "Engine.Generic", "fmt": { "len": 8, "max": 130, "min": -125, "add": -125, "unit": "percent" }, "name": "Engine Friction - Percent Torque"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "9A"}, "freq": 0.25, + "signals": [ + {"id": "EHEV_MODE_SUP", "path": "Battery.Generic.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "Enhanced Hybrid/EV Vehicle Charging State supported?", "hidden": true}, + {"id": "HEV_BATT_A_SUP", "path": "Battery.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "Hybrid/EV Battery Current supported?", "hidden": true}, + {"id": "HEV_BATT_V_SUP", "path": "Battery.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "Hybrid/EV Battery Voltage supported?", "hidden": true}, + {"id": "HEV_MODE_SUP", "path": "Battery.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "Hybrid/EV Vehicle Charging State supported?", "hidden": true}, + {"id": "HEV_BATT_V", "path": "Battery.Generic", "fmt": {"bix": 16, "len": 16, "max": 1023.98, "div": 64, "unit": "volts" }, "name": "Hybrid/EV Battery System Voltage"}, + {"id": "HEV_BATT_A", "path": "Battery.Generic", "fmt": {"bix": 32, "len": 16, "max": 3276.7, "min": -3276.8, "div": 10, "sign": true, "unit": "amps" }, "name": "Hybrid/EV Battery System Current"}, + {"id": "HEV_MODE", "path": "Battery.Generic", "name": "Hybrid/EV Vehicle Charging State", "fmt": {"len": 1, "map": { + "0": { "description": "Charge sustaining mode", "value": "CSM" }, + "1": { "description": "Charge depleting mode", "value": "CDM" } + }} + }, + {"id": "EHEV_MODE", "path": "Battery.Generic", "name": "Enhanced Hybrid/EV Vehicle Charging State", "fmt": {"bix": 1, "len": 2, "map": { + "0": { "description": "Charge sustaining mode", "value": "CSM" }, + "1": { "description": "Charge depleting mode", "value": "CDM" }, + "2": { "description": "Charge increasing mode", "value": "CIM" } + }} + } + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "9D"}, "freq": 0.25, + "signals": [ + {"id": "FUEL_RATE_ALT", "path": "Fuel.Generic", "fmt": { "len": 16, "max": 1310.7, "div": 50, "unit": "gramsPerSecond" }, "name": "Engine fuel rate (alternate)"}, + {"id": "VFUEL_RATE", "path": "Fuel.Generic", "fmt": {"bix": 16, "len": 16, "max": 1310.7, "div": 50, "unit": "gramsPerSecond" }, "name": "Vehicle fuel rate"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "9E"}, "freq": 0.25, + "signals": [ + {"id": "EXH_RATE", "path": "Fuel.Generic", "fmt": { "len": 16, "max": 13107, "div": 5, "unit": "kilogramsPerHour" }, "name": "Engine exhaust flow rate"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "9F"}, "freq": 0.25, + "signals": [ + {"id": "FUELSYSB_B4_SUP", "path": "Fuel.Generic.Internal", "fmt": { "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system B use percentage bank 4 supported", "hidden": true}, + {"id": "FUELSYSA_B4_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 1, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system A use percentage bank 4 supported", "hidden": true}, + {"id": "FUELSYSB_B3_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 2, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system B use percentage bank 3 supported", "hidden": true}, + {"id": "FUELSYSA_B3_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 3, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system A use percentage bank 3 supported", "hidden": true}, + {"id": "FUELSYSB_B2_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 4, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system B use percentage bank 2 supported", "hidden": true}, + {"id": "FUELSYSA_B2_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 5, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system A use percentage bank 2 supported", "hidden": true}, + {"id": "FUELSYSB_B1_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system B use percentage bank 1 supported", "hidden": true}, + {"id": "FUELSYSA_B1_SUP", "path": "Fuel.Generic.Internal", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "noyes" }, "name": "Fuel system A use percentage bank 1 supported", "hidden": true}, + {"id": "FUELSYSA_B1", "path": "Fuel.Generic", "fmt": {"bix": 8, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Fuel system A use percentage bank 1"}, + {"id": "FUELSYSB_B1", "path": "Fuel.Generic", "fmt": {"bix": 16, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Fuel system B use percentage bank 1"}, + {"id": "FUELSYSA_B2", "path": "Fuel.Generic", "fmt": {"bix": 24, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Fuel system A use percentage bank 2"}, + {"id": "FUELSYSB_B2", "path": "Fuel.Generic", "fmt": {"bix": 32, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Fuel system B use percentage bank 2"}, + {"id": "FUELSYSA_B3", "path": "Fuel.Generic", "fmt": {"bix": 40, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Fuel system A use percentage bank 3"}, + {"id": "FUELSYSB_B3", "path": "Fuel.Generic", "fmt": {"bix": 48, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Fuel system B use percentage bank 3"}, + {"id": "FUELSYSA_B4", "path": "Fuel.Generic", "fmt": {"bix": 56, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Fuel system A use percentage bank 4"}, + {"id": "FUELSYSB_B4", "path": "Fuel.Generic", "fmt": {"bix": 64, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Fuel system B use percentage bank 4"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "A2"}, "freq": 0.25, + "signals": [ + {"id": "CYL_RATE", "path": "Engine.Generic", "fmt": { "len": 16, "max": 2047.96875, "div": 32, "unit": "milligramsPerStroke" }, "name": "Cylinder fuel rate"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "A6"}, "freq": 1, + "signals": [ + {"id": "ODO", "path": "Trips.Generic", "fmt": { "len": 32, "max": 429496729.5, "div": 10, "unit": "kilometers" }, "name": "Odometer", "suggestedMetric": "odometer"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "AA"}, "freq": 0.25, + "signals": [ + {"id": "V_SET", "path": "Movement.Generic", "fmt": { "len": 8, "max": 255, "unit": "kilometersPerHour" }, "name": "Maximum current vehicle speed limit"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "B2"}, "freq": 60, + "signals": [ + {"id": "BAT_SOH", "path": "Battery.Generic", "fmt": { "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "Traction battery pack State of Health"} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "D2"}, "freq": 300, + "signals": [ + {"id": "SOCR_SUP", "path": "Battery.Generic", "fmt": {"bix": 6, "len": 1, "max": 1, "unit": "offon" }, "name": "State of certified range supported"}, + {"id": "SOCE_SUP", "path": "Battery.Generic", "fmt": {"bix": 7, "len": 1, "max": 1, "unit": "offon" }, "name": "State of certified energy supported"}, + {"id": "SOCE", "path": "Battery.Generic", "fmt": {"bix": 8, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "State of certified energy", "suggestedMetric": "stateOfHealth", "description": "Estimation of the battery's certified usable energy."}, + {"id": "SOCR", "path": "Battery.Generic", "fmt": {"bix": 16, "len": 8, "max": 100, "mul": 100, "div": 255, "unit": "percent" }, "name": "State of certified range", "suggestedMetric": "stateOfHealth", "description": "Estimation of the battery's certified usable range. This is a best-effort representation of the degradation of components that contribute to the range of the vehicle."} + ]}, +{ "hdr": "7E0", "rax": "7E8", "cmd": {"01": "D3"}, "freq": 1, + "signals": [ + {"id": "ODO_ENG", "path": "Trips.Generic", "fmt": { "len": 32, "max": 429496729.5, "div": 10, "unit": "kilometers" }, "name": "Engine odometer"} + ]} +] +}