mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-05 07:33:44 +08:00
Compare commits
1 Commits
Dom
...
TickleMeELMo
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bdae4b250 |
@@ -235,7 +235,7 @@ class BlueZClient:
|
|||||||
continue
|
continue
|
||||||
props = interfaces[DEVICE_IFACE]
|
props = interfaces[DEVICE_IFACE]
|
||||||
uuids = [str(value).lower() for value in props.get("UUIDs", [])]
|
uuids = [str(value).lower() for value in props.get("UUIDs", [])]
|
||||||
audio, controller = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", "")))
|
audio, controller, serial = device_capabilities(uuids, int(props.get("Class", 0)), str(props.get("Icon", "")))
|
||||||
device = {
|
device = {
|
||||||
"path": path,
|
"path": path,
|
||||||
"address": str(props.get("Address", "")),
|
"address": str(props.get("Address", "")),
|
||||||
@@ -248,9 +248,10 @@ class BlueZClient:
|
|||||||
"uuids": uuids,
|
"uuids": uuids,
|
||||||
"audio": audio,
|
"audio": audio,
|
||||||
"controller": controller,
|
"controller": controller,
|
||||||
|
"serial": serial,
|
||||||
}
|
}
|
||||||
if include_hidden or show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"],
|
if include_hidden or show_pairing_device(device["address"], device["name"], device["paired"], device["trusted"], device["connected"],
|
||||||
device["blocked"], audio, controller, include_discovering):
|
device["blocked"], audio, controller, serial, include_discovering):
|
||||||
devices.append(device)
|
devices.append(device)
|
||||||
return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower()))
|
return sorted(devices, key=lambda device: (not device["connected"], not device["paired"], -(device["rssi"] or -127), device["name"].lower()))
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ from typing import Any
|
|||||||
from openpilot.common.params import Params
|
from openpilot.common.params import Params
|
||||||
from openpilot.common.swaglog import cloudlog
|
from openpilot.common.swaglog import cloudlog
|
||||||
from openpilot.starpilot.system.bluetooth.bluez import BlueZClient
|
from openpilot.starpilot.system.bluetooth.bluez import BlueZClient
|
||||||
|
from openpilot.starpilot.system.bluetooth.elm327 import ELM327Session
|
||||||
from openpilot.starpilot.system.bluetooth.protocol import BLUETOOTH_SOCKET_PATH
|
from openpilot.starpilot.system.bluetooth.protocol import BLUETOOTH_SOCKET_PATH
|
||||||
from openpilot.starpilot.system.bluetooth.radio import BluetoothRadio
|
from openpilot.starpilot.system.bluetooth.radio import BluetoothRadio
|
||||||
|
|
||||||
|
|
||||||
OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response"}
|
OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response",
|
||||||
|
"elm_open", "elm_command", "elm_read_dtcs"}
|
||||||
SCAN_DURATION = 20.0
|
SCAN_DURATION = 20.0
|
||||||
AUDIO_TEST_START_DELAY = 3.0
|
AUDIO_TEST_START_DELAY = 3.0
|
||||||
AUDIO_TEST_HOLD_TIME = 3.0
|
AUDIO_TEST_HOLD_TIME = 3.0
|
||||||
@@ -24,13 +26,15 @@ MANUAL_DISCONNECT_SUPPRESSION_SECONDS = 300.0
|
|||||||
|
|
||||||
class BluetoothController:
|
class BluetoothController:
|
||||||
def __init__(self, params: Params | None = None, bluez_factory=BlueZClient, radio: BluetoothRadio | None = None,
|
def __init__(self, params: Params | None = None, bluez_factory=BlueZClient, radio: BluetoothRadio | None = None,
|
||||||
params_memory: Params | None = None, sleep=time.sleep):
|
params_memory: Params | None = None, sleep=time.sleep, elm_factory=ELM327Session):
|
||||||
self.params = params or Params()
|
self.params = params or Params()
|
||||||
self.params_memory = params_memory or Params(memory=True)
|
self.params_memory = params_memory or Params(memory=True)
|
||||||
self._bluez_factory = bluez_factory
|
self._bluez_factory = bluez_factory
|
||||||
|
self._elm_factory = elm_factory
|
||||||
self._radio = radio or BluetoothRadio()
|
self._radio = radio or BluetoothRadio()
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._bluez: BlueZClient | None = None
|
self._bluez: BlueZClient | None = None
|
||||||
|
self._elm: ELM327Session | None = None
|
||||||
self._pairing_address = ""
|
self._pairing_address = ""
|
||||||
self._pairing_error = ""
|
self._pairing_error = ""
|
||||||
self._last_reconnect = 0.0
|
self._last_reconnect = 0.0
|
||||||
@@ -46,6 +50,7 @@ class BluetoothController:
|
|||||||
self.params.remove("BluetoothAudioTestActive")
|
self.params.remove("BluetoothAudioTestActive")
|
||||||
self.params_memory.remove("TestAlert")
|
self.params_memory.remove("TestAlert")
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._close_elm()
|
||||||
if self._bluez is not None:
|
if self._bluez is not None:
|
||||||
self._bluez.close()
|
self._bluez.close()
|
||||||
self._bluez = None
|
self._bluez = None
|
||||||
@@ -80,6 +85,7 @@ class BluetoothController:
|
|||||||
|
|
||||||
def _reset_client(self) -> None:
|
def _reset_client(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._close_elm()
|
||||||
if self._bluez is not None:
|
if self._bluez is not None:
|
||||||
try:
|
try:
|
||||||
self._bluez.close()
|
self._bluez.close()
|
||||||
@@ -87,6 +93,32 @@ class BluetoothController:
|
|||||||
pass
|
pass
|
||||||
self._bluez = None
|
self._bluez = None
|
||||||
|
|
||||||
|
def _close_elm(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
session = self._elm
|
||||||
|
self._elm = None
|
||||||
|
if session is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
session.close()
|
||||||
|
except Exception:
|
||||||
|
cloudlog.warning("ELM327 session close failed")
|
||||||
|
|
||||||
|
def _invalidate_elm(self, session: ELM327Session) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._elm is not session:
|
||||||
|
return
|
||||||
|
self._close_elm()
|
||||||
|
|
||||||
|
def _active_elm(self, address: str) -> ELM327Session:
|
||||||
|
with self._lock:
|
||||||
|
session = self._elm
|
||||||
|
if session is None:
|
||||||
|
raise RuntimeError("ELM327 session is not open")
|
||||||
|
if str(session.address).upper() != address.upper():
|
||||||
|
raise RuntimeError("ELM327 session is open for another device")
|
||||||
|
return session
|
||||||
|
|
||||||
def _offroad(self) -> bool:
|
def _offroad(self) -> bool:
|
||||||
return self.params.get_bool("IsOffroad")
|
return self.params.get_bool("IsOffroad")
|
||||||
|
|
||||||
@@ -183,6 +215,7 @@ class BluetoothController:
|
|||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
|
self._close_elm()
|
||||||
try:
|
try:
|
||||||
client = self._bluez
|
client = self._bluez
|
||||||
if client is not None:
|
if client is not None:
|
||||||
@@ -231,6 +264,9 @@ class BluetoothController:
|
|||||||
self._manual_disconnect_until.pop(normalized_address, None)
|
self._manual_disconnect_until.pop(normalized_address, None)
|
||||||
raise
|
raise
|
||||||
elif command == "forget":
|
elif command == "forget":
|
||||||
|
with self._lock:
|
||||||
|
if self._elm is not None and str(self._elm.address).upper() == address.upper():
|
||||||
|
self._close_elm()
|
||||||
self._client().remove(address)
|
self._client().remove(address)
|
||||||
self._reconnect_backoff.pop(address.upper(), None)
|
self._reconnect_backoff.pop(address.upper(), None)
|
||||||
self._manual_disconnect_until.pop(address.upper(), None)
|
self._manual_disconnect_until.pop(address.upper(), None)
|
||||||
@@ -258,6 +294,52 @@ class BluetoothController:
|
|||||||
self._audio_test_deadline = deadline
|
self._audio_test_deadline = deadline
|
||||||
threading.Thread(target=self._test_audio_worker, args=(address, deadline), daemon=True).start()
|
threading.Thread(target=self._test_audio_worker, args=(address, deadline), daemon=True).start()
|
||||||
return {"audio_test_delay_ms": max(0, round((deadline - time.monotonic()) * 1000))}
|
return {"audio_test_delay_ms": max(0, round((deadline - time.monotonic()) * 1000))}
|
||||||
|
elif command == "elm_open":
|
||||||
|
if not address:
|
||||||
|
raise RuntimeError("Bluetooth device address is required")
|
||||||
|
if not self.params.get_bool("BluetoothEnabled"):
|
||||||
|
raise RuntimeError("Bluetooth is disabled")
|
||||||
|
with self._lock:
|
||||||
|
if self._elm is not None and str(self._elm.address).upper() == address.upper():
|
||||||
|
return {"adapter": self._elm.adapter_name}
|
||||||
|
device = self._client().device_for_address(address)
|
||||||
|
if not device.get("paired"):
|
||||||
|
raise RuntimeError("Pair the Bluetooth device before opening ELM327")
|
||||||
|
if not device.get("serial"):
|
||||||
|
raise RuntimeError("Bluetooth device does not advertise Serial Port Profile")
|
||||||
|
self._close_elm()
|
||||||
|
session = self._elm_factory(address)
|
||||||
|
try:
|
||||||
|
adapter = str(session.open())
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
session.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
session.adapter_name = adapter
|
||||||
|
self._elm = session
|
||||||
|
return {"adapter": adapter}
|
||||||
|
elif command == "elm_close":
|
||||||
|
with self._lock:
|
||||||
|
if self._elm is not None and str(self._elm.address).upper() == address.upper():
|
||||||
|
self._close_elm()
|
||||||
|
elif command == "elm_command":
|
||||||
|
session = self._active_elm(address)
|
||||||
|
try:
|
||||||
|
return {"response": session.command(str(request.get("value", "")))}
|
||||||
|
except Exception as error:
|
||||||
|
if getattr(session, "socket", True) is None or not isinstance(error, ValueError):
|
||||||
|
self._invalidate_elm(session)
|
||||||
|
raise
|
||||||
|
elif command == "elm_read_dtcs":
|
||||||
|
session = self._active_elm(address)
|
||||||
|
try:
|
||||||
|
return session.read_dtcs()
|
||||||
|
except Exception as error:
|
||||||
|
if getattr(session, "socket", True) is None or not isinstance(error, ValueError):
|
||||||
|
self._invalidate_elm(session)
|
||||||
|
raise
|
||||||
elif command == "pairing_response":
|
elif command == "pairing_response":
|
||||||
if not self._client().agent.respond(str(request.get("prompt_id", "")), bool(request.get("accepted", False)), str(request.get("value", ""))):
|
if not self._client().agent.respond(str(request.get("prompt_id", "")), bool(request.get("accepted", False)), str(request.get("value", ""))):
|
||||||
raise RuntimeError("Pairing request is no longer active")
|
raise RuntimeError("Pairing request is no longer active")
|
||||||
@@ -276,10 +358,14 @@ class BluetoothController:
|
|||||||
while True:
|
while True:
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
if not self.params.get_bool("BluetoothEnabled"):
|
if not self.params.get_bool("BluetoothEnabled"):
|
||||||
|
self._close_elm()
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
status = self.status()
|
status = self.status()
|
||||||
|
if self._elm is not None and not status["offroad"]:
|
||||||
|
self._close_elm()
|
||||||
if not status["available"] or not status["powered"]:
|
if not status["available"] or not status["powered"]:
|
||||||
|
self._close_elm()
|
||||||
continue
|
continue
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
self._maintain_scan(status, now)
|
self._maintain_scan(status, now)
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CHANNEL = 1
|
||||||
|
OPEN_TIMEOUT = 10.0
|
||||||
|
DEFAULT_COMMAND_TIMEOUT = 10.0
|
||||||
|
DTC_COMMAND_TIMEOUT = 25.0
|
||||||
|
MAX_COMMAND_LENGTH = 256
|
||||||
|
MAX_RESPONSE_SIZE = 64 * 1024
|
||||||
|
RECV_SIZE = 4096
|
||||||
|
|
||||||
|
|
||||||
|
class DTCParseError(ValueError):
|
||||||
|
def __init__(self, message: str, raw: str):
|
||||||
|
super().__init__(message)
|
||||||
|
self.raw = raw
|
||||||
|
|
||||||
|
|
||||||
|
def decode_dtc(first: int, second: int) -> str:
|
||||||
|
prefixes = "PCBU"
|
||||||
|
prefix = prefixes[(first >> 6) & 0x03]
|
||||||
|
return f"{prefix}{(first >> 4) & 0x03:X}{first & 0x0F:X}{second >> 4:X}{second & 0x0F:X}"
|
||||||
|
|
||||||
|
|
||||||
|
_HEX_BYTE = re.compile(r"(?i)(?<![0-9a-f])([0-9a-f]{2})(?![0-9a-f])")
|
||||||
|
_FORMATTED_LENGTH = re.compile(r"(?i)^[0-9a-f]{3}$")
|
||||||
|
_FORMATTED_FRAME = re.compile(r"(?i)^([0-9a-f]+):\s*(.*)$")
|
||||||
|
_HEX_BYTE_TOKEN = re.compile(r"(?i)^[0-9a-f]{2}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _reassemble_formatted_responses(raw: str) -> list[str]:
|
||||||
|
lines = []
|
||||||
|
expected_length = None
|
||||||
|
expected_index = 0
|
||||||
|
assembled = bytearray()
|
||||||
|
for raw_line in raw.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if expected_length is None:
|
||||||
|
if _FORMATTED_LENGTH.fullmatch(line):
|
||||||
|
expected_length = int(line, 16)
|
||||||
|
if expected_length == 0:
|
||||||
|
raise DTCParseError("Formatted ELM response has an invalid length", raw)
|
||||||
|
expected_index = 0
|
||||||
|
assembled.clear()
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _FORMATTED_LENGTH.fullmatch(line):
|
||||||
|
raise DTCParseError("Formatted ELM response started before the previous block completed", raw)
|
||||||
|
match = _FORMATTED_FRAME.fullmatch(line)
|
||||||
|
if match is None:
|
||||||
|
raise DTCParseError("Formatted ELM response has a malformed continuation", raw)
|
||||||
|
index, byte_text = match.groups()
|
||||||
|
if int(index, 16) != expected_index:
|
||||||
|
raise DTCParseError("Formatted ELM response has a missing continuation", raw)
|
||||||
|
byte_tokens = byte_text.split()
|
||||||
|
if not byte_tokens or any(_HEX_BYTE_TOKEN.fullmatch(token) is None for token in byte_tokens):
|
||||||
|
raise DTCParseError("Formatted ELM response has invalid hex bytes", raw)
|
||||||
|
assembled.extend(int(token, 16) for token in byte_tokens)
|
||||||
|
expected_index += 1
|
||||||
|
if len(assembled) >= expected_length:
|
||||||
|
lines.append(" ".join(f"{value:02X}" for value in assembled[:expected_length]))
|
||||||
|
expected_length = None
|
||||||
|
assembled.clear()
|
||||||
|
|
||||||
|
if expected_length is not None:
|
||||||
|
raise DTCParseError("Formatted ELM response is incomplete", raw)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dtcs(raw: str) -> list[str]:
|
||||||
|
response_lines = [line.strip().upper() for line in raw.splitlines() if line.strip()]
|
||||||
|
if any(line == "NO DATA" for line in response_lines) and all(line == "NO DATA" or line.startswith("SEARCHING") for line in response_lines):
|
||||||
|
return []
|
||||||
|
|
||||||
|
codes = []
|
||||||
|
seen = set()
|
||||||
|
valid_response = False
|
||||||
|
for line in _reassemble_formatted_responses(raw):
|
||||||
|
values = [int(match, 16) for match in _HEX_BYTE.findall(line)]
|
||||||
|
try:
|
||||||
|
response_index = values.index(0x43)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = values[response_index + 1:]
|
||||||
|
if not payload:
|
||||||
|
raise DTCParseError("Mode 03 response has no payload", raw)
|
||||||
|
if len(payload) % 2:
|
||||||
|
count = payload[0]
|
||||||
|
expected_length = count * 2
|
||||||
|
if len(payload) - 1 < expected_length:
|
||||||
|
raise DTCParseError(f"Mode 03 response claims {expected_length} DTC bytes, received {len(payload) - 1}", raw)
|
||||||
|
payload = payload[1:1 + expected_length]
|
||||||
|
|
||||||
|
valid_response = True
|
||||||
|
for index in range(0, len(payload) - 1, 2):
|
||||||
|
first, second = payload[index:index + 2]
|
||||||
|
if first == 0 and second == 0:
|
||||||
|
continue
|
||||||
|
code = decode_dtc(first, second)
|
||||||
|
if code not in seen:
|
||||||
|
seen.add(code)
|
||||||
|
codes.append(code)
|
||||||
|
if not valid_response:
|
||||||
|
raise DTCParseError("No valid Mode 03 response", raw)
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
class ELM327Session:
|
||||||
|
def __init__(self, address: str, channel: int = DEFAULT_CHANNEL):
|
||||||
|
self.address = address
|
||||||
|
self.channel = channel
|
||||||
|
self.socket: socket.socket | None = None
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.adapter_name = ""
|
||||||
|
|
||||||
|
def _close_unlocked(self) -> None:
|
||||||
|
client_socket = self.socket
|
||||||
|
self.socket = None
|
||||||
|
self.adapter_name = ""
|
||||||
|
if client_socket is not None:
|
||||||
|
try:
|
||||||
|
client_socket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self.lock:
|
||||||
|
self._close_unlocked()
|
||||||
|
|
||||||
|
def _receive_until_prompt_unlocked(self, timeout: float) -> bytes:
|
||||||
|
if self.socket is None:
|
||||||
|
raise RuntimeError("ELM327 session is not open")
|
||||||
|
self.socket.settimeout(timeout)
|
||||||
|
response = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = self.socket.recv(RECV_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("ELM327 connection closed")
|
||||||
|
response.extend(chunk)
|
||||||
|
if len(response) > MAX_RESPONSE_SIZE:
|
||||||
|
raise RuntimeError("ELM327 response exceeded 64 KiB")
|
||||||
|
if b">" in response:
|
||||||
|
return bytes(response)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_response(raw: bytes, command: str) -> str:
|
||||||
|
response = raw.split(b">", 1)[0].decode("ascii", errors="replace")
|
||||||
|
response = response.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
lines = response.split("\n")
|
||||||
|
while lines and not lines[0].strip():
|
||||||
|
lines.pop(0)
|
||||||
|
if lines and lines[0].strip() == command:
|
||||||
|
lines.pop(0)
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
def _drain_unlocked(self) -> None:
|
||||||
|
if self.socket is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.socket.settimeout(0.0)
|
||||||
|
while self.socket.recv(RECV_SIZE):
|
||||||
|
pass
|
||||||
|
except (BlockingIOError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _exchange_unlocked(self, command: str, timeout: float) -> str:
|
||||||
|
if self.socket is None:
|
||||||
|
raise RuntimeError("ELM327 session is not open")
|
||||||
|
try:
|
||||||
|
self._drain_unlocked()
|
||||||
|
self.socket.settimeout(timeout)
|
||||||
|
self.socket.sendall(command.encode("ascii") + b"\r")
|
||||||
|
return self._clean_response(self._receive_until_prompt_unlocked(timeout), command)
|
||||||
|
except Exception as error:
|
||||||
|
self._close_unlocked()
|
||||||
|
raise RuntimeError(f"ELM327 transport failed: {error}") from error
|
||||||
|
|
||||||
|
def open(self) -> str:
|
||||||
|
with self.lock:
|
||||||
|
if self.socket is not None:
|
||||||
|
return self.adapter_name
|
||||||
|
try:
|
||||||
|
self.socket = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
|
||||||
|
self.socket.settimeout(OPEN_TIMEOUT)
|
||||||
|
self.socket.connect((self.address, self.channel))
|
||||||
|
adapter_name = self._exchange_unlocked("ATI", OPEN_TIMEOUT)
|
||||||
|
if not adapter_name or adapter_name.strip().upper() in {"?", "ERROR", "COMMAND UNKNOWN", "UNKNOWN COMMAND", "NO DATA"}:
|
||||||
|
raise RuntimeError("ELM327 adapter rejected ATI")
|
||||||
|
self.adapter_name = adapter_name
|
||||||
|
for setup_command in ("ATE0", "ATL0", "ATH0"):
|
||||||
|
self._exchange_unlocked(setup_command, OPEN_TIMEOUT)
|
||||||
|
return self.adapter_name
|
||||||
|
except Exception as error:
|
||||||
|
self._close_unlocked()
|
||||||
|
if isinstance(error, RuntimeError) and str(error).startswith("ELM327 open failed:"):
|
||||||
|
raise
|
||||||
|
raise RuntimeError(f"ELM327 open failed: {error}") from error
|
||||||
|
|
||||||
|
def command(self, command: str, timeout: float = DEFAULT_COMMAND_TIMEOUT) -> str:
|
||||||
|
if not isinstance(command, str):
|
||||||
|
raise ValueError("ELM327 command must be text")
|
||||||
|
if "\r" in command or "\n" in command:
|
||||||
|
raise ValueError("ELM327 command cannot contain carriage returns or newlines")
|
||||||
|
command = command.strip()
|
||||||
|
if not command:
|
||||||
|
raise ValueError("ELM327 command cannot be empty")
|
||||||
|
if len(command) > MAX_COMMAND_LENGTH:
|
||||||
|
raise ValueError("ELM327 command is too long")
|
||||||
|
try:
|
||||||
|
command.encode("ascii")
|
||||||
|
except UnicodeEncodeError as error:
|
||||||
|
raise ValueError("ELM327 command must contain ASCII characters") from error
|
||||||
|
if timeout <= 0:
|
||||||
|
raise ValueError("ELM327 command timeout must be positive")
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
return self._exchange_unlocked(command, timeout)
|
||||||
|
|
||||||
|
def read_dtcs(self) -> dict[str, str | list[str]]:
|
||||||
|
with self.lock:
|
||||||
|
for setup_command in ("ATE0", "ATL0", "ATS1", "ATH0", "ATCAF1"):
|
||||||
|
self.command(setup_command)
|
||||||
|
raw = self.command("03", timeout=DTC_COMMAND_TIMEOUT)
|
||||||
|
return {"codes": parse_dtcs(raw), "raw": raw}
|
||||||
@@ -16,6 +16,7 @@ BLUETOOTH_RADIO_HELPER = "/usr/comma/bluetooth-radio"
|
|||||||
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
|
A2DP_SINK_UUID = "0000110b-0000-1000-8000-00805f9b34fb"
|
||||||
HID_UUID = "00001124-0000-1000-8000-00805f9b34fb"
|
HID_UUID = "00001124-0000-1000-8000-00805f9b34fb"
|
||||||
HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb"
|
HOG_UUID = "00001812-0000-1000-8000-00805f9b34fb"
|
||||||
|
SPP_UUID = "00001101-0000-1000-8000-00805f9b34fb"
|
||||||
COMMAND_TIMEOUTS = {
|
COMMAND_TIMEOUTS = {
|
||||||
"set_power": 90.0,
|
"set_power": 90.0,
|
||||||
"start_scan": 20.0,
|
"start_scan": 20.0,
|
||||||
@@ -24,6 +25,10 @@ COMMAND_TIMEOUTS = {
|
|||||||
"disconnect": 20.0,
|
"disconnect": 20.0,
|
||||||
"forget": 20.0,
|
"forget": 20.0,
|
||||||
"test_audio": 10.0,
|
"test_audio": 10.0,
|
||||||
|
"elm_open": 15.0,
|
||||||
|
"elm_close": 5.0,
|
||||||
|
"elm_command": 20.0,
|
||||||
|
"elm_read_dtcs": 30.0,
|
||||||
}
|
}
|
||||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
@@ -40,6 +45,7 @@ class BluetoothDevice:
|
|||||||
uuids: tuple[str, ...] = ()
|
uuids: tuple[str, ...] = ()
|
||||||
audio: bool = False
|
audio: bool = False
|
||||||
controller: bool = False
|
controller: bool = False
|
||||||
|
serial: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, value: dict[str, Any]) -> "BluetoothDevice":
|
def from_dict(cls, value: dict[str, Any]) -> "BluetoothDevice":
|
||||||
@@ -54,6 +60,7 @@ class BluetoothDevice:
|
|||||||
uuids=tuple(str(uuid).lower() for uuid in value.get("uuids", ())),
|
uuids=tuple(str(uuid).lower() for uuid in value.get("uuids", ())),
|
||||||
audio=bool(value.get("audio", False)),
|
audio=bool(value.get("audio", False)),
|
||||||
controller=bool(value.get("controller", False)),
|
controller=bool(value.get("controller", False)),
|
||||||
|
serial=bool(value.get("serial", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,21 +93,22 @@ class BluetoothStatus:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool]:
|
def device_capabilities(uuids: list[str] | tuple[str, ...], bluetooth_class: int = 0, icon: str = "") -> tuple[bool, bool, bool]:
|
||||||
normalized = {str(uuid).lower() for uuid in uuids}
|
normalized = {str(uuid).lower() for uuid in uuids}
|
||||||
major_class = (int(bluetooth_class) >> 8) & 0x1F
|
major_class = (int(bluetooth_class) >> 8) & 0x1F
|
||||||
audio = A2DP_SINK_UUID in normalized or major_class == 0x04 or icon in {"audio-card", "audio-headphones", "audio-headset"}
|
audio = A2DP_SINK_UUID in normalized or major_class == 0x04 or icon in {"audio-card", "audio-headphones", "audio-headset"}
|
||||||
controller = HID_UUID in normalized or HOG_UUID in normalized or major_class == 0x05 or icon in {"input-gaming", "input-mouse", "input-keyboard"}
|
controller = HID_UUID in normalized or HOG_UUID in normalized or major_class == 0x05 or icon in {"input-gaming", "input-mouse", "input-keyboard"}
|
||||||
return audio, controller
|
serial = SPP_UUID in normalized
|
||||||
|
return audio, controller, serial
|
||||||
|
|
||||||
|
|
||||||
def show_pairing_device(address: str, name: str, paired: bool, trusted: bool, connected: bool, blocked: bool,
|
def show_pairing_device(address: str, name: str, paired: bool, trusted: bool, connected: bool, blocked: bool,
|
||||||
audio: bool, controller: bool, discovering: bool = False) -> bool:
|
audio: bool, controller: bool, serial: bool = False, discovering: bool = False) -> bool:
|
||||||
known = paired or trusted or connected
|
known = paired or trusted or connected
|
||||||
normalized_address = "".join(character for character in address.upper() if character.isalnum())
|
normalized_address = "".join(character for character in address.upper() if character.isalnum())
|
||||||
normalized_name = "".join(character for character in name.upper() if character.isalnum())
|
normalized_name = "".join(character for character in name.upper() if character.isalnum())
|
||||||
named = bool(name) and name != "Unknown device" and normalized_name != normalized_address
|
named = bool(name) and name != "Unknown device" and normalized_name != normalized_address
|
||||||
return known or (named and not blocked and (audio or controller))
|
return known or (named and not blocked and (audio or controller or serial or discovering))
|
||||||
|
|
||||||
|
|
||||||
class _DesktopFakeBluetooth:
|
class _DesktopFakeBluetooth:
|
||||||
@@ -304,5 +312,17 @@ class BluetoothClient:
|
|||||||
result = self.call("test_audio", address=address)
|
result = self.call("test_audio", address=address)
|
||||||
return max(0.0, float(result.get("audio_test_delay_ms", 0)) / 1000.0)
|
return max(0.0, float(result.get("audio_test_delay_ms", 0)) / 1000.0)
|
||||||
|
|
||||||
|
def elm_open(self, address: str) -> dict[str, Any]:
|
||||||
|
return self.call("elm_open", address=address)
|
||||||
|
|
||||||
|
def elm_close(self, address: str) -> dict[str, Any]:
|
||||||
|
return self.call("elm_close", address=address)
|
||||||
|
|
||||||
|
def elm_command(self, address: str, value: str) -> dict[str, Any]:
|
||||||
|
return self.call("elm_command", address=address, value=value)
|
||||||
|
|
||||||
|
def elm_read_dtcs(self, address: str) -> dict[str, Any]:
|
||||||
|
return self.call("elm_read_dtcs", address=address)
|
||||||
|
|
||||||
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
|
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
|
||||||
self.call("pairing_response", prompt_id=prompt_id, accepted=accepted, value=value)
|
self.call("pairing_response", prompt_id=prompt_id, accepted=accepted, value=value)
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink
|
from openpilot.starpilot.system.bluetooth.audio import BluetoothAudioSink
|
||||||
|
import openpilot.starpilot.system.bluetooth.daemon as bluetooth_daemon
|
||||||
from openpilot.starpilot.system.bluetooth.bluez import PairingAgent
|
from openpilot.starpilot.system.bluetooth.bluez import PairingAgent
|
||||||
from openpilot.starpilot.system.bluetooth.daemon import BluetoothController
|
from openpilot.starpilot.system.bluetooth.daemon import BluetoothController
|
||||||
|
from openpilot.starpilot.system.bluetooth.elm327 import DTCParseError
|
||||||
from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus,
|
from openpilot.starpilot.system.bluetooth.protocol import (A2DP_SINK_UUID, HID_UUID, BluetoothClient, BluetoothDevice, BluetoothStatus,
|
||||||
device_capabilities, show_pairing_device)
|
SPP_UUID, device_capabilities, show_pairing_device)
|
||||||
from openpilot.system import hardware
|
from openpilot.system import hardware
|
||||||
from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager
|
from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ class FakeBlueZ:
|
|||||||
"connected": False,
|
"connected": False,
|
||||||
"audio": True,
|
"audio": True,
|
||||||
"controller": False,
|
"controller": False,
|
||||||
|
"serial": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
@@ -163,9 +166,40 @@ class FakeProcess:
|
|||||||
self.stopped = True
|
self.stopped = True
|
||||||
|
|
||||||
|
|
||||||
|
class FakeELM:
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(self, address):
|
||||||
|
self.address = address
|
||||||
|
self.adapter_name = "Fake ELM327"
|
||||||
|
self.closed = False
|
||||||
|
self.commands = []
|
||||||
|
self.opened = False
|
||||||
|
FakeELM.instances.append(self)
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
self.opened = True
|
||||||
|
return self.adapter_name
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def command(self, value):
|
||||||
|
self.commands.append(value)
|
||||||
|
if value == "fail":
|
||||||
|
raise RuntimeError("transport failed")
|
||||||
|
if value == "bad_input":
|
||||||
|
raise ValueError("invalid command")
|
||||||
|
return f"response for {value}"
|
||||||
|
|
||||||
|
def read_dtcs(self):
|
||||||
|
return {"codes": ["P0133"], "raw": "43 01 33"}
|
||||||
|
|
||||||
|
|
||||||
def test_protocol_round_trip_and_capabilities():
|
def test_protocol_round_trip_and_capabilities():
|
||||||
audio, controller = device_capabilities([A2DP_SINK_UUID, HID_UUID])
|
audio, controller, serial = device_capabilities([A2DP_SINK_UUID, HID_UUID])
|
||||||
assert audio and controller
|
assert audio and controller
|
||||||
|
assert not serial
|
||||||
status = BluetoothStatus.from_dict({
|
status = BluetoothStatus.from_dict({
|
||||||
"available": True,
|
"available": True,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -174,12 +208,167 @@ def test_protocol_round_trip_and_capabilities():
|
|||||||
assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True),)
|
assert status.devices == (BluetoothDevice("00:11:22:33:44:55", "Combo", uuids=(A2DP_SINK_UUID, HID_UUID), audio=True, controller=True),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serial_capability_round_trips_and_is_discoverable():
|
||||||
|
audio, controller, serial = device_capabilities([SPP_UUID.upper()])
|
||||||
|
assert not audio and not controller and serial
|
||||||
|
|
||||||
|
status = BluetoothStatus.from_dict({
|
||||||
|
"devices": [{"address": "00:11:22:33:44:55", "name": "OBDII", "serial": True}],
|
||||||
|
})
|
||||||
|
assert status.devices[0].serial
|
||||||
|
assert show_pairing_device("00:11:22:33:44:55", "OBDII", False, False, False, False,
|
||||||
|
audio=False, controller=False, serial=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serial_device_is_not_auto_reconnected_but_audio_device_is(monkeypatch):
|
||||||
|
class StopMaintenance(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
|
||||||
|
client = FakeBlueZ()
|
||||||
|
client.powered = True
|
||||||
|
client.device.update(paired=True, trusted=True, connected=False, audio=False, controller=False, serial=True)
|
||||||
|
sleeps = 0
|
||||||
|
|
||||||
|
def sleep(_delay):
|
||||||
|
nonlocal sleeps
|
||||||
|
sleeps += 1
|
||||||
|
if sleeps > 1:
|
||||||
|
raise StopMaintenance
|
||||||
|
|
||||||
|
monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
|
||||||
|
controller = BluetoothController(params, lambda: client, FakeRadio(), sleep=sleep, elm_factory=FakeELM)
|
||||||
|
controller._bluez = client
|
||||||
|
controller._last_reconnect = -100.0
|
||||||
|
with pytest.raises(StopMaintenance):
|
||||||
|
controller.maintain_connections()
|
||||||
|
assert client.actions == []
|
||||||
|
|
||||||
|
client.device.update(audio=True, serial=False)
|
||||||
|
sleeps = 0
|
||||||
|
controller._last_reconnect = -100.0
|
||||||
|
with pytest.raises(StopMaintenance):
|
||||||
|
controller.maintain_connections()
|
||||||
|
assert client.actions == [("connect", client.device["address"])]
|
||||||
|
|
||||||
|
|
||||||
|
def make_elm_controller(elm_factory=FakeELM):
|
||||||
|
params = FakeParams(IsOffroad=True, BluetoothEnabled=True)
|
||||||
|
client = FakeBlueZ()
|
||||||
|
client.device.update(paired=True, trusted=True, serial=True)
|
||||||
|
controller = BluetoothController(params, lambda: client, FakeRadio(), elm_factory=elm_factory)
|
||||||
|
return controller, client, params
|
||||||
|
|
||||||
|
|
||||||
|
def test_elm_is_lazy_and_requires_a_paired_serial_device():
|
||||||
|
FakeELM.instances = []
|
||||||
|
controller, client, params = make_elm_controller()
|
||||||
|
assert FakeELM.instances == []
|
||||||
|
controller.status()
|
||||||
|
assert FakeELM.instances == []
|
||||||
|
|
||||||
|
result = controller.handle({"command": "elm_open", "address": client.device["address"]})
|
||||||
|
assert result == {"adapter": "Fake ELM327"}
|
||||||
|
assert len(FakeELM.instances) == 1
|
||||||
|
|
||||||
|
params.values["IsOffroad"] = False
|
||||||
|
with pytest.raises(RuntimeError, match="offroad"):
|
||||||
|
controller.handle({"command": "elm_command", "address": client.device["address"], "value": "ATI"})
|
||||||
|
|
||||||
|
params.values["IsOffroad"] = True
|
||||||
|
client.device["paired"] = False
|
||||||
|
controller.handle({"command": "elm_close", "address": client.device["address"]})
|
||||||
|
with pytest.raises(RuntimeError, match="Pair"):
|
||||||
|
controller.handle({"command": "elm_open", "address": client.device["address"]})
|
||||||
|
|
||||||
|
client.device.update(paired=True, serial=False)
|
||||||
|
with pytest.raises(RuntimeError, match="Serial Port Profile"):
|
||||||
|
controller.handle({"command": "elm_open", "address": client.device["address"]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_elm_commands_use_one_session_and_close_on_transport_failure(monkeypatch):
|
||||||
|
FakeELM.instances = []
|
||||||
|
controller, client, _ = make_elm_controller()
|
||||||
|
address = client.device["address"]
|
||||||
|
controller.handle({"command": "elm_open", "address": address})
|
||||||
|
assert controller.handle({"command": "elm_open", "address": address}) == {"adapter": "Fake ELM327"}
|
||||||
|
assert controller.handle({"command": "elm_command", "address": address, "value": "ATI"}) == {"response": "response for ATI"}
|
||||||
|
assert controller.handle({"command": "elm_read_dtcs", "address": address}) == {"codes": ["P0133"], "raw": "43 01 33"}
|
||||||
|
with pytest.raises(ValueError, match="invalid command"):
|
||||||
|
controller.handle({"command": "elm_command", "address": address, "value": "bad_input"})
|
||||||
|
|
||||||
|
def fail_dtcs():
|
||||||
|
raise DTCParseError("No valid Mode 03 response", "garbage")
|
||||||
|
|
||||||
|
monkeypatch.setattr(FakeELM.instances[0], "read_dtcs", fail_dtcs)
|
||||||
|
with pytest.raises(DTCParseError):
|
||||||
|
controller.handle({"command": "elm_read_dtcs", "address": address})
|
||||||
|
assert controller._elm is not None and not FakeELM.instances[0].closed
|
||||||
|
with pytest.raises(RuntimeError, match="transport"):
|
||||||
|
controller.handle({"command": "elm_command", "address": address, "value": "fail"})
|
||||||
|
assert controller._elm is None
|
||||||
|
assert FakeELM.instances[0].closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_elm_open_replaces_a_different_session_and_close_is_allowed_onroad():
|
||||||
|
FakeELM.instances = []
|
||||||
|
controller, client, params = make_elm_controller()
|
||||||
|
first = client.device["address"]
|
||||||
|
second = "AA:BB:CC:DD:EE:FF"
|
||||||
|
controller.handle({"command": "elm_open", "address": first})
|
||||||
|
controller.handle({"command": "elm_open", "address": second})
|
||||||
|
assert len(FakeELM.instances) == 2
|
||||||
|
assert FakeELM.instances[0].closed
|
||||||
|
assert not FakeELM.instances[1].closed
|
||||||
|
|
||||||
|
params.values["IsOffroad"] = False
|
||||||
|
controller.handle({"command": "elm_close", "address": second})
|
||||||
|
assert FakeELM.instances[1].closed and controller._elm is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_elm_cleanup_happens_before_poweroff_forget_shutdown_and_onroad(monkeypatch):
|
||||||
|
class StopMaintenance(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
for cleanup in ("power", "forget", "shutdown", "onroad"):
|
||||||
|
FakeELM.instances = []
|
||||||
|
controller, client, params = make_elm_controller()
|
||||||
|
address = client.device["address"]
|
||||||
|
controller.handle({"command": "elm_open", "address": address})
|
||||||
|
session = FakeELM.instances[0]
|
||||||
|
|
||||||
|
if cleanup == "power":
|
||||||
|
controller.handle({"command": "set_power", "enabled": False})
|
||||||
|
elif cleanup == "forget":
|
||||||
|
controller.handle({"command": "forget", "address": address})
|
||||||
|
elif cleanup == "shutdown":
|
||||||
|
controller.close()
|
||||||
|
else:
|
||||||
|
params.values["IsOffroad"] = False
|
||||||
|
sleeps = 0
|
||||||
|
|
||||||
|
def sleep(_delay):
|
||||||
|
nonlocal sleeps
|
||||||
|
sleeps += 1
|
||||||
|
if sleeps > 1:
|
||||||
|
raise StopMaintenance
|
||||||
|
|
||||||
|
monkeypatch.setattr(bluetooth_daemon.time, "sleep", sleep)
|
||||||
|
controller._sleep = sleep
|
||||||
|
with pytest.raises(StopMaintenance):
|
||||||
|
controller.maintain_connections()
|
||||||
|
|
||||||
|
assert session.closed and controller._elm is None
|
||||||
|
|
||||||
|
|
||||||
def test_pairing_list_filters_anonymous_and_irrelevant_advertisements():
|
def test_pairing_list_filters_anonymous_and_irrelevant_advertisements():
|
||||||
assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False)
|
assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False)
|
||||||
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False)
|
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False)
|
||||||
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True)
|
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True)
|
||||||
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, True)
|
assert show_pairing_device("00:11:22:33:44:55", "Media Remote", False, False, False, False, False, True, discovering=True)
|
||||||
assert not show_pairing_device("00:11:22:33:44:55", "Nearby sensor", False, False, False, False, False, False, True)
|
assert show_pairing_device("00:11:22:33:44:55", "OBDII", False, False, False, False, False, False, False, discovering=True)
|
||||||
|
assert not show_pairing_device("00:11:22:33:44:55", "00:11:22:33:44:55", False, False, False, False, False, False, False, discovering=True)
|
||||||
|
assert not show_pairing_device("00:11:22:33:44:55", "OBDII", False, False, False, True, False, False, False, discovering=True)
|
||||||
assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False)
|
assert show_pairing_device("00:11:22:33:44:55", "Known device", True, True, False, False, False, False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
from collections import deque
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from openpilot.starpilot.system.bluetooth import elm327
|
||||||
|
|
||||||
|
|
||||||
|
ADDRESS = "00:11:22:33:44:55"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSocket:
|
||||||
|
def __init__(self, responses):
|
||||||
|
self.responses = deque(deque(response) for response in responses)
|
||||||
|
self.pending = deque()
|
||||||
|
self.sent = []
|
||||||
|
self.connected_to = None
|
||||||
|
self.timeouts = []
|
||||||
|
self.closed = False
|
||||||
|
self.close_calls = 0
|
||||||
|
self.recv_error = None
|
||||||
|
self.send_error = None
|
||||||
|
|
||||||
|
def settimeout(self, timeout):
|
||||||
|
self.timeouts.append(timeout)
|
||||||
|
|
||||||
|
def connect(self, address):
|
||||||
|
self.connected_to = address
|
||||||
|
|
||||||
|
def sendall(self, value):
|
||||||
|
if self.send_error is not None:
|
||||||
|
raise self.send_error
|
||||||
|
self.sent.append(value)
|
||||||
|
self.pending.extend(self.responses.popleft() if self.responses else deque())
|
||||||
|
|
||||||
|
def recv(self, _size):
|
||||||
|
if self.recv_error is not None:
|
||||||
|
raise self.recv_error
|
||||||
|
return self.pending.popleft() if self.pending else b""
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.close_calls += 1
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def startup_responses(identity=b"ELM327 v1.5"):
|
||||||
|
return [
|
||||||
|
[b"ATI\r\n", identity + b"\r\n>"],
|
||||||
|
[b"ATE0\r\nOK\r\n>"],
|
||||||
|
[b"ATL0\r\nOK\r\n>"],
|
||||||
|
[b"ATH0\r\nOK\r\n>"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_session(monkeypatch, responses):
|
||||||
|
fake = FakeSocket(responses)
|
||||||
|
monkeypatch.setattr(elm327.socket, "AF_BLUETOOTH", 31, raising=False)
|
||||||
|
monkeypatch.setattr(elm327.socket, "BTPROTO_RFCOMM", 3, raising=False)
|
||||||
|
monkeypatch.setattr(elm327.socket, "socket", lambda *args: fake)
|
||||||
|
return elm327.ELM327Session(ADDRESS), fake
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_uses_rfccomm_channel_one_and_validates_ati(monkeypatch):
|
||||||
|
session, fake = make_session(monkeypatch, startup_responses())
|
||||||
|
|
||||||
|
assert session.open() == "ELM327 v1.5"
|
||||||
|
assert fake.connected_to == (ADDRESS, 1)
|
||||||
|
assert fake.sent == [b"ATI\r", b"ATE0\r", b"ATL0\r", b"ATH0\r"]
|
||||||
|
assert session.adapter_name == "ELM327 v1.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_drains_stale_boot_prompt_and_late_packets(monkeypatch):
|
||||||
|
session, fake = make_session(monkeypatch, startup_responses() + [[b"ATRV\r\n12.5V\r\n>"]])
|
||||||
|
fake.pending = deque([b"\r\nELM327 v1.5\r\n>"])
|
||||||
|
assert session.open() == "ELM327 v1.5"
|
||||||
|
assert fake.sent[:4] == [b"ATI\r", b"ATE0\r", b"ATL0\r", b"ATH0\r"]
|
||||||
|
fake.pending = deque([b"UNSOLICITED NOISE\r\n"])
|
||||||
|
assert session.command("ATRV") == "12.5V"
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_rejects_empty_or_obviously_rejected_ati(monkeypatch):
|
||||||
|
for identity in (b"", b"?", b"ERROR"):
|
||||||
|
session, fake = make_session(monkeypatch, startup_responses(identity))
|
||||||
|
with pytest.raises(RuntimeError, match="ATI"):
|
||||||
|
session.open()
|
||||||
|
assert session.socket is None and fake.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_removes_exact_echo_and_reads_split_prompt_response(monkeypatch):
|
||||||
|
responses = startup_responses() + [[b"ATR", b"V\r\n12.4V\r", b"\n>"]]
|
||||||
|
session, fake = make_session(monkeypatch, responses)
|
||||||
|
session.open()
|
||||||
|
|
||||||
|
assert session.command(" ATRV ") == "12.4V"
|
||||||
|
assert fake.sent[-1] == b"ATRV\r"
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_response_bytes_decode_with_replacement(monkeypatch):
|
||||||
|
responses = startup_responses() + [[b"ATI\r\n\xffOK\r\n>"]]
|
||||||
|
session, _ = make_session(monkeypatch, responses)
|
||||||
|
session.open()
|
||||||
|
|
||||||
|
assert session.command("ATI") == "�OK"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("command", ["", " ", "ATI\r", "ATI\n", "AT\r\nI", "A" * (elm327.MAX_COMMAND_LENGTH + 1)])
|
||||||
|
def test_command_rejects_invalid_input(monkeypatch, command):
|
||||||
|
session, _ = make_session(monkeypatch, [])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
session.command(command)
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_rejects_non_ascii_input(monkeypatch):
|
||||||
|
session, _ = make_session(monkeypatch, [])
|
||||||
|
with pytest.raises(ValueError, match="ASCII"):
|
||||||
|
session.command("ATé")
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_size_limit_closes_session(monkeypatch):
|
||||||
|
responses = startup_responses() + [[b"x" * (elm327.MAX_RESPONSE_SIZE + 1)]]
|
||||||
|
session, fake = make_session(monkeypatch, responses)
|
||||||
|
session.open()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="64 KiB"):
|
||||||
|
session.command("ATI")
|
||||||
|
assert session.socket is None and fake.closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("error", [TimeoutError("timed out"), OSError("disconnected")])
|
||||||
|
def test_timeout_or_eof_closes_session(monkeypatch, error):
|
||||||
|
responses = startup_responses() + [[]]
|
||||||
|
session, fake = make_session(monkeypatch, responses)
|
||||||
|
session.open()
|
||||||
|
fake.recv_error = error if isinstance(error, TimeoutError) else None
|
||||||
|
|
||||||
|
if isinstance(error, TimeoutError):
|
||||||
|
with pytest.raises(RuntimeError, match="transport"):
|
||||||
|
session.command("ATI")
|
||||||
|
else:
|
||||||
|
with pytest.raises(RuntimeError, match="connection closed"):
|
||||||
|
session.command("ATI")
|
||||||
|
assert session.socket is None and fake.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_failure_closes_session(monkeypatch):
|
||||||
|
responses = startup_responses() + [[b"OK>"]]
|
||||||
|
session, fake = make_session(monkeypatch, responses)
|
||||||
|
session.open()
|
||||||
|
fake.send_error = OSError("send failed")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="transport"):
|
||||||
|
session.command("ATI")
|
||||||
|
assert session.socket is None and fake.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_is_idempotent(monkeypatch):
|
||||||
|
session, fake = make_session(monkeypatch, startup_responses())
|
||||||
|
session.open()
|
||||||
|
session.close()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
assert fake.close_calls == 1
|
||||||
|
assert session.socket is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_simultaneous_commands_are_serialized(monkeypatch):
|
||||||
|
class SerializedSocket(FakeSocket):
|
||||||
|
def __init__(self, responses):
|
||||||
|
super().__init__(responses)
|
||||||
|
self.command_started = threading.Event()
|
||||||
|
self.release_command = threading.Event()
|
||||||
|
self._command_sends = 0
|
||||||
|
|
||||||
|
def sendall(self, value):
|
||||||
|
super().sendall(value)
|
||||||
|
self._command_sends += 1
|
||||||
|
if self._command_sends == 5:
|
||||||
|
self.command_started.set()
|
||||||
|
assert self.release_command.wait(timeout=1.0)
|
||||||
|
|
||||||
|
fake = SerializedSocket(startup_responses() + [[b"VALUE1>"], [b"VALUE2>"]])
|
||||||
|
monkeypatch.setattr(elm327.socket, "AF_BLUETOOTH", 31, raising=False)
|
||||||
|
monkeypatch.setattr(elm327.socket, "BTPROTO_RFCOMM", 3, raising=False)
|
||||||
|
monkeypatch.setattr(elm327.socket, "socket", lambda *args: fake)
|
||||||
|
session = elm327.ELM327Session(ADDRESS)
|
||||||
|
session.open()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
first = threading.Thread(target=lambda: results.append(session.command("ONE")))
|
||||||
|
second = threading.Thread(target=lambda: results.append(session.command("TWO")))
|
||||||
|
first.start()
|
||||||
|
assert fake.command_started.wait(timeout=1.0)
|
||||||
|
second.start()
|
||||||
|
assert len(fake.sent) == 5
|
||||||
|
fake.release_command.set()
|
||||||
|
first.join(timeout=1.0)
|
||||||
|
second.join(timeout=1.0)
|
||||||
|
|
||||||
|
assert sorted(results) == ["VALUE1", "VALUE2"]
|
||||||
|
assert len(fake.sent) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_dtcs_runs_known_setup_and_returns_mode_three_result(monkeypatch):
|
||||||
|
responses = startup_responses() + [[b"OK>"] for _ in range(5)] + [[b"43 01 33 04 20 00 00>"]]
|
||||||
|
session, fake = make_session(monkeypatch, responses)
|
||||||
|
session.open()
|
||||||
|
|
||||||
|
assert session.read_dtcs() == {"codes": ["P0133", "P0420"], "raw": "43 01 33 04 20 00 00"}
|
||||||
|
assert fake.sent[-6:] == [b"ATE0\r", b"ATL0\r", b"ATS1\r", b"ATH0\r", b"ATCAF1\r", b"03\r"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw_command", ["ATS0", "ATCAF0"])
|
||||||
|
def test_read_dtcs_restores_parser_state_after_raw_command(monkeypatch, raw_command):
|
||||||
|
responses = startup_responses() + [[b"OK>"]] + [[b"OK>"] for _ in range(5)] + [[b"43 01 33 00 00 00 00>"]]
|
||||||
|
session, fake = make_session(monkeypatch, responses)
|
||||||
|
session.open()
|
||||||
|
session.command(raw_command)
|
||||||
|
|
||||||
|
assert session.read_dtcs() == {"codes": ["P0133"], "raw": "43 01 33 00 00 00 00"}
|
||||||
|
assert fake.sent[-6:] == [b"ATE0\r", b"ATL0\r", b"ATS1\r", b"ATH0\r", b"ATCAF1\r", b"03\r"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_can_dtc_is_decoded_and_padding_ignored():
|
||||||
|
assert elm327.parse_dtcs("43 01 33 00 00 00 00") == ["P0133"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_dtc_count_byte_is_skipped():
|
||||||
|
assert elm327.parse_dtcs("43 02 01 33 04 20") == ["P0133", "P0420"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_returns_no_codes():
|
||||||
|
assert elm327.parse_dtcs("NO DATA") == []
|
||||||
|
assert elm327.parse_dtcs("SEARCHING...\nNO DATA") == []
|
||||||
|
assert elm327.parse_dtcs("SEARCHING...\r\nNO DATA") == []
|
||||||
|
assert elm327.parse_dtcs("SEARCHING...\n43 01 33 00 00") == ["P0133"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_dtc_multiframe_response_is_reassembled():
|
||||||
|
raw = "008\n0: 43 03 00 59 01 54\n1: 01 55"
|
||||||
|
assert elm327.parse_dtcs(raw) == ["P0059", "P0154", "P0155"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_larger_numbered_multiframe_response_is_reassembled():
|
||||||
|
raw = "00E\n0: 43 06 01 33 04 20\n1: 00 59 01 54\n2: 01 55 01 56"
|
||||||
|
assert elm327.parse_dtcs(raw) == ["P0133", "P0420", "P0059", "P0154", "P0155", "P0156"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", [
|
||||||
|
"008\n0: 43 03 00 59 01 54",
|
||||||
|
"008\n1: 43 03 00 59 01 54\n1: 01 55",
|
||||||
|
"008\n0: 43 03 00 59 GG 54\n1: 01 55",
|
||||||
|
"008\n0: 43 03 00 59 01 54\n00E",
|
||||||
|
])
|
||||||
|
def test_invalid_numbered_multiframe_response_raises(raw):
|
||||||
|
with pytest.raises(elm327.DTCParseError):
|
||||||
|
elm327.parse_dtcs(raw)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["UNABLE TO CONNECT", "STOPPED", "?", "BUS ERROR", "7F 03 11", "43"])
|
||||||
|
def test_non_mode_three_response_raises(raw):
|
||||||
|
with pytest.raises(elm327.DTCParseError):
|
||||||
|
elm327.parse_dtcs(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_ecu_lines_are_ordered_and_deduplicated():
|
||||||
|
raw = "43 01 33 00 00\n43 04 20 00 00\n43 01 33 00 00"
|
||||||
|
assert elm327.parse_dtcs(raw) == ["P0133", "P0420"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_can_count_raises_with_raw_response():
|
||||||
|
raw = "43 02 01 33"
|
||||||
|
with pytest.raises(elm327.DTCParseError) as error:
|
||||||
|
elm327.parse_dtcs(raw)
|
||||||
|
assert error.value.raw == raw
|
||||||
@@ -398,6 +398,116 @@
|
|||||||
animation-delay: 0.28s;
|
animation-delay: 0.28s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bluetoothElmPanel {
|
||||||
|
background: var(--secondary-bg);
|
||||||
|
border: 1px solid rgba(169, 140, 229, 0.45);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmHeader,
|
||||||
|
.bluetoothElmActions,
|
||||||
|
.bluetoothElmCommand > div {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmHeader {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmHeader h3,
|
||||||
|
.bluetoothElmHeader p,
|
||||||
|
.bluetoothElmCodes p,
|
||||||
|
.bluetoothElmResponse pre,
|
||||||
|
.bluetoothElmCommand label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmHeader p,
|
||||||
|
.bluetoothElmCodes p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmHeader strong {
|
||||||
|
color: #cbb2fa;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmActions {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmActions button,
|
||||||
|
.bluetoothElmCommand button {
|
||||||
|
background: linear-gradient(135deg, #765bb6, #9474ce);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmActions .bluetoothSecondaryButton {
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--sidebar-border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmActions button:disabled,
|
||||||
|
.bluetoothElmCommand button:disabled,
|
||||||
|
.bluetoothElmCommand input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmCodes,
|
||||||
|
.bluetoothElmCommand,
|
||||||
|
.bluetoothElmResponse {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmCommand label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: block;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmCommand > div {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmCommand input {
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--sidebar-border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
color: var(--text-color);
|
||||||
|
flex: 1;
|
||||||
|
font: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bluetoothElmResponse pre {
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--sidebar-border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-top: 6px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes bluetoothSpin {
|
@keyframes bluetoothSpin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ const state = reactive({
|
|||||||
prompt: null,
|
prompt: null,
|
||||||
audioTestAddress: "",
|
audioTestAddress: "",
|
||||||
audioTestLabel: "",
|
audioTestLabel: "",
|
||||||
|
elmAddress: "",
|
||||||
|
elmName: "",
|
||||||
|
elmAdapter: "",
|
||||||
|
elmResponse: "",
|
||||||
|
elmCodes: null,
|
||||||
error: "",
|
error: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,7 +51,9 @@ function schedulePoll(delay = pollDelay()) {
|
|||||||
pollTimer = setTimeout(async () => {
|
pollTimer = setTimeout(async () => {
|
||||||
pollTimer = null
|
pollTimer = null
|
||||||
try {
|
try {
|
||||||
if (bluetoothPageActive() && document.visibilityState !== "hidden" && state.busy !== "power") {
|
if (state.elmAddress && (document.visibilityState === "hidden" || !bluetoothPageActive())) {
|
||||||
|
closeElm()
|
||||||
|
} else if (bluetoothPageActive() && document.visibilityState !== "hidden" && state.busy !== "power") {
|
||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,8 +104,10 @@ async function request(operation, body = {}) {
|
|||||||
}
|
}
|
||||||
state.error = ""
|
state.error = ""
|
||||||
await refresh()
|
await refresh()
|
||||||
|
return payload
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.error = error?.message || "Bluetooth operation failed"
|
state.error = error?.message || "Bluetooth operation failed"
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
state.busy = ""
|
state.busy = ""
|
||||||
if (operation === "power") state.powerTarget = null
|
if (operation === "power") state.powerTarget = null
|
||||||
@@ -106,6 +115,54 @@ async function request(operation, body = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearElmState() {
|
||||||
|
state.elmAddress = ""
|
||||||
|
state.elmName = ""
|
||||||
|
state.elmAdapter = ""
|
||||||
|
state.elmResponse = ""
|
||||||
|
state.elmCodes = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeElm() {
|
||||||
|
const address = state.elmAddress
|
||||||
|
if (!address) return
|
||||||
|
clearElmState()
|
||||||
|
request("elm_close", { address })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openElm(address) {
|
||||||
|
const device = state.devices.find((item) => normalizedAddress(item) === String(address || "").toUpperCase())
|
||||||
|
const payload = await request("elm_open", { address })
|
||||||
|
if (!payload || !device) return
|
||||||
|
state.elmAddress = address
|
||||||
|
state.elmName = device.name || address
|
||||||
|
state.elmAdapter = String(payload.adapter || "")
|
||||||
|
state.elmResponse = ""
|
||||||
|
state.elmCodes = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readElmCodes() {
|
||||||
|
const address = state.elmAddress
|
||||||
|
if (!address) return
|
||||||
|
const payload = await request("elm_read_dtcs", { address })
|
||||||
|
if (!payload || state.elmAddress !== address) return
|
||||||
|
state.elmCodes = Array.isArray(payload.codes) ? payload.codes.map(String) : []
|
||||||
|
state.elmResponse = String(payload.raw || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendElmCommand() {
|
||||||
|
const address = state.elmAddress
|
||||||
|
const input = document.getElementById("bluetoothElmCommand")
|
||||||
|
const command = input?.value.trim() || ""
|
||||||
|
if (!address || !command) {
|
||||||
|
state.error = "Enter an ELM327 command."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = await request("elm_command", { address, value: command })
|
||||||
|
if (!payload || state.elmAddress !== address) return
|
||||||
|
state.elmResponse = `${command}\n${String(payload.response || "")}`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshOnce() {
|
async function refreshOnce() {
|
||||||
const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}`
|
const statusUrl = `${galaxyPath("/api/bluetooth/status")}?_=${Date.now()}`
|
||||||
const response = await fetch(statusUrl, { cache: "no-store" })
|
const response = await fetch(statusUrl, { cache: "no-store" })
|
||||||
@@ -126,6 +183,10 @@ async function refreshOnce() {
|
|||||||
devices,
|
devices,
|
||||||
})
|
})
|
||||||
state.devices = devices
|
state.devices = devices
|
||||||
|
if (state.elmAddress && (!state.enabled || !state.offroad ||
|
||||||
|
!devices.some((device) => normalizedAddress(device) === state.elmAddress.toUpperCase() && device.paired))) {
|
||||||
|
closeElm()
|
||||||
|
}
|
||||||
if (state.deviceSignature !== deviceSignature) {
|
if (state.deviceSignature !== deviceSignature) {
|
||||||
state.deviceSignature = deviceSignature
|
state.deviceSignature = deviceSignature
|
||||||
state.revision++
|
state.revision++
|
||||||
@@ -223,7 +284,11 @@ function initialize() {
|
|||||||
window.addEventListener("focus", refresh)
|
window.addEventListener("focus", refresh)
|
||||||
window.addEventListener("pageshow", refresh)
|
window.addEventListener("pageshow", refresh)
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (document.visibilityState !== "hidden" && bluetoothPageActive()) refresh()
|
if (document.visibilityState === "hidden" || !bluetoothPageActive()) {
|
||||||
|
closeElm()
|
||||||
|
} else {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
refresh()
|
refresh()
|
||||||
schedulePoll(0)
|
schedulePoll(0)
|
||||||
@@ -248,6 +313,7 @@ function deviceCapabilities(device) {
|
|||||||
const capabilities = []
|
const capabilities = []
|
||||||
if (device.audio) capabilities.push("Audio")
|
if (device.audio) capabilities.push("Audio")
|
||||||
if (device.controller) capabilities.push("Controller")
|
if (device.controller) capabilities.push("Controller")
|
||||||
|
if (device.serial) capabilities.push("Serial")
|
||||||
return capabilities.join(" · ") || "Bluetooth device"
|
return capabilities.join(" · ") || "Bluetooth device"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +419,14 @@ function renderDeviceActions(device) {
|
|||||||
actions.push("<button data-bluetooth-operation=\"pair\" data-address=\"" + address + "\"" +
|
actions.push("<button data-bluetooth-operation=\"pair\" data-address=\"" + address + "\"" +
|
||||||
renderDisabledAttribute(!state.offroad || !!state.busy || pairing) + ">" + (pairing ? "Pairing…" : "Pair") + "</button>")
|
renderDisabledAttribute(!state.offroad || !!state.busy || pairing) + ">" + (pairing ? "Pairing…" : "Pair") + "</button>")
|
||||||
}
|
}
|
||||||
if (device.paired || device.connected) {
|
if (device.paired && device.serial) {
|
||||||
|
actions.push("<button data-bluetooth-operation=\"elm_open\" data-address=\"" + address + "\"" +
|
||||||
|
renderDisabledAttribute(!state.offroad || !!state.busy) + ">ELM327</button>")
|
||||||
|
actions.push("<button class=\"bluetoothIconButton bluetoothForgetButton\" data-bluetooth-operation=\"forget\" data-address=\"" +
|
||||||
|
address + "\" data-device-name=\"" + name + "\" title=\"Forget device\" aria-label=\"Forget " + name + "\"" +
|
||||||
|
renderDisabledAttribute(!state.offroad || !!state.busy) + "><i class=\"bi bi-trash3\" aria-hidden=\"true\"></i></button>")
|
||||||
|
}
|
||||||
|
if ((device.paired || device.connected) && !device.serial) {
|
||||||
const operation = device.connected ? "disconnect" : "connect"
|
const operation = device.connected ? "disconnect" : "connect"
|
||||||
actions.push("<button data-bluetooth-operation=\"" + operation + "\" data-address=\"" + address + "\"" +
|
actions.push("<button data-bluetooth-operation=\"" + operation + "\" data-address=\"" + address + "\"" +
|
||||||
renderDisabledAttribute(!!state.busy) + ">" + (device.connected ? "Disconnect" : "Connect") + "</button>")
|
renderDisabledAttribute(!!state.busy) + ">" + (device.connected ? "Disconnect" : "Connect") + "</button>")
|
||||||
@@ -407,7 +480,48 @@ function handleDeviceListClick(event) {
|
|||||||
const operation = button.dataset.bluetoothOperation
|
const operation = button.dataset.bluetoothOperation
|
||||||
const address = button.dataset.address || ""
|
const address = button.dataset.address || ""
|
||||||
if (operation === "forget" && !window.confirm("Forget " + (button.dataset.deviceName || "this device") + "?")) return
|
if (operation === "forget" && !window.confirm("Forget " + (button.dataset.deviceName || "this device") + "?")) return
|
||||||
request(operation, { address })
|
if (operation === "elm_open") {
|
||||||
|
openElm(address)
|
||||||
|
} else {
|
||||||
|
request(operation, { address })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderElmPanel() {
|
||||||
|
if (!state.elmAddress) return ""
|
||||||
|
return html`
|
||||||
|
<section class="bluetoothElmPanel">
|
||||||
|
<div class="bluetoothElmHeader">
|
||||||
|
<div>
|
||||||
|
<h3>ELM327</h3>
|
||||||
|
<p>${() => state.elmName || state.elmAddress}</p>
|
||||||
|
</div>
|
||||||
|
<strong>${() => state.elmAdapter || "Connecting…"}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="bluetoothElmActions">
|
||||||
|
<button disabled="${() => !state.offroad || !!state.busy}" @click="${readElmCodes}">Read Codes</button>
|
||||||
|
<button class="bluetoothSecondaryButton" disabled="${() => !!state.busy}" @click="${closeElm}">Close</button>
|
||||||
|
</div>
|
||||||
|
${() => state.elmCodes !== null ? html`
|
||||||
|
<div class="bluetoothElmCodes">
|
||||||
|
<strong>Stored Codes</strong>
|
||||||
|
<p>${() => state.elmCodes.length ? state.elmCodes.join(" · ") : "No stored codes reported."}</p>
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
<div class="bluetoothElmCommand">
|
||||||
|
<label for="bluetoothElmCommand">Command</label>
|
||||||
|
<div>
|
||||||
|
<input id="bluetoothElmCommand" type="text" autocomplete="off" placeholder="ATI"
|
||||||
|
disabled="${() => !state.offroad || !!state.busy}" />
|
||||||
|
<button disabled="${() => !state.offroad || !!state.busy}" @click="${sendElmCommand}">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bluetoothElmResponse">
|
||||||
|
<strong>Response</strong>
|
||||||
|
<pre>${() => state.elmResponse || "—"}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Bluetooth() {
|
export function Bluetooth() {
|
||||||
@@ -440,6 +554,7 @@ export function Bluetooth() {
|
|||||||
<span>The test sound is sent at NOW. The audible gap is Bluetooth latency.</span>
|
<span>The test sound is sent at NOW. The audible gap is Bluetooth latency.</span>
|
||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
|
${() => renderElmPanel()}
|
||||||
|
|
||||||
<div class="bluetoothToolbar">
|
<div class="bluetoothToolbar">
|
||||||
<button disabled="${() => !state.offroad || !state.enabled || !!state.busy}"
|
<button disabled="${() => !state.offroad || !state.enabled || !!state.busy}"
|
||||||
|
|||||||
@@ -132,7 +132,15 @@ class FakeBluetoothClient:
|
|||||||
|
|
||||||
def call(self, command, **payload):
|
def call(self, command, **payload):
|
||||||
self.calls.append((command, payload))
|
self.calls.append((command, payload))
|
||||||
return {"audio_test_delay_ms": 3000} if command == "test_audio" else {}
|
if command == "test_audio":
|
||||||
|
return {"audio_test_delay_ms": 3000}
|
||||||
|
if command == "elm_open":
|
||||||
|
return {"adapter": "Fake ELM327"}
|
||||||
|
if command == "elm_command":
|
||||||
|
return {"response": "OK"}
|
||||||
|
if command == "elm_read_dtcs":
|
||||||
|
return {"codes": ["P0133"], "raw": "43 01 33"}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def test_bluetooth_status_api(monkeypatch):
|
def test_bluetooth_status_api(monkeypatch):
|
||||||
@@ -203,6 +211,26 @@ def test_bluetooth_api_dispatches_operations(monkeypatch):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bluetooth_api_dispatches_elm_payload_and_allows_close_onroad(monkeypatch):
|
||||||
|
FakeBluetoothClient.calls = []
|
||||||
|
client, fake_params = _params_client(monkeypatch, {"IsOffroad": True}, "mici")
|
||||||
|
monkeypatch.setattr(the_galaxy, "BluetoothClient", FakeBluetoothClient)
|
||||||
|
address = "00:11:22:33:44:55"
|
||||||
|
|
||||||
|
assert client.post("/api/bluetooth/elm_open", json={"address": address}).get_json()["adapter"] == "Fake ELM327"
|
||||||
|
assert client.post("/api/bluetooth/elm_command", json={"address": address, "value": "ATI"}).get_json()["response"] == "OK"
|
||||||
|
assert client.post("/api/bluetooth/elm_read_dtcs", json={"address": address}).get_json()["codes"] == ["P0133"]
|
||||||
|
fake_params.values["IsOffroad"] = False
|
||||||
|
assert client.post("/api/bluetooth/elm_close", json={"address": address}).status_code == 200
|
||||||
|
|
||||||
|
assert FakeBluetoothClient.calls == [
|
||||||
|
("elm_open", {"address": address}),
|
||||||
|
("elm_command", {"address": address, "value": "ATI"}),
|
||||||
|
("elm_read_dtcs", {"address": address}),
|
||||||
|
("elm_close", {"address": address}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_wheel_controls_status_includes_favorite_slots(monkeypatch):
|
def test_wheel_controls_status_includes_favorite_slots(monkeypatch):
|
||||||
client, _ = _params_client(monkeypatch, {"IsOffroad": True, "FavoriteSlots": []}, "mici")
|
client, _ = _params_client(monkeypatch, {"IsOffroad": True, "FavoriteSlots": []}, "mici")
|
||||||
monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"available": True, "mappings": [], "devices": []})
|
monkeypatch.setattr(the_galaxy, "wheel_control_status", lambda *_args: {"available": True, "mappings": [], "devices": []})
|
||||||
|
|||||||
@@ -5070,11 +5070,16 @@ def setup(app):
|
|||||||
"select_audio": "select_audio",
|
"select_audio": "select_audio",
|
||||||
"test_audio": "test_audio",
|
"test_audio": "test_audio",
|
||||||
"pairing_response": "pairing_response",
|
"pairing_response": "pairing_response",
|
||||||
|
"elm_open": "elm_open",
|
||||||
|
"elm_close": "elm_close",
|
||||||
|
"elm_command": "elm_command",
|
||||||
|
"elm_read_dtcs": "elm_read_dtcs",
|
||||||
}
|
}
|
||||||
command = commands.get(operation)
|
command = commands.get(operation)
|
||||||
if command is None:
|
if command is None:
|
||||||
return jsonify({"error": "Unknown Bluetooth operation."}), 404
|
return jsonify({"error": "Unknown Bluetooth operation."}), 404
|
||||||
offroad_only = {"power", "scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response"}
|
offroad_only = {"power", "scan", "stop_scan", "pair", "forget", "test_audio", "pairing_response",
|
||||||
|
"elm_open", "elm_command", "elm_read_dtcs"}
|
||||||
if operation in offroad_only and not params.get_bool("IsOffroad"):
|
if operation in offroad_only and not params.get_bool("IsOffroad"):
|
||||||
return jsonify({"error": "Bluetooth settings can only be changed offroad."}), 409
|
return jsonify({"error": "Bluetooth settings can only be changed offroad."}), 409
|
||||||
|
|
||||||
@@ -5092,6 +5097,8 @@ def setup(app):
|
|||||||
payload["address"] = str(data.get("address", ""))
|
payload["address"] = str(data.get("address", ""))
|
||||||
if not payload["address"] and command != "select_audio":
|
if not payload["address"] and command != "select_audio":
|
||||||
return jsonify({"error": "Bluetooth device address is required."}), 400
|
return jsonify({"error": "Bluetooth device address is required."}), 400
|
||||||
|
if command == "elm_command":
|
||||||
|
payload["value"] = str(data.get("value", ""))
|
||||||
try:
|
try:
|
||||||
client = BluetoothClient(timeout=10.0)
|
client = BluetoothClient(timeout=10.0)
|
||||||
if command == "set_power":
|
if command == "set_power":
|
||||||
|
|||||||
@@ -98,6 +98,32 @@ class BluetoothManager:
|
|||||||
self._operations.pop(normalized_address, None)
|
self._operations.pop(normalized_address, None)
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def _run_result(self, fn, *args, operation: str = "", address: str = "", callback=None) -> None:
|
||||||
|
normalized_address = address.upper()
|
||||||
|
if normalized_address:
|
||||||
|
with self._lock:
|
||||||
|
self._operations[normalized_address] = operation
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
result = None
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
result = fn(*args)
|
||||||
|
except Exception as exception:
|
||||||
|
error = str(exception)
|
||||||
|
finally:
|
||||||
|
if normalized_address:
|
||||||
|
with self._lock:
|
||||||
|
if self._operations.get(normalized_address) == operation:
|
||||||
|
self._operations.pop(normalized_address, None)
|
||||||
|
if callback is not None:
|
||||||
|
callback(result, error)
|
||||||
|
elif error:
|
||||||
|
with self._lock:
|
||||||
|
self._operation_error = error
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
def set_power(self, enabled: bool) -> None:
|
def set_power(self, enabled: bool) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._power_pending:
|
if self._power_pending:
|
||||||
@@ -148,5 +174,17 @@ class BluetoothManager:
|
|||||||
self._audio_test_deadline = 0.0
|
self._audio_test_deadline = 0.0
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def elm_open(self, address: str, callback=None) -> None:
|
||||||
|
self._run_result(self._client.elm_open, address, operation="elm_open", address=address, callback=callback)
|
||||||
|
|
||||||
|
def elm_close(self, address: str, callback=None) -> None:
|
||||||
|
self._run_result(self._client.elm_close, address, operation="elm_close", address=address, callback=callback)
|
||||||
|
|
||||||
|
def elm_command(self, address: str, value: str, callback=None) -> None:
|
||||||
|
self._run_result(self._client.elm_command, address, value, operation="elm_command", address=address, callback=callback)
|
||||||
|
|
||||||
|
def elm_read_dtcs(self, address: str, callback=None) -> None:
|
||||||
|
self._run_result(self._client.elm_read_dtcs, address, operation="elm_read_dtcs", address=address, callback=callback)
|
||||||
|
|
||||||
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
|
def respond(self, prompt_id: str, accepted: bool, value: str = "") -> None:
|
||||||
self._run(self._client.respond, prompt_id, accepted, value)
|
self._run(self._client.respond, prompt_id, accepted, value)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
import threading
|
||||||
|
|
||||||
import pyray as rl
|
import pyray as rl
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ from openpilot.system.ui.widgets import DialogResult, Widget
|
|||||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||||
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
|
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
|
||||||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||||||
from openpilot.system.ui.widgets.label import gui_label
|
from openpilot.system.ui.widgets.label import gui_label, gui_text_box
|
||||||
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
|
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
|
||||||
from openpilot.system.ui.widgets.toggle import Toggle
|
from openpilot.system.ui.widgets.toggle import Toggle
|
||||||
|
|
||||||
@@ -45,11 +46,15 @@ def device_status_text(device: BluetoothDevice, operation: str, selected_audio:
|
|||||||
capabilities.append(tr("audio output") if selected_audio.upper() == device.address.upper() else tr("audio"))
|
capabilities.append(tr("audio output") if selected_audio.upper() == device.address.upper() else tr("audio"))
|
||||||
if device.controller:
|
if device.controller:
|
||||||
capabilities.append(tr("controller"))
|
capabilities.append(tr("controller"))
|
||||||
|
if device.serial:
|
||||||
|
capabilities.append(tr("serial"))
|
||||||
capability_text = " / ".join(capabilities)
|
capability_text = " / ".join(capabilities)
|
||||||
|
|
||||||
if device.connected:
|
if device.connected:
|
||||||
return tr("Connected") + (f" / {capability_text}" if capability_text else "")
|
return tr("Connected") + (f" / {capability_text}" if capability_text else "")
|
||||||
if device.paired:
|
if device.paired:
|
||||||
|
if device.serial:
|
||||||
|
return tr("Paired - tap to use ELM327") + (f" / {capability_text}" if capability_text else "")
|
||||||
return tr("Paired - tap to connect")
|
return tr("Paired - tap to connect")
|
||||||
return tr("Tap to pair") + (f" / {capability_text}" if capability_text else "")
|
return tr("Tap to pair") + (f" / {capability_text}" if capability_text else "")
|
||||||
|
|
||||||
@@ -58,6 +63,8 @@ def device_action_allowed(device: BluetoothDevice, operation: str, offroad: bool
|
|||||||
"""Mirror the daemon's operation policy before a row can receive a tap."""
|
"""Mirror the daemon's operation policy before a row can receive a tap."""
|
||||||
if operation:
|
if operation:
|
||||||
return False
|
return False
|
||||||
|
if device.serial and not offroad:
|
||||||
|
return False
|
||||||
if not offroad and not device.paired:
|
if not offroad and not device.paired:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -175,6 +182,139 @@ class BluetoothAudioTestDialog(Widget):
|
|||||||
self._done_button.render(button_rect)
|
self._done_button.render(button_rect)
|
||||||
|
|
||||||
|
|
||||||
|
class ELM327Dialog(Widget):
|
||||||
|
"""Ephemeral, offroad-only ELM327 controls opened from the Bluetooth panel."""
|
||||||
|
def __init__(self, manager: BluetoothManager, device: BluetoothDevice):
|
||||||
|
super().__init__()
|
||||||
|
self._manager = manager
|
||||||
|
self._address = device.address
|
||||||
|
self._name = device.name
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
self._closed = False
|
||||||
|
self._connecting = True
|
||||||
|
self._adapter = ""
|
||||||
|
self._response = ""
|
||||||
|
self._codes: list[str] | None = None
|
||||||
|
self._error = ""
|
||||||
|
self._keyboard = Keyboard(max_text_size=256, min_text_size=1, password_mode=False)
|
||||||
|
self._read_button = Button(tr("Read Codes"), self._read_codes, button_style=ButtonStyle.PRIMARY, font_size=42)
|
||||||
|
self._command_button = Button(tr("Send Command"), self._send_command, button_style=ButtonStyle.NORMAL, font_size=42)
|
||||||
|
self._done_button = Button(tr("Done"), gui_app.pop_widget, button_style=ButtonStyle.NORMAL, font_size=42)
|
||||||
|
|
||||||
|
def show_event(self):
|
||||||
|
super().show_event()
|
||||||
|
self._manager.elm_open(self._address, callback=self._on_open_result)
|
||||||
|
|
||||||
|
def hide_event(self):
|
||||||
|
with self._state_lock:
|
||||||
|
self._closed = True
|
||||||
|
self._manager.elm_close(self._address)
|
||||||
|
super().hide_event()
|
||||||
|
|
||||||
|
def _on_open_result(self, result, error):
|
||||||
|
should_close = False
|
||||||
|
with self._state_lock:
|
||||||
|
self._connecting = False
|
||||||
|
if error:
|
||||||
|
self._error = error
|
||||||
|
else:
|
||||||
|
self._adapter = str((result or {}).get("adapter", ""))
|
||||||
|
self._response = ""
|
||||||
|
self._codes = None
|
||||||
|
should_close = self._closed
|
||||||
|
if should_close:
|
||||||
|
self._manager.elm_close(self._address)
|
||||||
|
|
||||||
|
def _on_command_result(self, command: str, result, error):
|
||||||
|
with self._state_lock:
|
||||||
|
if error:
|
||||||
|
self._error = error
|
||||||
|
else:
|
||||||
|
response = str((result or {}).get("response", ""))
|
||||||
|
self._response = f"{command}\n{response}".strip()
|
||||||
|
|
||||||
|
def _on_read_result(self, result, error):
|
||||||
|
with self._state_lock:
|
||||||
|
if error:
|
||||||
|
self._error = error
|
||||||
|
else:
|
||||||
|
self._codes = [str(code) for code in (result or {}).get("codes", [])]
|
||||||
|
self._response = str((result or {}).get("raw", ""))
|
||||||
|
|
||||||
|
def _send_command(self):
|
||||||
|
with self._state_lock:
|
||||||
|
if self._connecting or self._error or self._closed:
|
||||||
|
return
|
||||||
|
self._keyboard.reset(min_text_size=1)
|
||||||
|
self._keyboard.set_title(tr("Send ELM327 command"), tr("Raw commands are available offroad only."))
|
||||||
|
self._keyboard.set_callback(self._on_command_entered)
|
||||||
|
gui_app.push_widget(self._keyboard)
|
||||||
|
|
||||||
|
def _on_command_entered(self, result: DialogResult):
|
||||||
|
command = self._keyboard.text.strip()
|
||||||
|
self._keyboard.clear()
|
||||||
|
if result != DialogResult.CONFIRM or not command:
|
||||||
|
return
|
||||||
|
with self._state_lock:
|
||||||
|
self._response = f"{command}\nSending..."
|
||||||
|
self._error = ""
|
||||||
|
self._manager.elm_command(self._address, command, callback=partial(self._on_command_result, command))
|
||||||
|
|
||||||
|
def _read_codes(self):
|
||||||
|
with self._state_lock:
|
||||||
|
self._codes = None
|
||||||
|
self._error = ""
|
||||||
|
self._manager.elm_read_dtcs(self._address, callback=self._on_read_result)
|
||||||
|
|
||||||
|
def _update_state(self):
|
||||||
|
with self._state_lock:
|
||||||
|
ready = bool(self._adapter) and not self._connecting and not self._error and not self._closed
|
||||||
|
status = self._manager.status
|
||||||
|
operation = self._manager.operation_for(self._address)
|
||||||
|
enabled = ready and status.offroad and not operation
|
||||||
|
self._read_button.set_enabled(enabled)
|
||||||
|
self._command_button.set_enabled(enabled)
|
||||||
|
self._done_button.set_enabled(True)
|
||||||
|
|
||||||
|
def _render(self, rect: rl.Rectangle):
|
||||||
|
dialog_rect = rl.Rectangle(rect.x + 90, rect.y + 60, rect.width - 180, rect.height - 120)
|
||||||
|
rl.draw_rectangle_rounded(dialog_rect, 0.03, 20, DIALOG_BACKGROUND)
|
||||||
|
|
||||||
|
with self._state_lock:
|
||||||
|
connecting = self._connecting
|
||||||
|
adapter = self._adapter
|
||||||
|
response = self._response
|
||||||
|
codes = self._codes
|
||||||
|
error = self._error
|
||||||
|
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 35, dialog_rect.width - 90, 70), tr("ELM327"),
|
||||||
|
font_size=62, font_weight=FontWeight.BOLD)
|
||||||
|
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 105, dialog_rect.width - 90, 52), self._name,
|
||||||
|
font_size=42, color=TEXT_SECONDARY)
|
||||||
|
identity = tr("Connecting...") if connecting else adapter
|
||||||
|
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 157, dialog_rect.width - 90, 52), identity,
|
||||||
|
font_size=42, color=TEXT_CONNECTED if adapter else TEXT_SECONDARY)
|
||||||
|
|
||||||
|
button_y = dialog_rect.y + 225
|
||||||
|
button_width = (dialog_rect.width - 135) / 3
|
||||||
|
self._read_button.render(rl.Rectangle(dialog_rect.x + 45, button_y, button_width, 90))
|
||||||
|
self._command_button.render(rl.Rectangle(dialog_rect.x + 60 + button_width, button_y, button_width, 90))
|
||||||
|
self._done_button.render(rl.Rectangle(dialog_rect.x + 75 + button_width * 2, button_y, button_width, 90))
|
||||||
|
|
||||||
|
if error:
|
||||||
|
gui_text_box(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 330, dialog_rect.width - 90, 80), error,
|
||||||
|
font_size=38, color=rl.Color(255, 150, 150, 255))
|
||||||
|
if codes is not None:
|
||||||
|
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 410, 300, 45), tr("Stored Codes"),
|
||||||
|
font_size=38, font_weight=FontWeight.BOLD)
|
||||||
|
code_text = "\n".join(codes) if codes else tr("No stored codes reported.")
|
||||||
|
gui_text_box(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 455, dialog_rect.width - 90, 95), code_text,
|
||||||
|
font_size=38, color=TEXT_SECONDARY)
|
||||||
|
gui_label(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 560, 300, 45), tr("Response"),
|
||||||
|
font_size=38, font_weight=FontWeight.BOLD)
|
||||||
|
gui_text_box(rl.Rectangle(dialog_rect.x + 45, dialog_rect.y + 605, dialog_rect.width - 90, dialog_rect.height - 650),
|
||||||
|
response or "—", font_size=36, color=TEXT_SECONDARY)
|
||||||
|
|
||||||
|
|
||||||
class BluetoothManagerUI(Widget):
|
class BluetoothManagerUI(Widget):
|
||||||
"""Big UI Bluetooth settings panel backed by the existing Bluetooth manager daemon."""
|
"""Big UI Bluetooth settings panel backed by the existing Bluetooth manager daemon."""
|
||||||
def __init__(self, manager: BluetoothManager):
|
def __init__(self, manager: BluetoothManager):
|
||||||
@@ -264,6 +404,8 @@ class BluetoothManagerUI(Widget):
|
|||||||
return
|
return
|
||||||
if not device.paired:
|
if not device.paired:
|
||||||
self._manager.pair(device.address)
|
self._manager.pair(device.address)
|
||||||
|
elif device.serial:
|
||||||
|
gui_app.push_widget(ELM327Dialog(self._manager, device))
|
||||||
elif not device.connected:
|
elif not device.connected:
|
||||||
self._manager.connect(device.address)
|
self._manager.connect(device.address)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user