diff --git a/starpilot/system/bluetooth/bluez.py b/starpilot/system/bluetooth/bluez.py
index f58c95a0d..9fd297847 100644
--- a/starpilot/system/bluetooth/bluez.py
+++ b/starpilot/system/bluetooth/bluez.py
@@ -214,7 +214,7 @@ class BlueZClient:
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", "")))
+ audio, controller, serial = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", "")))
device = {
"path": path,
"address": str(props.get("Address", "")),
@@ -227,9 +227,10 @@ class BlueZClient:
"uuids": uuids,
"audio": audio,
"controller": controller,
+ "serial": serial,
}
if include_hidden or show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"],
- device["blocked"], audio, controller, include_discovering):
+ device["blocked"], audio, controller, serial, include_discovering):
devices.append(device)
return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower()))
diff --git a/starpilot/system/bluetooth/daemon.py b/starpilot/system/bluetooth/daemon.py
index 60499bfb3..3a111fbda 100644
--- a/starpilot/system/bluetooth/daemon.py
+++ b/starpilot/system/bluetooth/daemon.py
@@ -9,11 +9,13 @@ from typing import Any
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.starpilot.system.bluetooth.bluez import BlueZClient
+from openpilot.starpilot.system.bluetooth.elm327 import ELM327Session
from openpilot.starpilot.system.bluetooth.protocol import BLUETOOTH_SOCKET_PATH
from openpilot.starpilot.system.bluetooth.radio import BluetoothRadio
-OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response"}
+OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response",
+ "elm_open", "elm_command", "elm_read_dtcs"}
SCAN_DURATION = 20.0
AUDIO_TEST_START_DELAY = 3.0
AUDIO_TEST_HOLD_TIME = 3.0
@@ -21,13 +23,15 @@ AUDIO_TEST_HOLD_TIME = 3.0
class BluetoothController:
def __init__(self, params: Params | None = None, bluez_factory=BlueZClient, radio: BluetoothRadio | None = None,
- params_memory: Params | None = None, sleep=time.sleep):
+ params_memory: Params | None = None, sleep=time.sleep, elm_factory=ELM327Session):
self.params = params or Params()
self.params_memory = params_memory or Params(memory=True)
self._bluez_factory = bluez_factory
+ self._elm_factory = elm_factory
self._radio = radio or BluetoothRadio()
self._lock = threading.RLock()
self._bluez: BlueZClient | None = None
+ self._elm: ELM327Session | None = None
self._pairing_address = ""
self._pairing_error = ""
self._last_reconnect = 0.0
@@ -41,6 +45,7 @@ class BluetoothController:
self.params.remove("BluetoothAudioTestActive")
self.params_memory.remove("TestAlert")
with self._lock:
+ self._close_elm()
if self._bluez is not None:
self._bluez.close()
self._bluez = None
@@ -75,6 +80,7 @@ class BluetoothController:
def _reset_client(self) -> None:
with self._lock:
+ self._close_elm()
if self._bluez is not None:
try:
self._bluez.close()
@@ -82,6 +88,32 @@ class BluetoothController:
pass
self._bluez = None
+ def _close_elm(self) -> None:
+ with self._lock:
+ session = self._elm
+ self._elm = None
+ if session is None:
+ return
+ try:
+ session.close()
+ except Exception:
+ cloudlog.warning("ELM327 session close failed")
+
+ def _invalidate_elm(self, session: ELM327Session) -> None:
+ with self._lock:
+ if self._elm is not session:
+ return
+ self._close_elm()
+
+ def _active_elm(self, address: str) -> ELM327Session:
+ with self._lock:
+ session = self._elm
+ if session is None:
+ raise RuntimeError("ELM327 session is not open")
+ if str(session.address).upper() != address.upper():
+ raise RuntimeError("ELM327 session is open for another device")
+ return session
+
def _offroad(self) -> bool:
return self.params.get_bool("IsOffroad")
@@ -178,6 +210,7 @@ class BluetoothController:
pass
raise
else:
+ self._close_elm()
try:
client = self._bluez
if client is not None:
@@ -209,6 +242,9 @@ class BluetoothController:
elif command == "disconnect":
self._client().disconnect(address)
elif command == "forget":
+ with self._lock:
+ if self._elm is not None and str(self._elm.address).upper() == address.upper():
+ self._close_elm()
self._client().remove(address)
if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper():
self.params.remove("BluetoothAudioAddress")
@@ -234,6 +270,50 @@ class BluetoothController:
self._audio_test_deadline = deadline
threading.Thread(target=self._test_audio_worker, args=(address, deadline), daemon=True).start()
return {"audio_test_delay_ms": max(0, round((deadline - time.monotonic()) * 1000))}
+ elif command == "elm_open":
+ if not address:
+ raise RuntimeError("Bluetooth device address is required")
+ if not self.params.get_bool("BluetoothEnabled"):
+ raise RuntimeError("Bluetooth is disabled")
+ with self._lock:
+ if self._elm is not None and str(self._elm.address).upper() == address.upper():
+ return {"adapter": self._elm.adapter_name}
+ device = self._client().device_for_address(address)
+ if not device.get("paired"):
+ raise RuntimeError("Pair the Bluetooth device before opening ELM327")
+ if not device.get("serial"):
+ raise RuntimeError("Bluetooth device does not advertise Serial Port Profile")
+ self._close_elm()
+ session = self._elm_factory(address)
+ try:
+ adapter = str(session.open())
+ except Exception:
+ try:
+ session.close()
+ except Exception:
+ pass
+ raise
+ session.adapter_name = adapter
+ self._elm = session
+ return {"adapter": adapter}
+ elif command == "elm_close":
+ with self._lock:
+ if self._elm is not None and str(self._elm.address).upper() == address.upper():
+ self._close_elm()
+ elif command == "elm_command":
+ session = self._active_elm(address)
+ try:
+ return {"response": session.command(str(request.get("value", "")))}
+ except Exception:
+ self._invalidate_elm(session)
+ raise
+ elif command == "elm_read_dtcs":
+ session = self._active_elm(address)
+ try:
+ return session.read_dtcs()
+ except Exception:
+ self._invalidate_elm(session)
+ raise
elif command == "pairing_response":
if not self._client().agent.respond(str(request.get("prompt_id", "")), bool(request.get("accepted", False)), str(request.get("value", ""))):
raise RuntimeError("Pairing request is no longer active")
@@ -252,10 +332,14 @@ class BluetoothController:
while True:
time.sleep(2)
if not self.params.get_bool("BluetoothEnabled"):
+ self._close_elm()
continue
try:
status = self.status()
+ if self._elm is not None and not status["offroad"]:
+ self._close_elm()
if not status["available"] or not status["powered"]:
+ self._close_elm()
continue
now = time.monotonic()
self._maintain_scan(status, now)
diff --git a/starpilot/system/bluetooth/elm327.py b/starpilot/system/bluetooth/elm327.py
new file mode 100644
index 000000000..0ab005200
--- /dev/null
+++ b/starpilot/system/bluetooth/elm327.py
@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+import re
+import socket
+import threading
+
+
+DEFAULT_CHANNEL = 1
+OPEN_TIMEOUT = 10.0
+DEFAULT_COMMAND_TIMEOUT = 10.0
+DTC_COMMAND_TIMEOUT = 25.0
+MAX_COMMAND_LENGTH = 256
+MAX_RESPONSE_SIZE = 64 * 1024
+RECV_SIZE = 4096
+
+
+class DTCParseError(ValueError):
+ def __init__(self, message: str, raw: str):
+ super().__init__(message)
+ self.raw = raw
+
+
+def decode_dtc(first: int, second: int) -> str:
+ prefixes = "PCBU"
+ prefix = prefixes[(first >> 6) & 0x03]
+ return f"{prefix}{(first >> 4) & 0x03:X}{first & 0x0F:X}{second >> 4:X}{second & 0x0F:X}"
+
+
+_HEX_BYTE = re.compile(r"(?i)(? list[str]:
+ codes = []
+ seen = set()
+ for line in raw.splitlines():
+ values = [int(match, 16) for match in _HEX_BYTE.findall(line)]
+ try:
+ response_index = values.index(0x43)
+ except ValueError:
+ continue
+
+ payload = values[response_index + 1:]
+ if len(payload) % 2:
+ count = payload[0]
+ expected_length = count * 2
+ if len(payload) - 1 < expected_length:
+ raise DTCParseError(f"Mode 03 response claims {expected_length} DTC bytes, received {len(payload) - 1}", raw)
+ payload = payload[1:1 + expected_length]
+
+ for index in range(0, len(payload) - 1, 2):
+ first, second = payload[index:index + 2]
+ if first == 0 and second == 0:
+ continue
+ code = decode_dtc(first, second)
+ if code not in seen:
+ seen.add(code)
+ codes.append(code)
+ return codes
+
+
+class ELM327Session:
+ def __init__(self, address: str, channel: int = DEFAULT_CHANNEL):
+ self.address = address
+ self.channel = channel
+ self.socket: socket.socket | None = None
+ self.lock = threading.RLock()
+ self.adapter_name = ""
+
+ def _close_unlocked(self) -> None:
+ client_socket = self.socket
+ self.socket = None
+ self.adapter_name = ""
+ if client_socket is not None:
+ try:
+ client_socket.close()
+ except Exception:
+ pass
+
+ def close(self) -> None:
+ with self.lock:
+ self._close_unlocked()
+
+ def _receive_until_prompt_unlocked(self, timeout: float) -> bytes:
+ if self.socket is None:
+ raise RuntimeError("ELM327 session is not open")
+ self.socket.settimeout(timeout)
+ response = bytearray()
+ while True:
+ chunk = self.socket.recv(RECV_SIZE)
+ if not chunk:
+ raise RuntimeError("ELM327 connection closed")
+ response.extend(chunk)
+ if len(response) > MAX_RESPONSE_SIZE:
+ raise RuntimeError("ELM327 response exceeded 64 KiB")
+ if b">" in response:
+ return bytes(response)
+
+ @staticmethod
+ def _clean_response(raw: bytes, command: str) -> str:
+ response = raw.split(b">", 1)[0].decode("ascii", errors="replace")
+ response = response.replace("\r\n", "\n").replace("\r", "\n")
+ lines = response.split("\n")
+ while lines and not lines[0].strip():
+ lines.pop(0)
+ if lines and lines[0].strip() == command:
+ lines.pop(0)
+ return "\n".join(lines).strip()
+
+ def _exchange_unlocked(self, command: str, timeout: float) -> str:
+ if self.socket is None:
+ raise RuntimeError("ELM327 session is not open")
+ try:
+ self.socket.settimeout(timeout)
+ self.socket.sendall(command.encode("ascii") + b"\r")
+ return self._clean_response(self._receive_until_prompt_unlocked(timeout), command)
+ except Exception as error:
+ self._close_unlocked()
+ raise RuntimeError(f"ELM327 transport failed: {error}") from error
+
+ def open(self) -> str:
+ with self.lock:
+ if self.socket is not None:
+ return self.adapter_name
+ try:
+ self.socket = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
+ self.socket.settimeout(OPEN_TIMEOUT)
+ self.socket.connect((self.address, self.channel))
+ adapter_name = self._exchange_unlocked("ATI", OPEN_TIMEOUT)
+ if not adapter_name or adapter_name.strip().upper() in {"?", "ERROR", "COMMAND UNKNOWN", "UNKNOWN COMMAND", "NO DATA"}:
+ raise RuntimeError("ELM327 adapter rejected ATI")
+ self.adapter_name = adapter_name
+ for setup_command in ("ATE0", "ATL0", "ATH0"):
+ self._exchange_unlocked(setup_command, OPEN_TIMEOUT)
+ return self.adapter_name
+ except Exception as error:
+ self._close_unlocked()
+ if isinstance(error, RuntimeError) and str(error).startswith("ELM327 open failed:"):
+ raise
+ raise RuntimeError(f"ELM327 open failed: {error}") from error
+
+ def command(self, command: str, timeout: float = DEFAULT_COMMAND_TIMEOUT) -> str:
+ if not isinstance(command, str):
+ raise ValueError("ELM327 command must be text")
+ if "\r" in command or "\n" in command:
+ raise ValueError("ELM327 command cannot contain carriage returns or newlines")
+ command = command.strip()
+ if not command:
+ raise ValueError("ELM327 command cannot be empty")
+ if len(command) > MAX_COMMAND_LENGTH:
+ raise ValueError("ELM327 command is too long")
+ try:
+ command.encode("ascii")
+ except UnicodeEncodeError as error:
+ raise ValueError("ELM327 command must contain ASCII characters") from error
+ if timeout <= 0:
+ raise ValueError("ELM327 command timeout must be positive")
+
+ with self.lock:
+ return self._exchange_unlocked(command, timeout)
+
+ def read_dtcs(self) -> dict[str, str | list[str]]:
+ with self.lock:
+ for setup_command in ("ATE0", "ATL0", "ATH0", "ATSP0"):
+ self.command(setup_command)
+ raw = self.command("03", timeout=DTC_COMMAND_TIMEOUT)
+ return {"codes": parse_dtcs(raw), "raw": raw}
diff --git a/starpilot/system/bluetooth/protocol.py b/starpilot/system/bluetooth/protocol.py
index 8c648ccd4..1efcba22f 100644
--- a/starpilot/system/bluetooth/protocol.py
+++ b/starpilot/system/bluetooth/protocol.py
@@ -16,6 +16,7 @@ BLUETOOTH_RADIO_HELPER = "/usr/comma/bluetooth-radio"
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
HID_UUID = "00001124-0000-1000-8000-00805f9b34fb"
HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb"
+SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb"
COMMAND_TIMEOUTS = {
"set_power": 90.0,
"start_scan": 20.0,
@@ -24,6 +25,10 @@ COMMAND_TIMEOUTS = {
"disconnect": 20.0,
"forget": 20.0,
"test_audio": 10.0,
+ "elm_open": 15.0,
+ "elm_close": 5.0,
+ "elm_command": 20.0,
+ "elm_read_dtcs": 30.0,
}
TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -40,6 +45,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 +60,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,21 +93,22 @@ class BluetoothStatus:
)
-def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool]:
+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, discovering: bool = False) -> bool:
+ audio: bool, controller: bool, serial: bool = False, discovering: bool = False) -> bool:
known = paired or trusted or connected
normalized_address = "".join(character for character in address.upper() if character.isalnum())
normalized_name = "".join(character for character in name.upper() if character.isalnum())
named = bool(name) and name != "Unknown device" and normalized_name != normalized_address
- return known or (named and not blocked and (audio or controller))
+ return known or (named and not blocked and (audio or controller or serial))
class _DesktopFakeBluetooth:
@@ -304,5 +312,17 @@ class BluetoothClient:
result = self.call("test_audio", address=address)
return max(0.0, float(result.get("audio_test_delay_ms", 0)) / 1000.0)
+ def elm_open(self, address: str) -> dict[str, Any]:
+ return self.call("elm_open", address=address)
+
+ def elm_close(self, address: str) -> dict[str, Any]:
+ return self.call("elm_close", address=address)
+
+ def elm_command(self, address: str, value: str) -> dict[str, Any]:
+ return self.call("elm_command", address=address, value=value)
+
+ def elm_read_dtcs(self, address: str) -> dict[str, Any]:
+ return self.call("elm_read_dtcs", address=address)
+
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
self.call("pairing_response", prompt_id=prompt_id, accepted=accepted, value=value)
diff --git a/starpilot/system/bluetooth/tests/test_bluetooth.py b/starpilot/system/bluetooth/tests/test_bluetooth.py
index 6880e51ac..7b450995d 100644
--- a/starpilot/system/bluetooth/tests/test_bluetooth.py
+++ b/starpilot/system/bluetooth/tests/test_bluetooth.py
@@ -6,10 +6,11 @@ import numpy as np
import pytest
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink
+import openpilot.starpilot.system.bluetooth.daemon as bluetooth_daemon
from openpilot.starpilot.system.bluetooth.bluez import PairingAgent
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)
+ SPP_UUID, device_capabilities, show_pairing_device)
from openpilot.system import hardware
from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager
@@ -64,6 +65,7 @@ class FakeBlueZ:
"connected": False,
"audio": True,
"controller": False,
+ "serial": False,
}
def close(self):
@@ -163,9 +165,38 @@ class FakeProcess:
self.stopped = True
+class FakeELM:
+ instances = []
+
+ def __init__(self, address):
+ self.address = address
+ self.adapter_name = "Fake ELM327"
+ self.closed = False
+ self.commands = []
+ self.opened = False
+ FakeELM.instances.append(self)
+
+ def open(self):
+ self.opened = True
+ return self.adapter_name
+
+ def close(self):
+ self.closed = True
+
+ def command(self, value):
+ self.commands.append(value)
+ if value == "fail":
+ raise RuntimeError("transport failed")
+ return f"response for {value}"
+
+ def read_dtcs(self):
+ return {"codes": ["P0133"], "raw": "43 01 33"}
+
+
def test_protocol_round_trip_and_capabilities():
- audio, controller = device_capabilities([A2DP_SINK_UUID, HID_UUID])
+ audio, controller, serial = device_capabilities([A2DP_SINK_UUID, HID_UUID])
assert audio and controller
+ assert not serial
status = BluetoothStatus.from_dict({
"available": True,
"enabled": True,
@@ -174,12 +205,155 @@ def test_protocol_round_trip_and_capabilities():
assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True),)
+def test_serial_capability_round_trips_and_is_discoverable():
+ audio, controller, serial = device_capabilities([SPP_UUID.upper()])
+ assert not audio and not controller and serial
+
+ status = BluetoothStatus.from_dict({
+ "devices": [{"address": "00:11:22:33:44:55", "name": "OBDII", "serial": True}],
+ })
+ assert status.devices[0].serial
+ assert show_pairing_device("00:11:22:33:44:55", "OBDII", False, False, False, False,
+ audio=False, controller=False, serial=True)
+
+
+def test_serial_device_is_not_auto_reconnected_but_audio_device_is(monkeypatch):
+ class StopMaintenance(Exception):
+ pass
+
+ params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
+ client = FakeBlueZ()
+ client.powered = True
+ client.device.update(paired=True, trusted=True, connected=False, audio=False, controller=False, serial=True)
+ sleeps = 0
+
+ def sleep(_delay):
+ nonlocal sleeps
+ sleeps += 1
+ if sleeps > 1:
+ raise StopMaintenance
+
+ monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
+ controller = BluetoothController(params, lambda: client, FakeRadio(), sleep=sleep, elm_factory=FakeELM)
+ controller._bluez = client
+ controller._last_reconnect = -100.0
+ with pytest.raises(StopMaintenance):
+ controller.maintain_connections()
+ assert client.actions == []
+
+ client.device.update(audio=True, serial=False)
+ sleeps = 0
+ controller._last_reconnect = -100.0
+ with pytest.raises(StopMaintenance):
+ controller.maintain_connections()
+ assert client.actions == [("connect", client.device["address"])]
+
+
+def make_elm_controller(elm_factory=FakeELM):
+ params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
+ client = FakeBlueZ()
+ client.device.update(paired=True, trusted=True, serial=True)
+ controller = BluetoothController(params, lambda: client, FakeRadio(), elm_factory=elm_factory)
+ return controller, client, params
+
+
+def test_elm_is_lazy_and_requires_a_paired_serial_device():
+ FakeELM.instances = []
+ controller, client, params = make_elm_controller()
+ assert FakeELM.instances == []
+ controller.status()
+ assert FakeELM.instances == []
+
+ result = controller.handle({"command": "elm_open", "address": client.device["address"]})
+ assert result == {"adapter": "Fake ELM327"}
+ assert len(FakeELM.instances) == 1
+
+ params.values["IsOffroad"] = False
+ with pytest.raises(RuntimeError, match="offroad"):
+ controller.handle({"command": "elm_command", "address": client.device["address"], "value": "ATI"})
+
+ params.values["IsOffroad"] = True
+ client.device["paired"] = False
+ controller.handle({"command": "elm_close", "address": client.device["address"]})
+ with pytest.raises(RuntimeError, match="Pair"):
+ controller.handle({"command": "elm_open", "address": client.device["address"]})
+
+ client.device.update(paired=True, serial=False)
+ with pytest.raises(RuntimeError, match="Serial Port Profile"):
+ controller.handle({"command": "elm_open", "address": client.device["address"]})
+
+
+def test_elm_commands_use_one_session_and_close_on_transport_failure():
+ FakeELM.instances = []
+ controller, client, _ = make_elm_controller()
+ address = client.device["address"]
+ controller.handle({"command": "elm_open", "address": address})
+ assert controller.handle({"command": "elm_open", "address": address}) == {"adapter": "Fake ELM327"}
+ assert controller.handle({"command": "elm_command", "address": address, "value": "ATI"}) == {"response": "response for ATI"}
+ assert controller.handle({"command": "elm_read_dtcs", "address": address}) == {"codes": ["P0133"], "raw": "43 01 33"}
+ with pytest.raises(RuntimeError, match="transport"):
+ controller.handle({"command": "elm_command", "address": address, "value": "fail"})
+ assert controller._elm is None
+ assert FakeELM.instances[0].closed
+
+
+def test_elm_open_replaces_a_different_session_and_close_is_allowed_onroad():
+ FakeELM.instances = []
+ controller, client, params = make_elm_controller()
+ first = client.device["address"]
+ second = "AA:BB:CC:DD:EE:FF"
+ controller.handle({"command": "elm_open", "address": first})
+ controller.handle({"command": "elm_open", "address": second})
+ assert len(FakeELM.instances) == 2
+ assert FakeELM.instances[0].closed
+ assert not FakeELM.instances[1].closed
+
+ params.values["IsOffroad"] = False
+ controller.handle({"command": "elm_close", "address": second})
+ assert FakeELM.instances[1].closed and controller._elm is None
+
+
+def test_elm_cleanup_happens_before_poweroff_forget_shutdown_and_onroad(monkeypatch):
+ class StopMaintenance(Exception):
+ pass
+
+ for cleanup in ("power", "forget", "shutdown", "onroad"):
+ FakeELM.instances = []
+ controller, client, params = make_elm_controller()
+ address = client.device["address"]
+ controller.handle({"command": "elm_open", "address": address})
+ session = FakeELM.instances[0]
+
+ if cleanup == "power":
+ controller.handle({"command": "set_power", "enabled": False})
+ elif cleanup == "forget":
+ controller.handle({"command": "forget", "address": address})
+ elif cleanup == "shutdown":
+ controller.close()
+ else:
+ params.values["IsOffroad"] = False
+ sleeps = 0
+
+ def sleep(_delay):
+ nonlocal sleeps
+ sleeps += 1
+ if sleeps > 1:
+ raise StopMaintenance
+
+ monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
+ controller._sleep = sleep
+ with pytest.raises(StopMaintenance):
+ controller.maintain_connections()
+
+ assert session.closed and controller._elm is None
+
+
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", "Media Remote", False, False, False, False, False, True, True)
- assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, True)
+ assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, discovering=True)
+ assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, discovering=True)
assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False)
diff --git a/starpilot/system/bluetooth/tests/test_elm327.py b/starpilot/system/bluetooth/tests/test_elm327.py
new file mode 100644
index 000000000..a62322bdb
--- /dev/null
+++ b/starpilot/system/bluetooth/tests/test_elm327.py
@@ -0,0 +1,227 @@
+from collections import deque
+import threading
+
+import pytest
+
+from openpilot.starpilot.system.bluetooth import elm327
+
+
+ADDRESS = "00:11:22:33:44:55"
+
+
+class FakeSocket:
+ def __init__(self, responses):
+ self.responses = deque(deque(response) for response in responses)
+ self.pending = deque()
+ self.sent = []
+ self.connected_to = None
+ self.timeouts = []
+ self.closed = False
+ self.close_calls = 0
+ self.recv_error = None
+ self.send_error = None
+
+ def settimeout(self, timeout):
+ self.timeouts.append(timeout)
+
+ def connect(self, address):
+ self.connected_to = address
+
+ def sendall(self, value):
+ if self.send_error is not None:
+ raise self.send_error
+ self.sent.append(value)
+ self.pending = self.responses.popleft() if self.responses else deque()
+
+ def recv(self, _size):
+ if self.recv_error is not None:
+ raise self.recv_error
+ return self.pending.popleft() if self.pending else b""
+
+ def close(self):
+ self.close_calls += 1
+ self.closed = True
+
+
+def startup_responses(identity=b"ELM327 v1.5"):
+ return [
+ [b"ATI\r\n", identity + b"\r\n>"],
+ [b"ATE0\r\nOK\r\n>"],
+ [b"ATL0\r\nOK\r\n>"],
+ [b"ATH0\r\nOK\r\n>"],
+ ]
+
+
+def make_session(monkeypatch, responses):
+ fake = FakeSocket(responses)
+ monkeypatch.setattr(elm327.socket, "AF_BLUETOOTH", 31, raising=False)
+ monkeypatch.setattr(elm327.socket, "BTPROTO_RFCOMM", 3, raising=False)
+ monkeypatch.setattr(elm327.socket, "socket", lambda *args: fake)
+ return elm327.ELM327Session(ADDRESS), fake
+
+
+def test_open_uses_rfccomm_channel_one_and_validates_ati(monkeypatch):
+ session, fake = make_session(monkeypatch, startup_responses())
+
+ assert session.open() == "ELM327 v1.5"
+ assert fake.connected_to == (ADDRESS, 1)
+ assert fake.sent == [b"ATI\r", b"ATE0\r", b"ATL0\r", b"ATH0\r"]
+ assert session.adapter_name == "ELM327 v1.5"
+
+
+def test_open_rejects_empty_or_obviously_rejected_ati(monkeypatch):
+ for identity in (b"", b"?", b"ERROR"):
+ session, fake = make_session(monkeypatch, startup_responses(identity))
+ with pytest.raises(RuntimeError, match="ATI"):
+ session.open()
+ assert session.socket is None and fake.closed
+
+
+def test_command_removes_exact_echo_and_reads_split_prompt_response(monkeypatch):
+ responses = startup_responses() + [[b"ATR", b"V\r\n12.4V\r", b"\n>"]]
+ session, fake = make_session(monkeypatch, responses)
+ session.open()
+
+ assert session.command(" ATRV ") == "12.4V"
+ assert fake.sent[-1] == b"ATRV\r"
+
+
+def test_malformed_response_bytes_decode_with_replacement(monkeypatch):
+ responses = startup_responses() + [[b"ATI\r\n\xffOK\r\n>"]]
+ session, _ = make_session(monkeypatch, responses)
+ session.open()
+
+ assert session.command("ATI") == "�OK"
+
+
+@pytest.mark.parametrize("command", ["", " ", "ATI\r", "ATI\n", "AT\r\nI", "A" * (elm327.MAX_COMMAND_LENGTH + 1)])
+def test_command_rejects_invalid_input(monkeypatch, command):
+ session, _ = make_session(monkeypatch, [])
+ with pytest.raises(ValueError):
+ session.command(command)
+
+
+def test_command_rejects_non_ascii_input(monkeypatch):
+ session, _ = make_session(monkeypatch, [])
+ with pytest.raises(ValueError, match="ASCII"):
+ session.command("ATé")
+
+
+def test_response_size_limit_closes_session(monkeypatch):
+ responses = startup_responses() + [[b"x" * (elm327.MAX_RESPONSE_SIZE + 1)]]
+ session, fake = make_session(monkeypatch, responses)
+ session.open()
+
+ with pytest.raises(RuntimeError, match="64 KiB"):
+ session.command("ATI")
+ assert session.socket is None and fake.closed
+
+
+@pytest.mark.parametrize("error", [TimeoutError("timed out"), OSError("disconnected")])
+def test_timeout_or_eof_closes_session(monkeypatch, error):
+ responses = startup_responses() + [[]]
+ session, fake = make_session(monkeypatch, responses)
+ session.open()
+ fake.recv_error = error if isinstance(error, TimeoutError) else None
+
+ if isinstance(error, TimeoutError):
+ with pytest.raises(RuntimeError, match="transport"):
+ session.command("ATI")
+ else:
+ with pytest.raises(RuntimeError, match="connection closed"):
+ session.command("ATI")
+ assert session.socket is None and fake.closed
+
+
+def test_send_failure_closes_session(monkeypatch):
+ responses = startup_responses() + [[b"OK>"]]
+ session, fake = make_session(monkeypatch, responses)
+ session.open()
+ fake.send_error = OSError("send failed")
+
+ with pytest.raises(RuntimeError, match="transport"):
+ session.command("ATI")
+ assert session.socket is None and fake.closed
+
+
+def test_close_is_idempotent(monkeypatch):
+ session, fake = make_session(monkeypatch, startup_responses())
+ session.open()
+ session.close()
+ session.close()
+
+ assert fake.close_calls == 1
+ assert session.socket is None
+
+
+def test_simultaneous_commands_are_serialized(monkeypatch):
+ class SerializedSocket(FakeSocket):
+ def __init__(self, responses):
+ super().__init__(responses)
+ self.command_started = threading.Event()
+ self.release_command = threading.Event()
+ self._command_sends = 0
+
+ def sendall(self, value):
+ super().sendall(value)
+ self._command_sends += 1
+ if self._command_sends == 5:
+ self.command_started.set()
+ assert self.release_command.wait(timeout=1.0)
+
+ fake = SerializedSocket(startup_responses() + [[b"VALUE1>"], [b"VALUE2>"]])
+ monkeypatch.setattr(elm327.socket, "AF_BLUETOOTH", 31, raising=False)
+ monkeypatch.setattr(elm327.socket, "BTPROTO_RFCOMM", 3, raising=False)
+ monkeypatch.setattr(elm327.socket, "socket", lambda *args: fake)
+ session = elm327.ELM327Session(ADDRESS)
+ session.open()
+
+ results = []
+ first = threading.Thread(target=lambda: results.append(session.command("ONE")))
+ second = threading.Thread(target=lambda: results.append(session.command("TWO")))
+ first.start()
+ assert fake.command_started.wait(timeout=1.0)
+ second.start()
+ assert len(fake.sent) == 5
+ fake.release_command.set()
+ first.join(timeout=1.0)
+ second.join(timeout=1.0)
+
+ assert sorted(results) == ["VALUE1", "VALUE2"]
+ assert len(fake.sent) == 6
+
+
+def test_read_dtcs_runs_known_setup_and_returns_mode_three_result(monkeypatch):
+ responses = startup_responses() + [
+ [b"OK>"], [b"OK>"], [b"OK>"], [b"OK>"],
+ [b"43 01 33 04 20 00 00>"],
+ ]
+ session, fake = make_session(monkeypatch, responses)
+ session.open()
+
+ assert session.read_dtcs() == {"codes": ["P0133", "P0420"], "raw": "43 01 33 04 20 00 00"}
+ assert fake.sent[-5:] == [b"ATE0\r", b"ATL0\r", b"ATH0\r", b"ATSP0\r", b"03\r"]
+
+
+def test_non_can_dtc_is_decoded_and_padding_ignored():
+ assert elm327.parse_dtcs("43 01 33 00 00 00 00") == ["P0133"]
+
+
+def test_can_dtc_count_byte_is_skipped():
+ assert elm327.parse_dtcs("43 02 01 33 04 20") == ["P0133", "P0420"]
+
+
+def test_no_data_returns_no_codes():
+ assert elm327.parse_dtcs("NO DATA") == []
+
+
+def test_multiple_ecu_lines_are_ordered_and_deduplicated():
+ raw = "43 01 33 00 00\n43 04 20 00 00\n43 01 33 00 00"
+ assert elm327.parse_dtcs(raw) == ["P0133", "P0420"]
+
+
+def test_malformed_can_count_raises_with_raw_response():
+ raw = "43 02 01 33"
+ with pytest.raises(elm327.DTCParseError) as error:
+ elm327.parse_dtcs(raw)
+ assert error.value.raw == raw
diff --git a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css
index 8b2722f15..3dafb39e2 100644
--- a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css
+++ b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.css
@@ -398,6 +398,116 @@
animation-delay: 0.28s;
}
+.bluetoothElmPanel {
+ background: var(--secondary-bg);
+ border: 1px solid rgba(169, 140, 229, 0.45);
+ border-radius: var(--border-radius-lg);
+ box-shadow: var(--shadow-sm);
+ padding: 18px;
+}
+
+.bluetoothElmHeader,
+.bluetoothElmActions,
+.bluetoothElmCommand > div {
+ align-items: center;
+ display: flex;
+ gap: 10px;
+}
+
+.bluetoothElmHeader {
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.bluetoothElmHeader h3,
+.bluetoothElmHeader p,
+.bluetoothElmCodes p,
+.bluetoothElmResponse pre,
+.bluetoothElmCommand label {
+ margin: 0;
+}
+
+.bluetoothElmHeader p,
+.bluetoothElmCodes p {
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.bluetoothElmHeader strong {
+ color: #cbb2fa;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.bluetoothElmActions {
+ margin-top: 16px;
+}
+
+.bluetoothElmActions button,
+.bluetoothElmCommand button {
+ background: linear-gradient(135deg, #765bb6, #9474ce);
+ border: 0;
+ border-radius: var(--border-radius-md);
+ color: #fff;
+ cursor: pointer;
+ font-weight: 700;
+ padding: 10px 14px;
+}
+
+.bluetoothElmActions .bluetoothSecondaryButton {
+ background: var(--input-bg);
+ border: 1px solid var(--sidebar-border-color);
+ color: var(--text-color);
+}
+
+.bluetoothElmActions button:disabled,
+.bluetoothElmCommand button:disabled,
+.bluetoothElmCommand input:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.bluetoothElmCodes,
+.bluetoothElmCommand,
+.bluetoothElmResponse {
+ margin-top: 16px;
+}
+
+.bluetoothElmCommand label {
+ color: var(--text-muted);
+ display: block;
+ font-size: 0.86rem;
+ margin-bottom: 6px;
+}
+
+.bluetoothElmCommand > div {
+ align-items: stretch;
+}
+
+.bluetoothElmCommand input {
+ background: var(--input-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-md);
+ color: var(--text-color);
+ flex: 1;
+ font: inherit;
+ min-width: 0;
+ padding: 10px 12px;
+}
+
+.bluetoothElmResponse pre {
+ background: var(--input-bg);
+ border: 1px solid var(--sidebar-border-color);
+ border-radius: var(--border-radius-md);
+ color: var(--text-color);
+ margin-top: 6px;
+ max-height: 220px;
+ overflow: auto;
+ padding: 12px;
+ white-space: pre-wrap;
+}
+
@keyframes bluetoothSpin {
to { transform: rotate(360deg); }
}
diff --git a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js
index d588df721..f7760b044 100644
--- a/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js
+++ b/starpilot/system/the_galaxy/assets/components/tools/bluetooth.js
@@ -20,6 +20,11 @@ const state = reactive({
prompt: null,
audioTestAddress: "",
audioTestLabel: "",
+ elmAddress: "",
+ elmName: "",
+ elmAdapter: "",
+ elmResponse: "",
+ elmCodes: null,
error: "",
})
@@ -46,7 +51,9 @@ function schedulePoll(delay = pollDelay()) {
pollTimer = setTimeout(async () => {
pollTimer = null
try {
- if (bluetoothPageActive() && document.visibilityState !== "hidden" && state.busy !== "power") {
+ if (state.elmAddress && (document.visibilityState === "hidden" || !bluetoothPageActive())) {
+ closeElm()
+ } else if (bluetoothPageActive() && document.visibilityState !== "hidden" && state.busy !== "power") {
await refresh()
}
} finally {
@@ -97,8 +104,10 @@ async function request(operation, body = {}) {
}
state.error = ""
await refresh()
+ return payload
} catch (error) {
state.error = error?.message || "Bluetooth operation failed"
+ return null
} finally {
state.busy = ""
if (operation === "power") state.powerTarget = null
@@ -106,6 +115,54 @@ async function request(operation, body = {}) {
}
}
+function clearElmState() {
+ state.elmAddress = ""
+ state.elmName = ""
+ state.elmAdapter = ""
+ state.elmResponse = ""
+ state.elmCodes = null
+}
+
+function closeElm() {
+ const address = state.elmAddress
+ if (!address) return
+ clearElmState()
+ request("elm_close", { address })
+}
+
+async function openElm(address) {
+ const device = state.devices.find((item) => normalizedAddress(item) === String(address || "").toUpperCase())
+ const payload = await request("elm_open", { address })
+ if (!payload || !device) return
+ state.elmAddress = address
+ state.elmName = device.name || address
+ state.elmAdapter = String(payload.adapter || "")
+ state.elmResponse = ""
+ state.elmCodes = null
+}
+
+async function readElmCodes() {
+ const address = state.elmAddress
+ if (!address) return
+ const payload = await request("elm_read_dtcs", { address })
+ if (!payload || state.elmAddress !== address) return
+ state.elmCodes = Array.isArray(payload.codes) ? payload.codes.map(String) : []
+ state.elmResponse = String(payload.raw || "")
+}
+
+async function sendElmCommand() {
+ const address = state.elmAddress
+ const input = document.getElementById("bluetoothElmCommand")
+ const command = input?.value.trim() || ""
+ if (!address || !command) {
+ state.error = "Enter an ELM327 command."
+ return
+ }
+ const payload = await request("elm_command", { address, value: command })
+ if (!payload || state.elmAddress !== address) return
+ state.elmResponse = `${command}\n${String(payload.response || "")}`.trim()
+}
+
async function refreshOnce() {
const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}`
const response = await fetch(statusUrl, { cache: "no-store" })
@@ -126,6 +183,10 @@ async function refreshOnce() {
devices,
})
state.devices = devices
+ if (state.elmAddress && (!state.enabled || !state.offroad ||
+ !devices.some((device) => normalizedAddress(device) === state.elmAddress.toUpperCase() && device.paired))) {
+ closeElm()
+ }
if (state.deviceSignature !== deviceSignature) {
state.deviceSignature = deviceSignature
state.revision++
@@ -223,7 +284,11 @@ function initialize() {
window.addEventListener("focus", refresh)
window.addEventListener("pageshow", refresh)
document.addEventListener("visibilitychange", () => {
- if (document.visibilityState !== "hidden" && bluetoothPageActive()) refresh()
+ if (document.visibilityState === "hidden" || !bluetoothPageActive()) {
+ closeElm()
+ } else {
+ refresh()
+ }
})
refresh()
schedulePoll(0)
@@ -248,6 +313,7 @@ function deviceCapabilities(device) {
const capabilities = []
if (device.audio) capabilities.push("Audio")
if (device.controller) capabilities.push("Controller")
+ if (device.serial) capabilities.push("Serial")
return capabilities.join(" · ") || "Bluetooth device"
}
@@ -353,7 +419,14 @@ function renderDeviceActions(device) {
actions.push("")
}
- if (device.paired || device.connected) {
+ if (device.paired && device.serial) {
+ actions.push("")
+ actions.push("")
+ }
+ if ((device.paired || device.connected) && !device.serial) {
const operation = device.connected ? "disconnect" : "connect"
actions.push("")
@@ -407,7 +480,48 @@ function handleDeviceListClick(event) {
const operation = button.dataset.bluetoothOperation
const address = button.dataset.address || ""
if (operation === "forget" && !window.confirm("Forget " + (button.dataset.deviceName || "this device") + "?")) return
- request(operation, { address })
+ if (operation === "elm_open") {
+ openElm(address)
+ } else {
+ request(operation, { address })
+ }
+}
+
+function renderElmPanel() {
+ if (!state.elmAddress) return ""
+ return html`
+ ${() => state.elmName || state.elmAddress} ${() => state.elmCodes.length ? state.elmCodes.join(" · ") : "No stored codes reported."}ELM327
+ ${() => state.elmResponse || "—"}
+