Compare commits

...

1 Commits

Author SHA1 Message Date
firestarsdog 0bdae4b250 ELMo & Friends 2026-09-04 13:46:04 -04:00
12 changed files with 1260 additions and 19 deletions
+3 -2
View File
@@ -235,7 +235,7 @@ class BlueZClient:
continue continue
props = interfaces[DEVICE_IFACE] props = interfaces[DEVICE_IFACE]
uuids = [str(value).lower() for value in props.get("UUIDs", [])] 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 = { device = {
"path": path, "path": path,
"address": str(props.get("Address", "")), "address": str(props.get("Address", "")),
@@ -248,9 +248,10 @@ class BlueZClient:
"uuids": uuids, "uuids": uuids,
"audio": audio, "audio": audio,
"controller": controller, "controller": controller,
"serial": serial,
} }
if include_hidden or show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"], 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) devices.append(device)
return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower())) return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower()))
+88 -2
View File
@@ -9,11 +9,13 @@ from typing import Any
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog from openpilot.common.swaglog import cloudlog
from openpilot.starpilot.system.bluetooth.bluez import BlueZClient 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.protocol import BLUETOOTH_SOCKET_PATH
from openpilot.starpilot.system.bluetooth.radio import BluetoothRadio 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 SCAN_DURATION = 20.0
AUDIO_TEST_START_DELAY = 3.0 AUDIO_TEST_START_DELAY = 3.0
AUDIO_TEST_HOLD_TIME = 3.0 AUDIO_TEST_HOLD_TIME = 3.0
@@ -24,13 +26,15 @@ MANUAL_DISCONNECT_SUPPRESSION_SECONDS = 300.0
class BluetoothController: class BluetoothController:
def __init__(self, params: Params | None = None, bluez_factory=BlueZClient, radio: BluetoothRadio | None = None, 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 = params or Params()
self.params_memory = params_memory or Params(memory=True) self.params_memory = params_memory or Params(memory=True)
self._bluez_factory = bluez_factory self._bluez_factory = bluez_factory
self._elm_factory = elm_factory
self._radio = radio or BluetoothRadio() self._radio = radio or BluetoothRadio()
self._lock = threading.RLock() self._lock = threading.RLock()
self._bluez: BlueZClient | None = None self._bluez: BlueZClient | None = None
self._elm: ELM327Session | None = None
self._pairing_address = "" self._pairing_address = ""
self._pairing_error = "" self._pairing_error = ""
self._last_reconnect = 0.0 self._last_reconnect = 0.0
@@ -46,6 +50,7 @@ class BluetoothController:
self.params.remove("BluetoothAudioTestActive") self.params.remove("BluetoothAudioTestActive")
self.params_memory.remove("TestAlert") self.params_memory.remove("TestAlert")
with self._lock: with self._lock:
self._close_elm()
if self._bluez is not None: if self._bluez is not None:
self._bluez.close() self._bluez.close()
self._bluez = None self._bluez = None
@@ -80,6 +85,7 @@ class BluetoothController:
def _reset_client(self) -> None: def _reset_client(self) -> None:
with self._lock: with self._lock:
self._close_elm()
if self._bluez is not None: if self._bluez is not None:
try: try:
self._bluez.close() self._bluez.close()
@@ -87,6 +93,32 @@ class BluetoothController:
pass pass
self._bluez = None 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: def _offroad(self) -> bool:
return self.params.get_bool("IsOffroad") return self.params.get_bool("IsOffroad")
@@ -183,6 +215,7 @@ class BluetoothController:
pass pass
raise raise
else: else:
self._close_elm()
try: try:
client = self._bluez client = self._bluez
if client is not None: if client is not None:
@@ -231,6 +264,9 @@ class BluetoothController:
self._manual_disconnect_until.pop(normalized_address, None) self._manual_disconnect_until.pop(normalized_address, None)
raise raise
elif command == "forget": 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) self._client().remove(address)
self._reconnect_backoff.pop(address.upper(), None) self._reconnect_backoff.pop(address.upper(), None)
self._manual_disconnect_until.pop(address.upper(), None) self._manual_disconnect_until.pop(address.upper(), None)
@@ -258,6 +294,52 @@ class BluetoothController:
self._audio_test_deadline = deadline self._audio_test_deadline = deadline
threading.Thread(target=self._test_audio_worker, args=(address, deadline), daemon=True).start() 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))} 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 as error:
if getattr(session, "socket", True) is None or not isinstance(error, ValueError):
self._invalidate_elm(session)
raise
elif command == "elm_read_dtcs":
session = self._active_elm(address)
try:
return session.read_dtcs()
except Exception as error:
if getattr(session, "socket", True) is None or not isinstance(error, ValueError):
self._invalidate_elm(session)
raise
elif command == "pairing_response": elif command == "pairing_response":
if not self._client().agent.respond(str(request.get("prompt_id", "")), bool(request.get("accepted", False)), str(request.get("value", ""))): 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") raise RuntimeError("Pairing request is no longer active")
@@ -276,10 +358,14 @@ class BluetoothController:
while True: while True:
time.sleep(2) time.sleep(2)
if not self.params.get_bool("BluetoothEnabled"): if not self.params.get_bool("BluetoothEnabled"):
self._close_elm()
continue continue
try: try:
status = self.status() status = self.status()
if self._elm is not None and not status["offroad"]:
self._close_elm()
if not status["available"] or not status["powered"]: if not status["available"] or not status["powered"]:
self._close_elm()
continue continue
now = time.monotonic() now = time.monotonic()
self._maintain_scan(status, now) self._maintain_scan(status, now)
+231
View File
@@ -0,0 +1,231 @@
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)(?<![0-9a-f])([0-9a-f]{2})(?![0-9a-f])")
_FORMATTED_LENGTH = re.compile(r"(?i)^[0-9a-f]{3}$")
_FORMATTED_FRAME = re.compile(r"(?i)^([0-9a-f]+):\s*(.*)$")
_HEX_BYTE_TOKEN = re.compile(r"(?i)^[0-9a-f]{2}$")
def _reassemble_formatted_responses(raw: str) -> list[str]:
lines = []
expected_length = None
expected_index = 0
assembled = bytearray()
for raw_line in raw.splitlines():
line = raw_line.strip()
if expected_length is None:
if _FORMATTED_LENGTH.fullmatch(line):
expected_length = int(line, 16)
if expected_length == 0:
raise DTCParseError("Formatted ELM response has an invalid length", raw)
expected_index = 0
assembled.clear()
else:
lines.append(line)
continue
if _FORMATTED_LENGTH.fullmatch(line):
raise DTCParseError("Formatted ELM response started before the previous block completed", raw)
match = _FORMATTED_FRAME.fullmatch(line)
if match is None:
raise DTCParseError("Formatted ELM response has a malformed continuation", raw)
index, byte_text = match.groups()
if int(index, 16) != expected_index:
raise DTCParseError("Formatted ELM response has a missing continuation", raw)
byte_tokens = byte_text.split()
if not byte_tokens or any(_HEX_BYTE_TOKEN.fullmatch(token) is None for token in byte_tokens):
raise DTCParseError("Formatted ELM response has invalid hex bytes", raw)
assembled.extend(int(token, 16) for token in byte_tokens)
expected_index += 1
if len(assembled) >= expected_length:
lines.append(" ".join(f"{value:02X}" for value in assembled[:expected_length]))
expected_length = None
assembled.clear()
if expected_length is not None:
raise DTCParseError("Formatted ELM response is incomplete", raw)
return lines
def parse_dtcs(raw: str) -> list[str]:
response_lines = [line.strip().upper() for line in raw.splitlines() if line.strip()]
if any(line == "NO DATA" for line in response_lines) and all(line == "NO DATA" or line.startswith("SEARCHING") for line in response_lines):
return []
codes = []
seen = set()
valid_response = False
for line in _reassemble_formatted_responses(raw):
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 not payload:
raise DTCParseError("Mode 03 response has no payload", raw)
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]
valid_response = True
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)
if not valid_response:
raise DTCParseError("No valid Mode 03 response", raw)
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 _drain_unlocked(self) -> None:
if self.socket is None:
return
try:
self.socket.settimeout(0.0)
while self.socket.recv(RECV_SIZE):
pass
except (BlockingIOError, OSError):
pass
def _exchange_unlocked(self, command: str, timeout: float) -> str:
if self.socket is None:
raise RuntimeError("ELM327 session is not open")
try:
self._drain_unlocked()
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", "ATS1", "ATH0", "ATCAF1"):
self.command(setup_command)
raw = self.command("03", timeout=DTC_COMMAND_TIMEOUT)
return {"codes": parse_dtcs(raw), "raw": raw}
+24 -4
View File
@@ -16,6 +16,7 @@ BLUETOOTH_RADIO_HELPER = "/usr/comma/bluetooth-radio"
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb" A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
HID_UUID = "00001124-0000-1000-8000-00805f9b34fb" HID_UUID = "00001124-0000-1000-8000-00805f9b34fb"
HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb" HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb"
SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb"
COMMAND_TIMEOUTS = { COMMAND_TIMEOUTS = {
"set_power": 90.0, "set_power": 90.0,
"start_scan": 20.0, "start_scan": 20.0,
@@ -24,6 +25,10 @@ COMMAND_TIMEOUTS = {
"disconnect": 20.0, "disconnect": 20.0,
"forget": 20.0, "forget": 20.0,
"test_audio": 10.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"} TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -40,6 +45,7 @@ class BluetoothDevice:
uuids: tuple[str, ...] = () uuids: tuple[str, ...] = ()
audio: bool = False audio: bool = False
controller: bool = False controller: bool = False
serial: bool = False
@classmethod @classmethod
def from_dict(cls, value: dict[str, Any]) -> "BluetoothDevice": 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", ())), uuids=tuple(str(uuid).lower() for uuid in value.get("uuids", ())),
audio=bool(value.get("audio", False)), audio=bool(value.get("audio", False)),
controller=bool(value.get("controller", 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} normalized = {str(uuid).lower() for uuid in uuids}
major_class = (int(bluetooth_class) >> 8) & 0x1F 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"} 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"} 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, 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 known = paired or trusted or connected
normalized_address = "".join(character for character in address.upper() if character.isalnum()) normalized_address = "".join(character for character in address.upper() if character.isalnum())
normalized_name = "".join(character for character in name.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 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 or discovering))
class _DesktopFakeBluetooth: class _DesktopFakeBluetooth:
@@ -304,5 +312,17 @@ class BluetoothClient:
result = self.call("test_audio", address=address) result = self.call("test_audio", address=address)
return max(0.0, float(result.get("audio_test_delay_ms", 0)) / 1000.0) 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: def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
self.call("pairing_response", prompt_id=prompt_id, accepted=accepted, value=value) self.call("pairing_response", prompt_id=prompt_id, accepted=accepted, value=value)
@@ -6,10 +6,12 @@ import numpy as np
import pytest import pytest
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink 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.bluez import PairingAgent
from openpilot.starpilot.system.bluetooth.daemon import BluetoothController from openpilot.starpilot.system.bluetooth.daemon import BluetoothController
from openpilot.starpilot.system.bluetooth.elm327 import DTCParseError
from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus, 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 import hardware
from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager
@@ -64,6 +66,7 @@ class FakeBlueZ:
"connected": False, "connected": False,
"audio": True, "audio": True,
"controller": False, "controller": False,
"serial": False,
} }
def close(self): def close(self):
@@ -163,9 +166,40 @@ class FakeProcess:
self.stopped = True 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")
if value == "bad_input":
raise ValueError("invalid command")
return f"response for {value}"
def read_dtcs(self):
return {"codes": ["P0133"], "raw": "43 01 33"}
def test_protocol_round_trip_and_capabilities(): 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 audio and controller
assert not serial
status = BluetoothStatus.from_dict({ status = BluetoothStatus.from_dict({
"available": True, "available": True,
"enabled": True, "enabled": True,
@@ -174,12 +208,167 @@ 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),) 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(monkeypatch):
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(ValueError, match="invalid command"):
controller.handle({"command": "elm_command", "address": address, "value": "bad_input"})
def fail_dtcs():
raise DTCParseError("No valid Mode 03 response", "garbage")
monkeypatch.setattr(FakeELM.instances[0], "read_dtcs", fail_dtcs)
with pytest.raises(DTCParseError):
controller.handle({"command": "elm_read_dtcs", "address": address})
assert controller._elm is not None and not FakeELM.instances[0].closed
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(): 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", "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 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)
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, 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, True) assert show_pairing_device("00:11:22:33:44:55", "OBDII", False, False, False, False, False, False, False, discovering=True)
assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False, False, discovering=True)
assert not show_pairing_device("00:11:22:33:44:55", "OBDII", False, False, False, True, False, False, False, discovering=True)
assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False) assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False)
@@ -0,0 +1,274 @@
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.extend(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_drains_stale_boot_prompt_and_late_packets(monkeypatch):
session, fake = make_session(monkeypatch, startup_responses() + [[b"ATRV\r\n12.5V\r\n>"]])
fake.pending = deque([b"\r\nELM327 v1.5\r\n>"])
assert session.open() == "ELM327 v1.5"
assert fake.sent[:4] == [b"ATI\r", b"ATE0\r", b"ATL0\r", b"ATH0\r"]
fake.pending = deque([b"UNSOLICITED NOISE\r\n"])
assert session.command("ATRV") == "12.5V"
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>"] for _ in range(5)] + [[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[-6:] == [b"ATE0\r", b"ATL0\r", b"ATS1\r", b"ATH0\r", b"ATCAF1\r", b"03\r"]
@pytest.mark.parametrize("raw_command", ["ATS0", "ATCAF0"])
def test_read_dtcs_restores_parser_state_after_raw_command(monkeypatch, raw_command):
responses = startup_responses() + [[b"OK>"]] + [[b"OK>"] for _ in range(5)] + [[b"43 01 33 00 00 00 00>"]]
session, fake = make_session(monkeypatch, responses)
session.open()
session.command(raw_command)
assert session.read_dtcs() == {"codes": ["P0133"], "raw": "43 01 33 00 00 00 00"}
assert fake.sent[-6:] == [b"ATE0\r", b"ATL0\r", b"ATS1\r", b"ATH0\r", b"ATCAF1\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") == []
assert elm327.parse_dtcs("SEARCHING...\nNO DATA") == []
assert elm327.parse_dtcs("SEARCHING...\r\nNO DATA") == []
assert elm327.parse_dtcs("SEARCHING...\n43 01 33 00 00") == ["P0133"]
def test_can_dtc_multiframe_response_is_reassembled():
raw = "008\n0: 43 03 00 59 01 54\n1: 01 55"
assert elm327.parse_dtcs(raw) == ["P0059", "P0154", "P0155"]
def test_larger_numbered_multiframe_response_is_reassembled():
raw = "00E\n0: 43 06 01 33 04 20\n1: 00 59 01 54\n2: 01 55 01 56"
assert elm327.parse_dtcs(raw) == ["P0133", "P0420", "P0059", "P0154", "P0155", "P0156"]
@pytest.mark.parametrize("raw", [
"008\n0: 43 03 00 59 01 54",
"008\n1: 43 03 00 59 01 54\n1: 01 55",
"008\n0: 43 03 00 59 GG 54\n1: 01 55",
"008\n0: 43 03 00 59 01 54\n00E",
])
def test_invalid_numbered_multiframe_response_raises(raw):
with pytest.raises(elm327.DTCParseError):
elm327.parse_dtcs(raw)
@pytest.mark.parametrize("raw", ["UNABLE TO CONNECT", "STOPPED", "?", "BUS ERROR", "7F 03 11", "43"])
def test_non_mode_three_response_raises(raw):
with pytest.raises(elm327.DTCParseError):
elm327.parse_dtcs(raw)
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
@@ -398,6 +398,116 @@
animation-delay: 0.28s; 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 { @keyframes bluetoothSpin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@@ -20,6 +20,11 @@ const state = reactive({
prompt: null, prompt: null,
audioTestAddress: "", audioTestAddress: "",
audioTestLabel: "", audioTestLabel: "",
elmAddress: "",
elmName: "",
elmAdapter: "",
elmResponse: "",
elmCodes: null,
error: "", error: "",
}) })
@@ -46,7 +51,9 @@ function schedulePoll(delay = pollDelay()) {
pollTimer = setTimeout(async () => { pollTimer = setTimeout(async () => {
pollTimer = null pollTimer = null
try { 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() await refresh()
} }
} finally { } finally {
@@ -97,8 +104,10 @@ async function request(operation, body = {}) {
} }
state.error = "" state.error = ""
await refresh() await refresh()
return payload
} catch (error) { } catch (error) {
state.error = error?.message || "Bluetooth operation failed" state.error = error?.message || "Bluetooth operation failed"
return null
} finally { } finally {
state.busy = "" state.busy = ""
if (operation === "power") state.powerTarget = null 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() { async function refreshOnce() {
const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}` const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}`
const response = await fetch(statusUrl, { cache: "no-store" }) const response = await fetch(statusUrl, { cache: "no-store" })
@@ -126,6 +183,10 @@ async function refreshOnce() {
devices, devices,
}) })
state.devices = 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) { if (state.deviceSignature !== deviceSignature) {
state.deviceSignature = deviceSignature state.deviceSignature = deviceSignature
state.revision++ state.revision++
@@ -223,7 +284,11 @@ function initialize() {
window.addEventListener("focus", refresh) window.addEventListener("focus", refresh)
window.addEventListener("pageshow", refresh) window.addEventListener("pageshow", refresh)
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "hidden" && bluetoothPageActive()) refresh() if (document.visibilityState === "hidden" || !bluetoothPageActive()) {
closeElm()
} else {
refresh()
}
}) })
refresh() refresh()
schedulePoll(0) schedulePoll(0)
@@ -248,6 +313,7 @@ function deviceCapabilities(device) {
const capabilities = [] const capabilities = []
if (device.audio) capabilities.push("Audio") if (device.audio) capabilities.push("Audio")
if (device.controller) capabilities.push("Controller") if (device.controller) capabilities.push("Controller")
if (device.serial) capabilities.push("Serial")
return capabilities.join(" · ") || "Bluetooth device" return capabilities.join(" · ") || "Bluetooth device"
} }
@@ -353,7 +419,14 @@ function renderDeviceActions(device) {
actions.push("<button data-bluetooth-operation=\"pair\" data-address=\"" + address + "\"" + actions.push("<button data-bluetooth-operation=\"pair\" data-address=\"" + address + "\"" +
renderDisabledAttribute(!state.offroad || !!state.busy || pairing) + ">" + (pairing ? "Pairing…" : "Pair") + "</button>") renderDisabledAttribute(!state.offroad || !!state.busy || pairing) + ">" + (pairing ? "Pairing…" : "Pair") + "</button>")
} }
if (device.paired || device.connected) { if (device.paired && device.serial) {
actions.push("<button data-bluetooth-operation=\"elm_open\" data-address=\"" + address + "\"" +
renderDisabledAttribute(!state.offroad || !!state.busy) + ">ELM327</button>")
actions.push("<button class=\"bluetoothIconButton bluetoothForgetButton\" data-bluetooth-operation=\"forget\" data-address=\"" +
address + "\" data-device-name=\"" + name + "\" title=\"Forget device\" aria-label=\"Forget " + name + "\"" +
renderDisabledAttribute(!state.offroad || !!state.busy) + "><i class=\"bi bi-trash3\" aria-hidden=\"true\"></i></button>")
}
if ((device.paired || device.connected) && !device.serial) {
const operation = device.connected ? "disconnect" : "connect" const operation = device.connected ? "disconnect" : "connect"
actions.push("<button data-bluetooth-operation=\"" + operation + "\" data-address=\"" + address + "\"" + actions.push("<button data-bluetooth-operation=\"" + operation + "\" data-address=\"" + address + "\"" +
renderDisabledAttribute(!!state.busy) + ">" + (device.connected ? "Disconnect" : "Connect") + "</button>") renderDisabledAttribute(!!state.busy) + ">" + (device.connected ? "Disconnect" : "Connect") + "</button>")
@@ -407,7 +480,48 @@ function handleDeviceListClick(event) {
const operation = button.dataset.bluetoothOperation const operation = button.dataset.bluetoothOperation
const address = button.dataset.address || "" const address = button.dataset.address || ""
if (operation === "forget" && !window.confirm("Forget " + (button.dataset.deviceName || "this device") + "?")) return 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`
<section class="bluetoothElmPanel">
<div class="bluetoothElmHeader">
<div>
<h3>ELM327</h3>
<p>${() => state.elmName || state.elmAddress}</p>
</div>
<strong>${() => state.elmAdapter || "Connecting…"}</strong>
</div>
<div class="bluetoothElmActions">
<button disabled="${() => !state.offroad || !!state.busy}" @click="${readElmCodes}">Read Codes</button>
<button class="bluetoothSecondaryButton" disabled="${() => !!state.busy}" @click="${closeElm}">Close</button>
</div>
${() => state.elmCodes !== null ? html`
<div class="bluetoothElmCodes">
<strong>Stored Codes</strong>
<p>${() => state.elmCodes.length ? state.elmCodes.join(" · ") : "No stored codes reported."}</p>
</div>
` : ""}
<div class="bluetoothElmCommand">
<label for="bluetoothElmCommand">Command</label>
<div>
<input id="bluetoothElmCommand" type="text" autocomplete="off" placeholder="ATI"
disabled="${() => !state.offroad || !!state.busy}" />
<button disabled="${() => !state.offroad || !!state.busy}" @click="${sendElmCommand}">Send</button>
</div>
</div>
<div class="bluetoothElmResponse">
<strong>Response</strong>
<pre>${() => state.elmResponse || "—"}</pre>
</div>
</section>
`
} }
export function Bluetooth() { export function Bluetooth() {
@@ -440,6 +554,7 @@ export function Bluetooth() {
<span>The test sound is sent at NOW. The audible gap is Bluetooth latency.</span> <span>The test sound is sent at NOW. The audible gap is Bluetooth latency.</span>
</div> </div>
` : ""} ` : ""}
${() => renderElmPanel()}
<div class="bluetoothToolbar"> <div class="bluetoothToolbar">
<button disabled="${() => !state.offroad || !state.enabled || !!state.busy}" <button disabled="${() => !state.offroad || !state.enabled || !!state.busy}"
@@ -132,7 +132,15 @@ class FakeBluetoothClient:
def call(self, command, **payload): def call(self, command, **payload):
self.calls.append((command, payload)) self.calls.append((command, payload))
return {"audio_test_delay_ms": 3000} if command == "test_audio" else {} if command == "test_audio":
return {"audio_test_delay_ms": 3000}
if command == "elm_open":
return {"adapter": "Fake ELM327"}
if command == "elm_command":
return {"response": "OK"}
if command == "elm_read_dtcs":
return {"codes": ["P0133"], "raw": "43 01 33"}
return {}
def test_bluetooth_status_api(monkeypatch): def test_bluetooth_status_api(monkeypatch):
@@ -203,6 +211,26 @@ def test_bluetooth_api_dispatches_operations(monkeypatch):
] ]
def test_bluetooth_api_dispatches_elm_payload_and_allows_close_onroad(monkeypatch):
FakeBluetoothClient.calls = []
client, fake_params = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
monkeypatch.setattr(the_galaxy, "BluetoothClient", FakeBluetoothClient)
address = "00:11:22:33:44:55"
assert client.post("/api/bluetooth/elm_open", json={"address": address}).get_json()["adapter"] == "Fake ELM327"
assert client.post("/api/bluetooth/elm_command", json={"address": address, "value": "ATI"}).get_json()["response"] == "OK"
assert client.post("/api/bluetooth/elm_read_dtcs", json={"address": address}).get_json()["codes"] == ["P0133"]
fake_params.values["IsOffroad"] = False
assert client.post("/api/bluetooth/elm_close", json={"address": address}).status_code == 200
assert FakeBluetoothClient.calls == [
("elm_open", {"address": address}),
("elm_command", {"address": address, "value": "ATI"}),
("elm_read_dtcs", {"address": address}),
("elm_close", {"address": address}),
]
def test_wheel_controls_status_includes_favorite_slots(monkeypatch): def test_wheel_controls_status_includes_favorite_slots(monkeypatch):
client, _ = _params_client(monkeypatch, {"IsOffroad": True, "FavoriteSlots": []}, "mici") client, _ = _params_client(monkeypatch, {"IsOffroad": True, "FavoriteSlots": []}, "mici")
monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"available": True, "mappings": [], "devices": []}) monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"available": True, "mappings": [], "devices": []})
+8 -1
View File
@@ -5070,11 +5070,16 @@ def setup(app):
"select_audio": "select_audio", "select_audio": "select_audio",
"test_audio": "test_audio", "test_audio": "test_audio",
"pairing_response": "pairing_response", "pairing_response": "pairing_response",
"elm_open": "elm_open",
"elm_close": "elm_close",
"elm_command": "elm_command",
"elm_read_dtcs": "elm_read_dtcs",
} }
command = commands.get(operation) command = commands.get(operation)
if command is None: if command is None:
return jsonify({"error": "Unknown Bluetooth operation."}), 404 return jsonify({"error": "Unknown Bluetooth operation."}), 404
offroad_only = {"power", "scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response"} offroad_only = {"power", "scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response",
"elm_open", "elm_command", "elm_read_dtcs"}
if operation in offroad_only and not params.get_bool("IsOffroad"): if operation in offroad_only and not params.get_bool("IsOffroad"):
return jsonify({"error": "Bluetooth settings can only be changed offroad."}), 409 return jsonify({"error": "Bluetooth settings can only be changed offroad."}), 409
@@ -5092,6 +5097,8 @@ def setup(app):
payload["address"] = str(data.get("address", "")) payload["address"] = str(data.get("address", ""))
if not payload["address"] and command != "select_audio": if not payload["address"] and command != "select_audio":
return jsonify({"error": "Bluetooth device address is required."}), 400 return jsonify({"error": "Bluetooth device address is required."}), 400
if command == "elm_command":
payload["value"] = str(data.get("value", ""))
try: try:
client = BluetoothClient(timeout=10.0) client = BluetoothClient(timeout=10.0)
if command == "set_power": if command == "set_power":
+38
View File
@@ -98,6 +98,32 @@ class BluetoothManager:
self._operations.pop(normalized_address, None) self._operations.pop(normalized_address, None)
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
def _run_result(self, fn, *args, operation: str = "", address: str = "", callback=None) -> None:
normalized_address = address.upper()
if normalized_address:
with self._lock:
self._operations[normalized_address] = operation
def worker():
result = None
error = None
try:
result = fn(*args)
except Exception as exception:
error = str(exception)
finally:
if normalized_address:
with self._lock:
if self._operations.get(normalized_address) == operation:
self._operations.pop(normalized_address, None)
if callback is not None:
callback(result, error)
elif error:
with self._lock:
self._operation_error = error
threading.Thread(target=worker, daemon=True).start()
def set_power(self, enabled: bool) -> None: def set_power(self, enabled: bool) -> None:
with self._lock: with self._lock:
if self._power_pending: if self._power_pending:
@@ -148,5 +174,17 @@ class BluetoothManager:
self._audio_test_deadline = 0.0 self._audio_test_deadline = 0.0
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
def elm_open(self, address: str, callback=None) -> None:
self._run_result(self._client.elm_open, address, operation="elm_open", address=address, callback=callback)
def elm_close(self, address: str, callback=None) -> None:
self._run_result(self._client.elm_close, address, operation="elm_close", address=address, callback=callback)
def elm_command(self, address: str, value: str, callback=None) -> None:
self._run_result(self._client.elm_command, address, value, operation="elm_command", address=address, callback=callback)
def elm_read_dtcs(self, address: str, callback=None) -> None:
self._run_result(self._client.elm_read_dtcs, address, operation="elm_read_dtcs", address=address, callback=callback)
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None: def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
self._run(self._client.respond, prompt_id, accepted, value) self._run(self._client.respond, prompt_id, accepted, value)
+143 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from functools import partial from functools import partial
import threading
import pyray as rl import pyray as rl
@@ -14,7 +15,7 @@ from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle 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.confirm_dialog import ConfirmDialog, alert_dialog
from openpilot.system.ui.widgets.keyboard import Keyboard from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.label import gui_label, gui_text_box
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.widgets.toggle import Toggle from openpilot.system.ui.widgets.toggle import Toggle
@@ -45,11 +46,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")) capabilities.append(tr("audio output") if selected_audio.upper() == device.address.upper() else tr("audio"))
if device.controller: if device.controller:
capabilities.append(tr("controller")) capabilities.append(tr("controller"))
if device.serial:
capabilities.append(tr("serial"))
capability_text = " / ".join(capabilities) capability_text = " / ".join(capabilities)
if device.connected: if device.connected:
return tr("Connected") + (f" / {capability_text}" if capability_text else "") return tr("Connected") + (f" / {capability_text}" if capability_text else "")
if device.paired: if device.paired:
if device.serial:
return tr("Paired - tap to use ELM327") + (f" / {capability_text}" if capability_text else "")
return tr("Paired - tap to connect") return tr("Paired - tap to connect")
return tr("Tap to pair") + (f" / {capability_text}" if capability_text else "") return tr("Tap to pair") + (f" / {capability_text}" if capability_text else "")
@@ -58,6 +63,8 @@ def device_action_allowed(device: BluetoothDevice, operation: str, offroad: bool
"""Mirror the daemon's operation policy before a row can receive a tap.""" """Mirror the daemon's operation policy before a row can receive a tap."""
if operation: if operation:
return False return False
if device.serial and not offroad:
return False
if not offroad and not device.paired: if not offroad and not device.paired:
return False return False
return True return True
@@ -175,6 +182,139 @@ class BluetoothAudioTestDialog(Widget):
self._done_button.render(button_rect) self._done_button.render(button_rect)
class ELM327Dialog(Widget):
"""Ephemeral, offroad-only ELM327 controls opened from the Bluetooth panel."""
def __init__(self, manager: BluetoothManager, device: BluetoothDevice):
super().__init__()
self._manager = manager
self._address = device.address
self._name = device.name
self._state_lock = threading.Lock()
self._closed = False
self._connecting = True
self._adapter = ""
self._response = ""
self._codes: list[str] | None = None
self._error = ""
self._keyboard = Keyboard(max_text_size=256, min_text_size=1, password_mode=False)
self._read_button = Button(tr("Read Codes"), self._read_codes, button_style=ButtonStyle.PRIMARY, font_size=42)
self._command_button = Button(tr("Send Command"), self._send_command, button_style=ButtonStyle.NORMAL, font_size=42)
self._done_button = Button(tr("Done"), gui_app.pop_widget, button_style=ButtonStyle.NORMAL, font_size=42)
def show_event(self):
super().show_event()
self._manager.elm_open(self._address, callback=self._on_open_result)
def hide_event(self):
with self._state_lock:
self._closed = True
self._manager.elm_close(self._address)
super().hide_event()
def _on_open_result(self, result, error):
should_close = False
with self._state_lock:
self._connecting = False
if error:
self._error = error
else:
self._adapter = str((result or {}).get("adapter", ""))
self._response = ""
self._codes = None
should_close = self._closed
if should_close:
self._manager.elm_close(self._address)
def _on_command_result(self, command: str, result, error):
with self._state_lock:
if error:
self._error = error
else:
response = str((result or {}).get("response", ""))
self._response = f"{command}\n{response}".strip()
def _on_read_result(self, result, error):
with self._state_lock:
if error:
self._error = error
else:
self._codes = [str(code) for code in (result or {}).get("codes", [])]
self._response = str((result or {}).get("raw", ""))
def _send_command(self):
with self._state_lock:
if self._connecting or self._error or self._closed:
return
self._keyboard.reset(min_text_size=1)
self._keyboard.set_title(tr("Send ELM327 command"), tr("Raw commands are available offroad only."))
self._keyboard.set_callback(self._on_command_entered)
gui_app.push_widget(self._keyboard)
def _on_command_entered(self, result: DialogResult):
command = self._keyboard.text.strip()
self._keyboard.clear()
if result != DialogResult.CONFIRM or not command:
return
with self._state_lock:
self._response = f"{command}\nSending..."
self._error = ""
self._manager.elm_command(self._address, command, callback=partial(self._on_command_result, command))
def _read_codes(self):
with self._state_lock:
self._codes = None
self._error = ""
self._manager.elm_read_dtcs(self._address, callback=self._on_read_result)
def _update_state(self):
with self._state_lock:
ready = bool(self._adapter) and not self._connecting and not self._error and not self._closed
status = self._manager.status
operation = self._manager.operation_for(self._address)
enabled = ready and status.offroad and not operation
self._read_button.set_enabled(enabled)
self._command_button.set_enabled(enabled)
self._done_button.set_enabled(True)
def _render(self, rect: rl.Rectangle):
dialog_rect = rl.Rectangle(rect.x + 90, rect.y + 60, rect.width - 180, rect.height - 120)
rl.draw_rectangle_rounded(dialog_rect, 0.03, 20, DIALOG_BACKGROUND)
with self._state_lock:
connecting = self._connecting
adapter = self._adapter
response = self._response
codes = self._codes
error = self._error
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 35, dialog_rect.width - 90, 70), tr("ELM327"),
font_size=62, font_weight=FontWeight.BOLD)
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 105, dialog_rect.width - 90, 52), self._name,
font_size=42, color=TEXT_SECONDARY)
identity = tr("Connecting...") if connecting else adapter
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 157, dialog_rect.width - 90, 52), identity,
font_size=42, color=TEXT_CONNECTED if adapter else TEXT_SECONDARY)
button_y = dialog_rect.y + 225
button_width = (dialog_rect.width - 135) / 3
self._read_button.render(rl.Rectangle(dialog_rect.x + 45, button_y, button_width, 90))
self._command_button.render(rl.Rectangle(dialog_rect.x + 60 + button_width, button_y, button_width, 90))
self._done_button.render(rl.Rectangle(dialog_rect.x + 75 + button_width * 2, button_y, button_width, 90))
if error:
gui_text_box(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 330, dialog_rect.width - 90, 80), error,
font_size=38, color=rl.Color(255, 150, 150, 255))
if codes is not None:
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 410, 300, 45), tr("Stored Codes"),
font_size=38, font_weight=FontWeight.BOLD)
code_text = "\n".join(codes) if codes else tr("No stored codes reported.")
gui_text_box(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 455, dialog_rect.width - 90, 95), code_text,
font_size=38, color=TEXT_SECONDARY)
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 560, 300, 45), tr("Response"),
font_size=38, font_weight=FontWeight.BOLD)
gui_text_box(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 605, dialog_rect.width - 90, dialog_rect.height - 650),
response or "", font_size=36, color=TEXT_SECONDARY)
class BluetoothManagerUI(Widget): class BluetoothManagerUI(Widget):
"""Big UI Bluetooth settings panel backed by the existing Bluetooth manager daemon.""" """Big UI Bluetooth settings panel backed by the existing Bluetooth manager daemon."""
def __init__(self, manager: BluetoothManager): def __init__(self, manager: BluetoothManager):
@@ -264,6 +404,8 @@ class BluetoothManagerUI(Widget):
return return
if not device.paired: if not device.paired:
self._manager.pair(device.address) self._manager.pair(device.address)
elif device.serial:
gui_app.push_widget(ELM327Dialog(self._manager, device))
elif not device.connected: elif not device.connected:
self._manager.connect(device.address) self._manager.connect(device.address)
else: else: