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` +
+
+
+

ELM327

+

${() => state.elmName || state.elmAddress}

+
+ ${() => state.elmAdapter || "Connecting…"} +
+
+ + +
+ ${() => state.elmCodes !== null ? html` +
+ Stored Codes +

${() => state.elmCodes.length ? state.elmCodes.join(" · ") : "No stored codes reported."}

+
+ ` : ""} +
+ +
+ + +
+
+
+ Response +
${() => state.elmResponse || "—"}
+
+
+ ` } export function Bluetooth() { @@ -440,6 +554,7 @@ export function Bluetooth() { The test sound is sent at NOW. The audible gap is Bluetooth latency. ` : ""} + ${() => renderElmPanel()}