mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-03 06:33:50 +08:00
Tickle Me ELMo
This commit is contained in:
@@ -214,7 +214,7 @@ class BlueZClient:
|
||||
continue
|
||||
props = interfaces[DEVICE_IFACE]
|
||||
uuids = [str(value).lower() for value in props.get("UUIDs", [])]
|
||||
audio, controller = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", "")))
|
||||
audio, controller, serial = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", "")))
|
||||
device = {
|
||||
"path": path,
|
||||
"address": str(props.get("Address", "")),
|
||||
@@ -227,9 +227,10 @@ class BlueZClient:
|
||||
"uuids": uuids,
|
||||
"audio": audio,
|
||||
"controller": controller,
|
||||
"serial": serial,
|
||||
}
|
||||
if include_hidden or show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"],
|
||||
device["blocked"], audio, controller, include_discovering):
|
||||
device["blocked"], audio, controller, serial, include_discovering):
|
||||
devices.append(device)
|
||||
return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower()))
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ from typing import Any
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.starpilot.system.bluetooth.bluez import BlueZClient
|
||||
from openpilot.starpilot.system.bluetooth.elm327 import ELM327Session
|
||||
from openpilot.starpilot.system.bluetooth.protocol import BLUETOOTH_SOCKET_PATH
|
||||
from openpilot.starpilot.system.bluetooth.radio import BluetoothRadio
|
||||
|
||||
|
||||
OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response"}
|
||||
OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response",
|
||||
"elm_open", "elm_command", "elm_read_dtcs"}
|
||||
SCAN_DURATION = 20.0
|
||||
AUDIO_TEST_START_DELAY = 3.0
|
||||
AUDIO_TEST_HOLD_TIME = 3.0
|
||||
@@ -21,13 +23,15 @@ AUDIO_TEST_HOLD_TIME = 3.0
|
||||
|
||||
class BluetoothController:
|
||||
def __init__(self, params: Params | None = None, bluez_factory=BlueZClient, radio: BluetoothRadio | None = None,
|
||||
params_memory: Params | None = None, sleep=time.sleep):
|
||||
params_memory: Params | None = None, sleep=time.sleep, elm_factory=ELM327Session):
|
||||
self.params = params or Params()
|
||||
self.params_memory = params_memory or Params(memory=True)
|
||||
self._bluez_factory = bluez_factory
|
||||
self._elm_factory = elm_factory
|
||||
self._radio = radio or BluetoothRadio()
|
||||
self._lock = threading.RLock()
|
||||
self._bluez: BlueZClient | None = None
|
||||
self._elm: ELM327Session | None = None
|
||||
self._pairing_address = ""
|
||||
self._pairing_error = ""
|
||||
self._last_reconnect = 0.0
|
||||
@@ -41,6 +45,7 @@ class BluetoothController:
|
||||
self.params.remove("BluetoothAudioTestActive")
|
||||
self.params_memory.remove("TestAlert")
|
||||
with self._lock:
|
||||
self._close_elm()
|
||||
if self._bluez is not None:
|
||||
self._bluez.close()
|
||||
self._bluez = None
|
||||
@@ -75,6 +80,7 @@ class BluetoothController:
|
||||
|
||||
def _reset_client(self) -> None:
|
||||
with self._lock:
|
||||
self._close_elm()
|
||||
if self._bluez is not None:
|
||||
try:
|
||||
self._bluez.close()
|
||||
@@ -82,6 +88,32 @@ class BluetoothController:
|
||||
pass
|
||||
self._bluez = None
|
||||
|
||||
def _close_elm(self) -> None:
|
||||
with self._lock:
|
||||
session = self._elm
|
||||
self._elm = None
|
||||
if session is None:
|
||||
return
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
cloudlog.warning("ELM327 session close failed")
|
||||
|
||||
def _invalidate_elm(self, session: ELM327Session) -> None:
|
||||
with self._lock:
|
||||
if self._elm is not session:
|
||||
return
|
||||
self._close_elm()
|
||||
|
||||
def _active_elm(self, address: str) -> ELM327Session:
|
||||
with self._lock:
|
||||
session = self._elm
|
||||
if session is None:
|
||||
raise RuntimeError("ELM327 session is not open")
|
||||
if str(session.address).upper() != address.upper():
|
||||
raise RuntimeError("ELM327 session is open for another device")
|
||||
return session
|
||||
|
||||
def _offroad(self) -> bool:
|
||||
return self.params.get_bool("IsOffroad")
|
||||
|
||||
@@ -178,6 +210,7 @@ class BluetoothController:
|
||||
pass
|
||||
raise
|
||||
else:
|
||||
self._close_elm()
|
||||
try:
|
||||
client = self._bluez
|
||||
if client is not None:
|
||||
@@ -209,6 +242,9 @@ class BluetoothController:
|
||||
elif command == "disconnect":
|
||||
self._client().disconnect(address)
|
||||
elif command == "forget":
|
||||
with self._lock:
|
||||
if self._elm is not None and str(self._elm.address).upper() == address.upper():
|
||||
self._close_elm()
|
||||
self._client().remove(address)
|
||||
if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper():
|
||||
self.params.remove("BluetoothAudioAddress")
|
||||
@@ -234,6 +270,50 @@ class BluetoothController:
|
||||
self._audio_test_deadline = deadline
|
||||
threading.Thread(target=self._test_audio_worker, args=(address, deadline), daemon=True).start()
|
||||
return {"audio_test_delay_ms": max(0, round((deadline - time.monotonic()) * 1000))}
|
||||
elif command == "elm_open":
|
||||
if not address:
|
||||
raise RuntimeError("Bluetooth device address is required")
|
||||
if not self.params.get_bool("BluetoothEnabled"):
|
||||
raise RuntimeError("Bluetooth is disabled")
|
||||
with self._lock:
|
||||
if self._elm is not None and str(self._elm.address).upper() == address.upper():
|
||||
return {"adapter": self._elm.adapter_name}
|
||||
device = self._client().device_for_address(address)
|
||||
if not device.get("paired"):
|
||||
raise RuntimeError("Pair the Bluetooth device before opening ELM327")
|
||||
if not device.get("serial"):
|
||||
raise RuntimeError("Bluetooth device does not advertise Serial Port Profile")
|
||||
self._close_elm()
|
||||
session = self._elm_factory(address)
|
||||
try:
|
||||
adapter = str(session.open())
|
||||
except Exception:
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
session.adapter_name = adapter
|
||||
self._elm = session
|
||||
return {"adapter": adapter}
|
||||
elif command == "elm_close":
|
||||
with self._lock:
|
||||
if self._elm is not None and str(self._elm.address).upper() == address.upper():
|
||||
self._close_elm()
|
||||
elif command == "elm_command":
|
||||
session = self._active_elm(address)
|
||||
try:
|
||||
return {"response": session.command(str(request.get("value", "")))}
|
||||
except Exception:
|
||||
self._invalidate_elm(session)
|
||||
raise
|
||||
elif command == "elm_read_dtcs":
|
||||
session = self._active_elm(address)
|
||||
try:
|
||||
return session.read_dtcs()
|
||||
except Exception:
|
||||
self._invalidate_elm(session)
|
||||
raise
|
||||
elif command == "pairing_response":
|
||||
if not self._client().agent.respond(str(request.get("prompt_id", "")), bool(request.get("accepted", False)), str(request.get("value", ""))):
|
||||
raise RuntimeError("Pairing request is no longer active")
|
||||
@@ -252,10 +332,14 @@ class BluetoothController:
|
||||
while True:
|
||||
time.sleep(2)
|
||||
if not self.params.get_bool("BluetoothEnabled"):
|
||||
self._close_elm()
|
||||
continue
|
||||
try:
|
||||
status = self.status()
|
||||
if self._elm is not None and not status["offroad"]:
|
||||
self._close_elm()
|
||||
if not status["available"] or not status["powered"]:
|
||||
self._close_elm()
|
||||
continue
|
||||
now = time.monotonic()
|
||||
self._maintain_scan(status, now)
|
||||
|
||||
@@ -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}
|
||||
@@ -16,6 +16,7 @@ BLUETOOTH_RADIO_HELPER = "/usr/comma/bluetooth-radio"
|
||||
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
|
||||
HID_UUID = "00001124-0000-1000-8000-00805f9b34fb"
|
||||
HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb"
|
||||
SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb"
|
||||
COMMAND_TIMEOUTS = {
|
||||
"set_power": 90.0,
|
||||
"start_scan": 20.0,
|
||||
@@ -24,6 +25,10 @@ COMMAND_TIMEOUTS = {
|
||||
"disconnect": 20.0,
|
||||
"forget": 20.0,
|
||||
"test_audio": 10.0,
|
||||
"elm_open": 15.0,
|
||||
"elm_close": 5.0,
|
||||
"elm_command": 20.0,
|
||||
"elm_read_dtcs": 30.0,
|
||||
}
|
||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
@@ -40,6 +45,7 @@ class BluetoothDevice:
|
||||
uuids: tuple[str, ...] = ()
|
||||
audio: bool = False
|
||||
controller: bool = False
|
||||
serial: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict[str, Any]) -> "BluetoothDevice":
|
||||
@@ -54,6 +60,7 @@ class BluetoothDevice:
|
||||
uuids=tuple(str(uuid).lower() for uuid in value.get("uuids", ())),
|
||||
audio=bool(value.get("audio", False)),
|
||||
controller=bool(value.get("controller", False)),
|
||||
serial=bool(value.get("serial", False)),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,21 +93,22 @@ class BluetoothStatus:
|
||||
)
|
||||
|
||||
|
||||
def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool]:
|
||||
def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool, bool]:
|
||||
normalized = {str(uuid).lower() for uuid in uuids}
|
||||
major_class = (int(bluetooth_class) >> 8) & 0x1F
|
||||
audio = A2DP_SINK_UUID in normalized or major_class == 0x04 or icon in {"audio-card", "audio-headphones", "audio-headset"}
|
||||
controller = HID_UUID in normalized or HOG_UUID in normalized or major_class == 0x05 or icon in {"input-gaming", "input-mouse", "input-keyboard"}
|
||||
return audio, controller
|
||||
serial = SPP_UUID in normalized
|
||||
return audio, controller, serial
|
||||
|
||||
|
||||
def show_pairing_device(address: str, name: str, paired: bool, trusted: bool, connected: bool, blocked: bool,
|
||||
audio: bool, controller: bool, discovering: bool = False) -> bool:
|
||||
audio: bool, controller: bool, serial: bool = False, discovering: bool = False) -> bool:
|
||||
known = paired or trusted or connected
|
||||
normalized_address = "".join(character for character in address.upper() if character.isalnum())
|
||||
normalized_name = "".join(character for character in name.upper() if character.isalnum())
|
||||
named = bool(name) and name != "Unknown device" and normalized_name != normalized_address
|
||||
return known or (named and not blocked and (audio or controller))
|
||||
return known or (named and not blocked and (audio or controller or serial))
|
||||
|
||||
|
||||
class _DesktopFakeBluetooth:
|
||||
@@ -304,5 +312,17 @@ class BluetoothClient:
|
||||
result = self.call("test_audio", address=address)
|
||||
return max(0.0, float(result.get("audio_test_delay_ms", 0)) / 1000.0)
|
||||
|
||||
def elm_open(self, address: str) -> dict[str, Any]:
|
||||
return self.call("elm_open", address=address)
|
||||
|
||||
def elm_close(self, address: str) -> dict[str, Any]:
|
||||
return self.call("elm_close", address=address)
|
||||
|
||||
def elm_command(self, address: str, value: str) -> dict[str, Any]:
|
||||
return self.call("elm_command", address=address, value=value)
|
||||
|
||||
def elm_read_dtcs(self, address: str) -> dict[str, Any]:
|
||||
return self.call("elm_read_dtcs", address=address)
|
||||
|
||||
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
|
||||
self.call("pairing_response", prompt_id=prompt_id, accepted=accepted, value=value)
|
||||
|
||||
@@ -6,10 +6,11 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink
|
||||
import openpilot.starpilot.system.bluetooth.daemon as bluetooth_daemon
|
||||
from openpilot.starpilot.system.bluetooth.bluez import PairingAgent
|
||||
from openpilot.starpilot.system.bluetooth.daemon import BluetoothController
|
||||
from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus,
|
||||
device_capabilities, show_pairing_device)
|
||||
SPP_UUID, device_capabilities, show_pairing_device)
|
||||
from openpilot.system import hardware
|
||||
from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager
|
||||
|
||||
@@ -64,6 +65,7 @@ class FakeBlueZ:
|
||||
"connected": False,
|
||||
"audio": True,
|
||||
"controller": False,
|
||||
"serial": False,
|
||||
}
|
||||
|
||||
def close(self):
|
||||
@@ -163,9 +165,38 @@ class FakeProcess:
|
||||
self.stopped = True
|
||||
|
||||
|
||||
class FakeELM:
|
||||
instances = []
|
||||
|
||||
def __init__(self, address):
|
||||
self.address = address
|
||||
self.adapter_name = "Fake ELM327"
|
||||
self.closed = False
|
||||
self.commands = []
|
||||
self.opened = False
|
||||
FakeELM.instances.append(self)
|
||||
|
||||
def open(self):
|
||||
self.opened = True
|
||||
return self.adapter_name
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def command(self, value):
|
||||
self.commands.append(value)
|
||||
if value == "fail":
|
||||
raise RuntimeError("transport failed")
|
||||
return f"response for {value}"
|
||||
|
||||
def read_dtcs(self):
|
||||
return {"codes": ["P0133"], "raw": "43 01 33"}
|
||||
|
||||
|
||||
def test_protocol_round_trip_and_capabilities():
|
||||
audio, controller = device_capabilities([A2DP_SINK_UUID, HID_UUID])
|
||||
audio, controller, serial = device_capabilities([A2DP_SINK_UUID, HID_UUID])
|
||||
assert audio and controller
|
||||
assert not serial
|
||||
status = BluetoothStatus.from_dict({
|
||||
"available": True,
|
||||
"enabled": True,
|
||||
@@ -174,12 +205,155 @@ def test_protocol_round_trip_and_capabilities():
|
||||
assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True),)
|
||||
|
||||
|
||||
def test_serial_capability_round_trips_and_is_discoverable():
|
||||
audio, controller, serial = device_capabilities([SPP_UUID.upper()])
|
||||
assert not audio and not controller and serial
|
||||
|
||||
status = BluetoothStatus.from_dict({
|
||||
"devices": [{"address": "00:11:22:33:44:55", "name": "OBDII", "serial": True}],
|
||||
})
|
||||
assert status.devices[0].serial
|
||||
assert show_pairing_device("00:11:22:33:44:55", "OBDII", False, False, False, False,
|
||||
audio=False, controller=False, serial=True)
|
||||
|
||||
|
||||
def test_serial_device_is_not_auto_reconnected_but_audio_device_is(monkeypatch):
|
||||
class StopMaintenance(Exception):
|
||||
pass
|
||||
|
||||
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
|
||||
client = FakeBlueZ()
|
||||
client.powered = True
|
||||
client.device.update(paired=True, trusted=True, connected=False, audio=False, controller=False, serial=True)
|
||||
sleeps = 0
|
||||
|
||||
def sleep(_delay):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps > 1:
|
||||
raise StopMaintenance
|
||||
|
||||
monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
|
||||
controller = BluetoothController(params, lambda: client, FakeRadio(), sleep=sleep, elm_factory=FakeELM)
|
||||
controller._bluez = client
|
||||
controller._last_reconnect = -100.0
|
||||
with pytest.raises(StopMaintenance):
|
||||
controller.maintain_connections()
|
||||
assert client.actions == []
|
||||
|
||||
client.device.update(audio=True, serial=False)
|
||||
sleeps = 0
|
||||
controller._last_reconnect = -100.0
|
||||
with pytest.raises(StopMaintenance):
|
||||
controller.maintain_connections()
|
||||
assert client.actions == [("connect", client.device["address"])]
|
||||
|
||||
|
||||
def make_elm_controller(elm_factory=FakeELM):
|
||||
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
|
||||
client = FakeBlueZ()
|
||||
client.device.update(paired=True, trusted=True, serial=True)
|
||||
controller = BluetoothController(params, lambda: client, FakeRadio(), elm_factory=elm_factory)
|
||||
return controller, client, params
|
||||
|
||||
|
||||
def test_elm_is_lazy_and_requires_a_paired_serial_device():
|
||||
FakeELM.instances = []
|
||||
controller, client, params = make_elm_controller()
|
||||
assert FakeELM.instances == []
|
||||
controller.status()
|
||||
assert FakeELM.instances == []
|
||||
|
||||
result = controller.handle({"command": "elm_open", "address": client.device["address"]})
|
||||
assert result == {"adapter": "Fake ELM327"}
|
||||
assert len(FakeELM.instances) == 1
|
||||
|
||||
params.values["IsOffroad"] = False
|
||||
with pytest.raises(RuntimeError, match="offroad"):
|
||||
controller.handle({"command": "elm_command", "address": client.device["address"], "value": "ATI"})
|
||||
|
||||
params.values["IsOffroad"] = True
|
||||
client.device["paired"] = False
|
||||
controller.handle({"command": "elm_close", "address": client.device["address"]})
|
||||
with pytest.raises(RuntimeError, match="Pair"):
|
||||
controller.handle({"command": "elm_open", "address": client.device["address"]})
|
||||
|
||||
client.device.update(paired=True, serial=False)
|
||||
with pytest.raises(RuntimeError, match="Serial Port Profile"):
|
||||
controller.handle({"command": "elm_open", "address": client.device["address"]})
|
||||
|
||||
|
||||
def test_elm_commands_use_one_session_and_close_on_transport_failure():
|
||||
FakeELM.instances = []
|
||||
controller, client, _ = make_elm_controller()
|
||||
address = client.device["address"]
|
||||
controller.handle({"command": "elm_open", "address": address})
|
||||
assert controller.handle({"command": "elm_open", "address": address}) == {"adapter": "Fake ELM327"}
|
||||
assert controller.handle({"command": "elm_command", "address": address, "value": "ATI"}) == {"response": "response for ATI"}
|
||||
assert controller.handle({"command": "elm_read_dtcs", "address": address}) == {"codes": ["P0133"], "raw": "43 01 33"}
|
||||
with pytest.raises(RuntimeError, match="transport"):
|
||||
controller.handle({"command": "elm_command", "address": address, "value": "fail"})
|
||||
assert controller._elm is None
|
||||
assert FakeELM.instances[0].closed
|
||||
|
||||
|
||||
def test_elm_open_replaces_a_different_session_and_close_is_allowed_onroad():
|
||||
FakeELM.instances = []
|
||||
controller, client, params = make_elm_controller()
|
||||
first = client.device["address"]
|
||||
second = "AA:BB:CC:DD:EE:FF"
|
||||
controller.handle({"command": "elm_open", "address": first})
|
||||
controller.handle({"command": "elm_open", "address": second})
|
||||
assert len(FakeELM.instances) == 2
|
||||
assert FakeELM.instances[0].closed
|
||||
assert not FakeELM.instances[1].closed
|
||||
|
||||
params.values["IsOffroad"] = False
|
||||
controller.handle({"command": "elm_close", "address": second})
|
||||
assert FakeELM.instances[1].closed and controller._elm is None
|
||||
|
||||
|
||||
def test_elm_cleanup_happens_before_poweroff_forget_shutdown_and_onroad(monkeypatch):
|
||||
class StopMaintenance(Exception):
|
||||
pass
|
||||
|
||||
for cleanup in ("power", "forget", "shutdown", "onroad"):
|
||||
FakeELM.instances = []
|
||||
controller, client, params = make_elm_controller()
|
||||
address = client.device["address"]
|
||||
controller.handle({"command": "elm_open", "address": address})
|
||||
session = FakeELM.instances[0]
|
||||
|
||||
if cleanup == "power":
|
||||
controller.handle({"command": "set_power", "enabled": False})
|
||||
elif cleanup == "forget":
|
||||
controller.handle({"command": "forget", "address": address})
|
||||
elif cleanup == "shutdown":
|
||||
controller.close()
|
||||
else:
|
||||
params.values["IsOffroad"] = False
|
||||
sleeps = 0
|
||||
|
||||
def sleep(_delay):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps > 1:
|
||||
raise StopMaintenance
|
||||
|
||||
monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
|
||||
controller._sleep = sleep
|
||||
with pytest.raises(StopMaintenance):
|
||||
controller.maintain_connections()
|
||||
|
||||
assert session.closed and controller._elm is None
|
||||
|
||||
|
||||
def test_pairing_list_filters_anonymous_and_irrelevant_advertisements():
|
||||
assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False)
|
||||
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False)
|
||||
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True)
|
||||
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, True)
|
||||
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, True)
|
||||
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, discovering=True)
|
||||
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, discovering=True)
|
||||
assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -398,6 +398,116 @@
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
|
||||
.bluetoothElmPanel {
|
||||
background: var(--secondary-bg);
|
||||
border: 1px solid rgba(169, 140, 229, 0.45);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.bluetoothElmHeader,
|
||||
.bluetoothElmActions,
|
||||
.bluetoothElmCommand > div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bluetoothElmHeader {
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.bluetoothElmHeader h3,
|
||||
.bluetoothElmHeader p,
|
||||
.bluetoothElmCodes p,
|
||||
.bluetoothElmResponse pre,
|
||||
.bluetoothElmCommand label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bluetoothElmHeader p,
|
||||
.bluetoothElmCodes p {
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bluetoothElmHeader strong {
|
||||
color: #cbb2fa;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bluetoothElmActions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bluetoothElmActions button,
|
||||
.bluetoothElmCommand button {
|
||||
background: linear-gradient(135deg, #765bb6, #9474ce);
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-md);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.bluetoothElmActions .bluetoothSecondaryButton {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--sidebar-border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.bluetoothElmActions button:disabled,
|
||||
.bluetoothElmCommand button:disabled,
|
||||
.bluetoothElmCommand input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.bluetoothElmCodes,
|
||||
.bluetoothElmCommand,
|
||||
.bluetoothElmResponse {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bluetoothElmCommand label {
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
font-size: 0.86rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bluetoothElmCommand > div {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.bluetoothElmCommand input {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--sidebar-border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.bluetoothElmResponse pre {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--sidebar-border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-color);
|
||||
margin-top: 6px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@keyframes bluetoothSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ const state = reactive({
|
||||
prompt: null,
|
||||
audioTestAddress: "",
|
||||
audioTestLabel: "",
|
||||
elmAddress: "",
|
||||
elmName: "",
|
||||
elmAdapter: "",
|
||||
elmResponse: "",
|
||||
elmCodes: null,
|
||||
error: "",
|
||||
})
|
||||
|
||||
@@ -46,7 +51,9 @@ function schedulePoll(delay = pollDelay()) {
|
||||
pollTimer = setTimeout(async () => {
|
||||
pollTimer = null
|
||||
try {
|
||||
if (bluetoothPageActive() && document.visibilityState !== "hidden" && state.busy !== "power") {
|
||||
if (state.elmAddress && (document.visibilityState === "hidden" || !bluetoothPageActive())) {
|
||||
closeElm()
|
||||
} else if (bluetoothPageActive() && document.visibilityState !== "hidden" && state.busy !== "power") {
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
@@ -97,8 +104,10 @@ async function request(operation, body = {}) {
|
||||
}
|
||||
state.error = ""
|
||||
await refresh()
|
||||
return payload
|
||||
} catch (error) {
|
||||
state.error = error?.message || "Bluetooth operation failed"
|
||||
return null
|
||||
} finally {
|
||||
state.busy = ""
|
||||
if (operation === "power") state.powerTarget = null
|
||||
@@ -106,6 +115,54 @@ async function request(operation, body = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearElmState() {
|
||||
state.elmAddress = ""
|
||||
state.elmName = ""
|
||||
state.elmAdapter = ""
|
||||
state.elmResponse = ""
|
||||
state.elmCodes = null
|
||||
}
|
||||
|
||||
function closeElm() {
|
||||
const address = state.elmAddress
|
||||
if (!address) return
|
||||
clearElmState()
|
||||
request("elm_close", { address })
|
||||
}
|
||||
|
||||
async function openElm(address) {
|
||||
const device = state.devices.find((item) => normalizedAddress(item) === String(address || "").toUpperCase())
|
||||
const payload = await request("elm_open", { address })
|
||||
if (!payload || !device) return
|
||||
state.elmAddress = address
|
||||
state.elmName = device.name || address
|
||||
state.elmAdapter = String(payload.adapter || "")
|
||||
state.elmResponse = ""
|
||||
state.elmCodes = null
|
||||
}
|
||||
|
||||
async function readElmCodes() {
|
||||
const address = state.elmAddress
|
||||
if (!address) return
|
||||
const payload = await request("elm_read_dtcs", { address })
|
||||
if (!payload || state.elmAddress !== address) return
|
||||
state.elmCodes = Array.isArray(payload.codes) ? payload.codes.map(String) : []
|
||||
state.elmResponse = String(payload.raw || "")
|
||||
}
|
||||
|
||||
async function sendElmCommand() {
|
||||
const address = state.elmAddress
|
||||
const input = document.getElementById("bluetoothElmCommand")
|
||||
const command = input?.value.trim() || ""
|
||||
if (!address || !command) {
|
||||
state.error = "Enter an ELM327 command."
|
||||
return
|
||||
}
|
||||
const payload = await request("elm_command", { address, value: command })
|
||||
if (!payload || state.elmAddress !== address) return
|
||||
state.elmResponse = `${command}\n${String(payload.response || "")}`.trim()
|
||||
}
|
||||
|
||||
async function refreshOnce() {
|
||||
const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}`
|
||||
const response = await fetch(statusUrl, { cache: "no-store" })
|
||||
@@ -126,6 +183,10 @@ async function refreshOnce() {
|
||||
devices,
|
||||
})
|
||||
state.devices = devices
|
||||
if (state.elmAddress && (!state.enabled || !state.offroad ||
|
||||
!devices.some((device) => normalizedAddress(device) === state.elmAddress.toUpperCase() && device.paired))) {
|
||||
closeElm()
|
||||
}
|
||||
if (state.deviceSignature !== deviceSignature) {
|
||||
state.deviceSignature = deviceSignature
|
||||
state.revision++
|
||||
@@ -223,7 +284,11 @@ function initialize() {
|
||||
window.addEventListener("focus", refresh)
|
||||
window.addEventListener("pageshow", refresh)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "hidden" && bluetoothPageActive()) refresh()
|
||||
if (document.visibilityState === "hidden" || !bluetoothPageActive()) {
|
||||
closeElm()
|
||||
} else {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
refresh()
|
||||
schedulePoll(0)
|
||||
@@ -248,6 +313,7 @@ function deviceCapabilities(device) {
|
||||
const capabilities = []
|
||||
if (device.audio) capabilities.push("Audio")
|
||||
if (device.controller) capabilities.push("Controller")
|
||||
if (device.serial) capabilities.push("Serial")
|
||||
return capabilities.join(" · ") || "Bluetooth device"
|
||||
}
|
||||
|
||||
@@ -353,7 +419,14 @@ function renderDeviceActions(device) {
|
||||
actions.push("<button data-bluetooth-operation=\"pair\" data-address=\"" + address + "\"" +
|
||||
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"
|
||||
actions.push("<button data-bluetooth-operation=\"" + operation + "\" data-address=\"" + address + "\"" +
|
||||
renderDisabledAttribute(!!state.busy) + ">" + (device.connected ? "Disconnect" : "Connect") + "</button>")
|
||||
@@ -407,7 +480,48 @@ function handleDeviceListClick(event) {
|
||||
const operation = button.dataset.bluetoothOperation
|
||||
const address = button.dataset.address || ""
|
||||
if (operation === "forget" && !window.confirm("Forget " + (button.dataset.deviceName || "this device") + "?")) return
|
||||
request(operation, { address })
|
||||
if (operation === "elm_open") {
|
||||
openElm(address)
|
||||
} else {
|
||||
request(operation, { address })
|
||||
}
|
||||
}
|
||||
|
||||
function renderElmPanel() {
|
||||
if (!state.elmAddress) return ""
|
||||
return html`
|
||||
<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() {
|
||||
@@ -440,6 +554,7 @@ export function Bluetooth() {
|
||||
<span>The test sound is sent at NOW. The audible gap is Bluetooth latency.</span>
|
||||
</div>
|
||||
` : ""}
|
||||
${() => renderElmPanel()}
|
||||
|
||||
<div class="bluetoothToolbar">
|
||||
<button disabled="${() => !state.offroad || !state.enabled || !!state.busy}"
|
||||
|
||||
@@ -132,7 +132,15 @@ class FakeBluetoothClient:
|
||||
|
||||
def call(self, 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):
|
||||
@@ -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):
|
||||
client, _ = _params_client(monkeypatch, {"IsOffroad": True, "FavoriteSlots": []}, "mici")
|
||||
monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"available": True, "mappings": [], "devices": []})
|
||||
|
||||
@@ -4994,11 +4994,16 @@ def setup(app):
|
||||
"select_audio": "select_audio",
|
||||
"test_audio": "test_audio",
|
||||
"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)
|
||||
if command is None:
|
||||
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"):
|
||||
return jsonify({"error": "Bluetooth settings can only be changed offroad."}), 409
|
||||
|
||||
@@ -5016,6 +5021,8 @@ def setup(app):
|
||||
payload["address"] = str(data.get("address", ""))
|
||||
if not payload["address"] and command != "select_audio":
|
||||
return jsonify({"error": "Bluetooth device address is required."}), 400
|
||||
if command == "elm_command":
|
||||
payload["value"] = str(data.get("value", ""))
|
||||
try:
|
||||
client = BluetoothClient(timeout=10.0)
|
||||
if command == "set_power":
|
||||
|
||||
@@ -97,6 +97,32 @@ class BluetoothManager:
|
||||
self._operations.pop(normalized_address, None)
|
||||
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:
|
||||
with self._lock:
|
||||
if self._power_pending:
|
||||
@@ -146,5 +172,17 @@ class BluetoothManager:
|
||||
self._audio_test_deadline = 0.0
|
||||
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:
|
||||
self._run(self._client.respond, prompt_id, accepted, value)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
import threading
|
||||
|
||||
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.confirm_dialog import ConfirmDialog, alert_dialog
|
||||
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.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"))
|
||||
if device.controller:
|
||||
capabilities.append(tr("controller"))
|
||||
if device.serial:
|
||||
capabilities.append(tr("serial"))
|
||||
capability_text = " / ".join(capabilities)
|
||||
|
||||
if device.connected:
|
||||
return tr("Connected") + (f" / {capability_text}" if capability_text else "")
|
||||
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("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."""
|
||||
if operation:
|
||||
return False
|
||||
if device.serial and not offroad:
|
||||
return False
|
||||
if not offroad and not device.paired:
|
||||
return False
|
||||
return True
|
||||
@@ -175,6 +182,139 @@ class BluetoothAudioTestDialog(Widget):
|
||||
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):
|
||||
"""Big UI Bluetooth settings panel backed by the existing Bluetooth manager daemon."""
|
||||
def __init__(self, manager: BluetoothManager):
|
||||
@@ -264,6 +404,8 @@ class BluetoothManagerUI(Widget):
|
||||
return
|
||||
if not device.paired:
|
||||
self._manager.pair(device.address)
|
||||
elif device.serial:
|
||||
gui_app.push_widget(ELM327Dialog(self._manager, device))
|
||||
elif not device.connected:
|
||||
self._manager.connect(device.address)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user