This commit is contained in:
firestarsdog
2026-08-30 04:28:08 -04:00
parent 6f49c1cfc9
commit 2334df399b
46 changed files with 9916 additions and 87 deletions
+2
View File
@@ -183,6 +183,8 @@ testpaths = [
"tools/replay",
"tools/cabana",
"cereal/messaging/tests",
"starpilot/system/obdyssey/tests",
"starpilot/system/bluetooth/tests",
]
[tool.codespell]
@@ -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)
+254
View File
@@ -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
+307 -59
View File
@@ -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)
+1
View File
@@ -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":
+16 -6
View File
@@ -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()
@@ -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()
+66
View File
@@ -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",
]
@@ -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"
}
}
]
}
File diff suppressed because it is too large Load Diff
+493
View File
@@ -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 <DID_MSB> <DID_LSB> <DataBytes...>
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])
File diff suppressed because it is too large Load Diff
+914
View File
@@ -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,
)
+207
View File
@@ -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 <repo>-<sha>/, 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
+715
View File
@@ -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
+291
View File
@@ -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)
@@ -0,0 +1 @@
# OBDyssey test suite
@@ -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
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>"
@@ -0,0 +1,6 @@
ATCP1D
OK
ATSHDB33F1
OK
ATCRA1EC6AE80
OK
@@ -0,0 +1,6 @@
ATCP1D
OK
ATSHDB33F1
OK
ATCRA1DC6AE80
OK
@@ -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.
@@ -0,0 +1,3 @@
ATD
OK
>
@@ -0,0 +1,3 @@
22A001
CAN ERROR
>
@@ -0,0 +1,6 @@
ATCFC1
OK
ATFCSM0
OK
62 F1 90 31 47 31 46
>
@@ -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
>
@@ -0,0 +1,12 @@
ATZ
ELM327 v1.5
>
ATE0
OK
>
ATI
ELM327 v1.5
>
ATRV
13.8V
>
@@ -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
>
@@ -0,0 +1,3 @@
22F190
10 0C 62 F1 90 31 47
>
@@ -0,0 +1,12 @@
ATZ
ELM327 v2.3
>
ATE0
OK
>
ATI
STN2230 v4.3.1
>
ATRV
14.1V
>
@@ -0,0 +1,3 @@
22A001
NO DATA
>
@@ -0,0 +1,7 @@
22F190
7F 22 78
>
7F 22 78
>
62 F1 90 31 47 31 46
>
@@ -0,0 +1,3 @@
010C
STOPPED
>
@@ -0,0 +1,3 @@
19 02 FF
59 02 FF 12 34 56 24
>
+239
View File
@@ -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)
+1
View File
@@ -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),
+23 -1
View File
@@ -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)
+492
View File
@@ -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()
+15
View File
@@ -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.
+761
View File
@@ -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"}
]}
]
}