Tickle Me ELMo

This commit is contained in:
firestarsdog
2026-09-02 01:05:37 -04:00
parent 3d4c625deb
commit 114c184200
12 changed files with 1131 additions and 19 deletions
+3 -2
View File
@@ -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()))
+86 -2
View File
@@ -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)
+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"
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": []})
+8 -1
View File
@@ -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":
+38
View File
@@ -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)
+143 -1
View File
@@ -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: