Compare commits

...

3 Commits

Author SHA1 Message Date
firestarsdog 6170955afc Tickle Me ELMo 2026-09-06 00:54:36 -04:00
firestarsdog 7d46313213 Sluglas, the Stripper Slug 2026-09-06 00:50:57 -04:00
firestarsdog edf76af796 Revert "Test fix - revert if nukes galaxy lol"
This reverts commit f51059956c.
2026-09-05 22:28:13 -04:00
17 changed files with 1367 additions and 40 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()))
+86 -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,50 @@ 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:
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": 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 +356,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)
+166
View File
@@ -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)(?<![0-9a-f])([0-9a-f]{2})(?![0-9a-f])")
def parse_dtcs(raw: str) -> 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}
+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))
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,11 @@ 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.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 +65,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 +165,38 @@ 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")
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 +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),) 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(): 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 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) assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False)
@@ -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
@@ -24,7 +24,7 @@ function rememberEvent(eventId) {
async function pollSentryEvent() { async function pollSentryEvent() {
try { try {
const response = await fetch("/api/sentry/status", { cache: "no-store" }) const response = await fetch(galaxyPath("/api/sentry/status"), { cache: "no-store" })
if (!response.ok) return if (!response.ok) return
const payload = await response.json() const payload = await response.json()
const event = payload?.lastEvent const event = payload?.lastEvent
@@ -81,6 +81,13 @@ async function readJsonResponse(response) {
try { try {
return JSON.parse(body) return JSON.parse(body)
} catch { } catch {
const contentType = response.headers?.get("content-type") || ""
if (contentType.includes("text/html") || body.trim().startsWith("<")) {
if (response.status === 200) {
throw new Error("Galaxy returned an HTML page instead of JSON data. Check your session or connection.")
}
throw new Error(`Galaxy returned an HTML error page (${response.status}). Check the device connection or Galaxy tunnel.`)
}
throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`) throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`)
} }
} }
@@ -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}"
@@ -108,14 +108,14 @@ async function sendTestEvent() {
state.testBusy = true state.testBusy = true
try { try {
const response = await fetch(galaxyPath("/api/sentry/test"), { method: "POST" }) const response = await fetch(galaxyPath("/api/sentry/test"), { method: "POST" })
const payload = await response.json() const payload = await readJsonResponse(response)
if (!response.ok) { if (!response.ok) {
showSnackbar(payload.error || "Sentry test failed.") showSnackbar(payload.error || "Sentry test failed.")
return return
} }
showSnackbar("Test capture started. The images will appear here shortly.") showSnackbar("Test capture started. The images will appear here shortly.")
} catch (error) { } catch (error) {
showSnackbar("Network error — is the device reachable?") showSnackbar(error.message || "Network error — is the device reachable?")
} finally { } finally {
state.testBusy = false state.testBusy = false
} }
@@ -128,6 +128,13 @@ async function readJsonResponse(response) {
try { try {
return JSON.parse(body) return JSON.parse(body)
} catch { } catch {
const contentType = response.headers?.get("content-type") || ""
if (contentType.includes("text/html") || body.trim().startsWith("<")) {
if (response.status === 200) {
throw new Error("Galaxy returned an HTML page instead of JSON data. Check your session or connection.")
}
throw new Error(`Galaxy returned an HTML error page (${response.status}). Check the device connection or Galaxy tunnel.`)
}
throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`) throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`)
} }
} }
@@ -89,5 +89,12 @@ export function isGalaxyTunnel() {
} }
export function galaxyPath(path) { export function galaxyPath(path) {
return path.startsWith("/") ? path : `/${path}` const suffix = path.startsWith("/") ? path : `/${path}`
if (!isGalaxyTunnel()) return suffix
if (suffix === "/api" || suffix.startsWith("/api/")) return suffix
const firstPathSegment = window.location.pathname.split("/").filter(Boolean)[0] || ""
const slug = /^[A-Za-z0-9]{16}$/.test(firstPathSegment) ? `/${firstPathSegment}` : ""
if (!slug || suffix === slug || suffix.startsWith(`${slug}/`)) return suffix
return `${slug}${suffix}`
} }
@@ -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": []})
@@ -0,0 +1,140 @@
import json
import time
from pathlib import Path
import pytest
from test_dashboard_stats import FakeParams, MODULE_DIR, _install_server_import_stubs
def _load_server_module():
import importlib.util
import sys
_install_server_import_stubs()
spec = importlib.util.spec_from_file_location("sentry_routing_server", MODULE_DIR / "the_galaxy.py")
module = importlib.util.module_from_spec(spec)
sys.modules["sentry_routing_server"] = module
spec.loader.exec_module(module)
return module
the_galaxy = _load_server_module()
@pytest.fixture
def client(monkeypatch, tmp_path):
assert the_galaxy._import_galaxy_web_symbols()
monkeypatch.setattr(the_galaxy, "params", FakeParams())
monkeypatch.setattr(the_galaxy, "_get_galaxy_dir", lambda: tmp_path)
app = the_galaxy.Flask(
f"test_galaxy_{time.monotonic_ns()}",
template_folder=str(MODULE_DIR / "templates"),
static_folder=str(MODULE_DIR / "assets"),
)
the_galaxy.setup(app)
return app.test_client()
def test_slug_middleware_strips_16_char_slug(client):
# Slug-prefixed API call to sentry push config
response = client.get("/df70390ca648d7c3/api/sentry/push/config")
assert response.status_code == 200
assert "application/json" in response.headers.get("Content-Type", "")
data = response.get_json()
assert data["enabled"] is True
assert len(data["publicKey"]) > 20
# Direct unslugged API call
response_direct = client.get("/api/sentry/push/config")
assert response_direct.status_code == 200
assert response_direct.get_json()["publicKey"] == data["publicKey"]
def test_slug_middleware_service_worker_and_headers(client):
with client.get("/df70390ca648d7c3/service-worker.js") as response:
assert response.status_code == 200
assert response.headers.get("Service-Worker-Allowed") == "/"
assert "no-store" in response.headers.get("Cache-Control", "")
with client.get("/service-worker.js") as response_direct:
assert response_direct.status_code == 200
assert response_direct.headers.get("Service-Worker-Allowed") == "/"
def test_404_api_returns_json_not_html(client):
# Non-existent API route without slug
res1 = client.get("/api/nonexistent")
assert res1.status_code == 404
assert "application/json" in res1.headers.get("Content-Type", "")
assert res1.get_json() == {"error": "Not found"}
# Non-existent API route with slug
res2 = client.get("/df70390ca648d7c3/api/nonexistent")
assert res2.status_code == 404
assert "application/json" in res2.headers.get("Content-Type", "")
assert res2.get_json() == {"error": "Not found"}
# POST to non-existent route returns 404 JSON
res3 = client.post("/random_post_route")
assert res3.status_code == 404
assert "application/json" in res3.headers.get("Content-Type", "")
def test_404_assets_returns_not_found_text(client):
res = client.get("/assets/nonexistent_image.png")
assert res.status_code == 404
assert res.get_data(as_text=True) == "Not found"
def test_404_spa_client_routes_return_html(client):
# SPA route without slug returns index.html
res1 = client.get("/sentry")
assert res1.status_code == 200
assert "text/html" in res1.headers.get("Content-Type", "")
# SPA route with slug returns index.html
res2 = client.get("/df70390ca648d7c3/sentry")
assert res2.status_code == 200
assert "text/html" in res2.headers.get("Content-Type", "")
def test_sentry_push_subscribe_lifecycle(client):
subscription_payload = {
"endpoint": "https://fcm.googleapis.com/fcm/send/test-endpoint-id",
"expirationTime": None,
"keys": {
"p256dh": "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-Skv60QVu3vW5PFGhmqazETUFAmeLbvDWP00n-5wViBRio5B-dQ31-10",
"auth": "5KkU95j6j8gBsmVdYqC8pA",
},
}
res = client.post(
"/api/sentry/push/subscribe",
data=json.dumps(subscription_payload),
content_type="application/json",
)
assert res.status_code == 200
assert res.get_json()["subscribed"] is True
assert res.get_json()["subscriptionCount"] == 1
# Check config shows count 1
res_cfg = client.get("/api/sentry/push/config")
assert res_cfg.get_json()["subscriptionCount"] == 1
def test_sentry_vapid_corrupt_file_self_healing(tmp_path, monkeypatch):
monkeypatch.setattr(the_galaxy, "_get_galaxy_dir", lambda: tmp_path)
key_path, _ = the_galaxy._sentry_push_paths()
key_path.parent.mkdir(parents=True, exist_ok=True)
# Write 0-byte corrupted file
key_path.write_bytes(b"")
assert key_path.stat().st_size == 0
# Should self-heal and generate valid key
vapid = the_galaxy._get_sentry_vapid()
assert vapid is not None
assert key_path.stat().st_size > 0
pub_key = the_galaxy._sentry_vapid_public_key(vapid)
assert len(pub_key) > 20
+68 -14
View File
@@ -6,6 +6,7 @@ import importlib
import math import math
import numbers import numbers
import os import os
import platform
import sys import sys
import sysconfig import sysconfig
import tarfile import tarfile
@@ -186,7 +187,9 @@ def _galaxy_runtime_dependency_paths() -> tuple[str, ...]:
"/usr/local/venv/lib/python3.12/site-packages", "/usr/local/venv/lib/python3.12/site-packages",
] ]
for venv_name in (".venv", ".venv-linux-arm64"): is_arm = platform.machine().lower() in ("aarch64", "arm64")
venv_names = (".venv-linux-arm64", ".venv") if is_arm else (".venv",)
for venv_name in venv_names:
venv_path = repo_root / venv_name / "lib" venv_path = repo_root / venv_name / "lib"
if venv_path.is_dir(): if venv_path.is_dir():
candidates.extend(str(path) for path in venv_path.glob("python*/site-packages")) candidates.extend(str(path) for path in venv_path.glob("python*/site-packages"))
@@ -196,10 +199,14 @@ def _galaxy_runtime_dependency_paths() -> tuple[str, ...]:
REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party" REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party"
GALAXY_RUNTIME_DEPENDENCY_PATHS = _galaxy_runtime_dependency_paths() GALAXY_RUNTIME_DEPENDENCY_PATHS = _galaxy_runtime_dependency_paths()
for deps_path in GALAXY_DEPS_PATHS + GALAXY_RUNTIME_DEPENDENCY_PATHS: for deps_path in GALAXY_DEPS_PATHS:
if os.path.isdir(deps_path) and deps_path not in sys.path: if os.path.isdir(deps_path) and deps_path not in sys.path:
sys.path.insert(0, deps_path) sys.path.insert(0, deps_path)
for deps_path in GALAXY_RUNTIME_DEPENDENCY_PATHS:
if os.path.isdir(deps_path) and deps_path not in sys.path:
sys.path.append(deps_path)
if REPO_THIRD_PARTY_PATH.is_dir() and str(REPO_THIRD_PARTY_PATH) not in sys.path: if REPO_THIRD_PARTY_PATH.is_dir() and str(REPO_THIRD_PARTY_PATH) not in sys.path:
sys.path.insert(0, str(REPO_THIRD_PARTY_PATH)) sys.path.insert(0, str(REPO_THIRD_PARTY_PATH))
@@ -949,16 +956,23 @@ def _get_sentry_vapid():
except ModuleNotFoundError as error: except ModuleNotFoundError as error:
raise RuntimeError("pywebpush is not installed") from error raise RuntimeError("pywebpush is not installed") from error
private_key_path, _ = _sentry_push_paths() with _SENTRY_PUSH_LOCK:
private_key_path.parent.mkdir(parents=True, exist_ok=True) private_key_path, _ = _sentry_push_paths()
if private_key_path.is_file(): private_key_path.parent.mkdir(parents=True, exist_ok=True)
return Vapid.from_file(str(private_key_path)) if private_key_path.is_file():
try:
if private_key_path.stat().st_size > 0:
return Vapid.from_file(str(private_key_path))
except Exception as error:
cloudlog.warning("Galaxy: Existing Sentry VAPID private key was invalid, regenerating: %s", error)
vapid = Vapid() vapid = Vapid()
vapid.generate_keys() vapid.generate_keys()
vapid.save_key(str(private_key_path)) temporary_path = private_key_path.with_suffix(".tmp")
private_key_path.chmod(0o600) vapid.save_key(str(temporary_path))
return vapid temporary_path.chmod(0o600)
temporary_path.replace(private_key_path)
return vapid
def _sentry_vapid_public_key(vapid) -> str: def _sentry_vapid_public_key(vapid) -> str:
@@ -4959,7 +4973,30 @@ def _set_lateral_maneuver_mode(enabled):
return _save_lateral_maneuver_status(status) return _save_lateral_maneuver_status(status)
_SLUG_PREFIX_RE = re.compile(r"^/([A-Za-z0-9]{16})(/.*)?$")
class GalaxySlugMiddleware:
"""WSGI middleware to normalize reverse-proxy requests prefixed with a 16-character tunnel slug."""
def __init__(self, wsgi_app):
self.wsgi_app = wsgi_app
def __call__(self, environ, start_response):
path_info = environ.get("PATH_INFO", "")
match = _SLUG_PREFIX_RE.match(path_info)
if match:
environ["HTTP_X_GALAXY_SLUG"] = match.group(1)
remainder = match.group(2)
environ["PATH_INFO"] = remainder if remainder else "/"
return self.wsgi_app(environ, start_response)
def setup(app): def setup(app):
if not isinstance(app.wsgi_app, GalaxySlugMiddleware):
app.wsgi_app = GalaxySlugMiddleware(app.wsgi_app)
model_status_debug = { model_status_debug = {
"last_signature": None, "last_signature": None,
"last_log_time": 0.0, "last_log_time": 0.0,
@@ -5007,6 +5044,7 @@ def setup(app):
def not_found(_): def not_found(_):
is_api = ( is_api = (
request.path == "/api" request.path == "/api"
or request.path.startswith("/api/")
or "/api/" in request.path or "/api/" in request.path
or request.is_json or request.is_json
or (request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html) or (request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html)
@@ -5083,11 +5121,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
@@ -5105,6 +5148,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":
@@ -8198,14 +8243,19 @@ def setup(app):
def sentry_service_worker(): def sentry_service_worker():
response = send_from_directory(app.static_folder, "service-worker.js", mimetype="application/javascript") response = send_from_directory(app.static_folder, "service-worker.js", mimetype="application/javascript")
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Service-Worker-Allowed"] = "/"
return response return response
@app.route("/api/sentry/push/config", methods=["GET"]) @app.route("/api/sentry/push/config", methods=["GET"])
def sentry_push_config(): def sentry_push_config():
try: try:
public_key = _sentry_vapid_public_key(_get_sentry_vapid()) public_key = _sentry_vapid_public_key(_get_sentry_vapid())
except Exception: except (RuntimeError, ModuleNotFoundError) as error:
cloudlog.warning("Galaxy: Sentry Web Push dependencies unavailable: %s", error)
return jsonify({"enabled": False, "error": "Web Push dependencies are unavailable."}), 503 return jsonify({"enabled": False, "error": "Web Push dependencies are unavailable."}), 503
except Exception as error:
cloudlog.exception("Galaxy: Failed to initialize Sentry Web Push: %s", error)
return jsonify({"enabled": False, "error": f"Push notification service error: {error}"}), 500
return jsonify({ return jsonify({
"enabled": True, "enabled": True,
@@ -8221,8 +8271,12 @@ def setup(app):
try: try:
_get_sentry_vapid() _get_sentry_vapid()
except Exception: except (RuntimeError, ModuleNotFoundError) as error:
cloudlog.warning("Galaxy: Sentry Web Push dependencies unavailable: %s", error)
return jsonify({"error": "Web Push dependencies are unavailable."}), 503 return jsonify({"error": "Web Push dependencies are unavailable."}), 503
except Exception as error:
cloudlog.exception("Galaxy: Failed to initialize Sentry Web Push for subscription: %s", error)
return jsonify({"error": f"Push notification service error: {error}"}), 500
with _SENTRY_PUSH_LOCK: with _SENTRY_PUSH_LOCK:
subscriptions = _load_sentry_push_subscriptions() subscriptions = _load_sentry_push_subscriptions()
+11 -4
View File
@@ -86,9 +86,16 @@ class Vapid01(object):
:type private_key: bytes :type private_key: bytes
""" """
# not sure why, but load_pem_private_key fails to deserialize try:
return cls.from_der( key = serialization.load_pem_private_key(
b''.join(private_key.splitlines()[1:-1])) private_key,
password=None,
backend=default_backend()
)
return cls(key)
except Exception:
lines = [line.strip() for line in private_key.splitlines() if line.strip() and not line.strip().startswith(b"-----")]
return cls.from_der(b''.join(lines))
@classmethod @classmethod
def from_der(cls, private_key): def from_der(cls, private_key):
@@ -197,7 +204,7 @@ class Vapid01(object):
def generate_keys(self): def generate_keys(self):
"""Generate a valid ECDSA Key Pair.""" """Generate a valid ECDSA Key Pair."""
self.private_key = ec.generate_private_key(ec.SECP256R1, self.private_key = ec.generate_private_key(ec.SECP256R1(),
default_backend()) default_backend())
def private_pem(self): def private_pem(self):
+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: