This commit is contained in:
firestarsdog
2026-08-30 05:48:31 -04:00
parent 2620f9f9bd
commit 562e8b11fd
27 changed files with 4728 additions and 77 deletions
+23 -1
View File
@@ -5,7 +5,7 @@ from functools import partial
import pyray as rl
from openpilot.starpilot.system.bluetooth.protocol import BluetoothDevice, BluetoothStatus
from openpilot.starpilot.system.bluetooth.protocol import BluetoothDevice, BluetoothStatus, looks_like_obd_device
from openpilot.system.ui.lib.application import FontWeight, MousePos, gui_app
from openpilot.system.ui.lib.bluetooth_manager import BluetoothManager
from openpilot.system.ui.lib.multilang import tr
@@ -23,6 +23,7 @@ HEADER_HEIGHT = 180
ITEM_HEIGHT = 160
HEADER_PADDING = 40
SCAN_BUTTON_WIDTH = 260
OBDYSSEY_BUTTON_WIDTH = 260
FORGET_BUTTON_WIDTH = 180
ACTION_GAP = 35
@@ -35,6 +36,10 @@ TEXT_DISABLED = rl.Color(150, 150, 150, 255)
TEXT_CONNECTED = rl.Color(113, 209, 135, 255)
def is_obd_device(device: BluetoothDevice) -> bool:
return (device.serial or looks_like_obd_device(device.name)) and not (device.audio or device.controller)
def device_status_text(device: BluetoothDevice, operation: str, selected_audio: str) -> str:
"""Return the concise, state-first label shown below a Bluetooth device name."""
if operation:
@@ -45,11 +50,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 or looks_like_obd_device(device.name):
capabilities.append(tr("OBD-II"))
capability_text = " / ".join(capabilities)
if device.connected:
return tr("Connected") + (f" / {capability_text}" if capability_text else "")
if device.paired:
if is_obd_device(device):
return tr("Paired - tap to open OBDyssey")
return tr("Paired - tap to connect")
return tr("Tap to pair") + (f" / {capability_text}" if capability_text else "")
@@ -183,6 +192,7 @@ class BluetoothManagerUI(Widget):
self._scroll_panel = GuiScrollPanel()
self._power_toggle = Toggle(initial_state=False, callback=self._toggle_power)
self._scan_button = Button(tr("Scan"), self._scan, button_style=ButtonStyle.NORMAL, font_size=42)
self._obdyssey_button = Button(tr("OBDyssey"), self._open_obdyssey, button_style=ButtonStyle.PRIMARY, font_size=42)
self._device_rows: dict[str, BluetoothDeviceRow] = {}
self._pending_power: bool | None = None
self._scan_pending = False
@@ -192,6 +202,10 @@ class BluetoothManagerUI(Widget):
self._last_operation_error = ""
self._keyboard = Keyboard(max_text_size=32, min_text_size=1, password_mode=False)
def _open_obdyssey(self):
from openpilot.system.ui.widgets.obdyssey import OBDysseyScreen
gui_app.push_widget(OBDysseyScreen())
def show_event(self):
super().show_event()
self._manager.set_active(True)
@@ -264,6 +278,8 @@ class BluetoothManagerUI(Widget):
return
if not device.paired:
self._manager.pair(device.address)
elif is_obd_device(device):
self._open_obdyssey()
elif not device.connected:
self._manager.connect(device.address)
else:
@@ -406,11 +422,17 @@ class BluetoothManagerUI(Widget):
subtitle += " - " + tr("Limited while driving")
gui_label(rl.Rectangle(rect.x + HEADER_PADDING, rect.y + 104, 650, 44), subtitle, font_size=40, color=TEXT_SECONDARY)
has_obd = any((device.connected or device.paired) and (device.serial or looks_like_obd_device(device.name)) for device in status.devices)
toggle_rect = rl.Rectangle(rect.x + rect.width - HEADER_PADDING - 160, rect.y + (rect.height - 80) / 2, 160, 80)
self._power_toggle.render(toggle_rect)
scan_rect = rl.Rectangle(toggle_rect.x - ACTION_GAP - SCAN_BUTTON_WIDTH, rect.y + (rect.height - 100) / 2, SCAN_BUTTON_WIDTH, 100)
self._scan_button.render(scan_rect)
if has_obd:
obdyssey_rect = rl.Rectangle(scan_rect.x - ACTION_GAP - OBDYSSEY_BUTTON_WIDTH, rect.y + (rect.height - 100) / 2, OBDYSSEY_BUTTON_WIDTH, 100)
self._obdyssey_button.render(obdyssey_rect)
def _render_device_list(self, rect: rl.Rectangle, rows: list[BluetoothDeviceRow]):
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(rows) * ITEM_HEIGHT)
offset = self._scroll_panel.update(rect, content_rect)
+419
View File
@@ -0,0 +1,419 @@
from __future__ import annotations
import threading
import time
from typing import Any
import pyray as rl
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.starpilot.system.obdyssey.protocol import OBDysseyClient, OBDysseyStatus
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
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.label import gui_label
HEADER_HEIGHT = 180
HEADER_PADDING = 40
BUTTON_GAP = 25
BUTTON_HEIGHT = 100
PANEL_BACKGROUND = rl.BLACK
CARD_BACKGROUND = rl.Color(27, 27, 27, 255)
ROW_BORDER = rl.LIGHTGRAY
TEXT_SECONDARY = rl.Color(170, 170, 170, 255)
TEXT_CONNECTED = rl.Color(113, 209, 135, 255)
TEXT_DANGER = rl.Color(255, 100, 100, 255)
TEXT_WARNING = rl.Color(255, 185, 45, 255)
DTC_STATE_UNAVAILABLE = "unavailable"
DTC_STATE_IN_PROGRESS = "in_progress"
DTC_STATE_CLEAN = "clean"
DTC_STATE_FAULTS = "faults"
COMMON_SIGNAL_NAMES = {
"SAE_ENGINE_RPM": ("Engine RPM", "rpm"),
"SAE_VEHICLE_SPEED": ("Speed", "km/h"),
"SAE_ENGINE_COOLANT_TEMP": ("Coolant Temp", "°C"),
"SAE_CALCULATED_ENGINE_LOAD": ("Engine Load", "%"),
"SAE_THROTTLE_POSITION": ("Throttle Position", "%"),
"SAE_INTAKE_AIR_TEMP": ("Intake Air Temp", "°C"),
"SAE_MAF_AIR_FLOW": ("MAF Air Flow", "g/s"),
"SAE_FUEL_TANK_LEVEL": ("Fuel Level", "%"),
"SAE_CONTROL_MODULE_VOLTAGE": ("Module Voltage", "V"),
"SAE_AMBIENT_AIR_TEMP": ("Ambient Temp", "°C"),
"SAE_HYBRID_EV_BATTERY_REMAINING": ("EV Battery SOC", "%"),
"BOLT_HVBAT_SOC": ("Battery SOC", "%"),
"BOLT_HVBAT_VOLTAGE": ("Pack Voltage", "V"),
"BOLT_HVBAT_CURRENT": ("Pack Current", "A"),
"BOLT_HVBAT_TEMP": ("Battery Temp", "°C"),
"BOLT_HVBAT_POWER": ("Battery Power", "kW"),
"BOLT_MOTOR_RPM": ("Motor RPM", "rpm"),
"BOLT_MOTOR_TEMP": ("Motor Temp", "°C"),
}
class OBDysseyScreen(Widget):
"""Full on-device Car Scanner and Diagnostics UI panel backed by obdysseyd."""
def __init__(self, client: OBDysseyClient | None = None, params: Params | None = None):
super().__init__()
self._client = client or OBDysseyClient()
self.params = params or Params()
self._scroll_panel = GuiScrollPanel()
self._back_button = Button(tr("Back"), self._go_back, button_style=ButtonStyle.NORMAL, font_size=42)
self._retry_button = Button(tr("Retry"), self._retry_connection, button_style=ButtonStyle.NORMAL, font_size=42)
self._dtc_button = Button(tr("Scan DTCs"), self._trigger_dtc_scan, button_style=ButtonStyle.NORMAL, font_size=42)
self._clear_button = Button(tr("Clear Codes"), self._confirm_clear_dtcs, button_style=ButtonStyle.DANGER, font_size=42)
self._stop_event = threading.Event()
self._poller_thread: threading.Thread | None = None
# Cached state updated by poller thread
self._status: OBDysseyStatus | None = None
self._available_signals: list[dict[str, Any]] = []
self._live_telemetry: dict[str, Any] = {}
self._dtcs: list[dict[str, Any]] = []
self._dtc_state = DTC_STATE_UNAVAILABLE
self._dtc_scan_in_progress = False
self._clear_in_progress = False
self._retry_in_progress = False
self._last_error = ""
def _go_back(self):
gui_app.pop_widget()
def _diagnostic_ready(self) -> bool:
status = self._status
return bool(status and (getattr(status, "diagnostic_ready", False) or getattr(status, "connected", False) or status.state == "ready"))
def _retry_connection(self):
if self._retry_in_progress:
return
self._retry_in_progress = True
self._last_error = ""
self._dtc_state = DTC_STATE_UNAVAILABLE
def _task():
try:
self._client.connect()
except Exception as err:
self._last_error = str(err)
cloudlog.warning(f"OBDysseyScreen error retrying connection: {err}")
finally:
self._retry_in_progress = False
threading.Thread(target=_task, daemon=True).start()
def show_event(self):
super().show_event()
self._stop_event.clear()
self._poller_thread = threading.Thread(target=self._worker_loop, daemon=True)
self._poller_thread.start()
def hide_event(self):
self._stop_event.set()
if self._poller_thread is not None and self._poller_thread.is_alive():
self._poller_thread.join(timeout=0.2)
self._poller_thread = None
super().hide_event()
def _trigger_dtc_scan(self):
if not self._diagnostic_ready():
return
if not self._dtc_scan_in_progress:
self._dtc_scan_in_progress = True
self._dtc_state = DTC_STATE_IN_PROGRESS
self._last_error = ""
threading.Thread(target=self._scan_dtcs_worker, daemon=True).start()
def _scan_dtcs_worker(self):
try:
dtcs = self._client.read_dtcs()
self._dtcs = dtcs
self._dtc_state = DTC_STATE_FAULTS if dtcs else DTC_STATE_CLEAN
self._last_error = ""
except Exception as err:
self._dtcs = []
self._dtc_state = DTC_STATE_UNAVAILABLE
self._last_error = str(err)
cloudlog.warning(f"OBDysseyScreen error scanning DTCs: {err}")
finally:
self._dtc_scan_in_progress = False
def _confirm_clear_dtcs(self):
if not self.params.get_bool("IsOffroad"):
gui_app.push_widget(alert_dialog(tr("Clearing trouble codes is only permitted when vehicle is parked / offroad.")))
return
def apply(result: DialogResult):
if result == DialogResult.CONFIRM:
self._clear_dtcs_worker()
gui_app.push_widget(ConfirmDialog(
tr("Clear all diagnostic trouble codes and reset Check Engine Light?"),
tr("Clear Codes"),
callback=apply,
))
def _clear_dtcs_worker(self):
self._clear_in_progress = True
self._dtc_state = DTC_STATE_IN_PROGRESS
def _task():
try:
self._client.clear_dtcs()
time.sleep(0.5)
# Rescan after clearing
dtcs = self._client.read_dtcs()
self._dtcs = dtcs
self._dtc_state = DTC_STATE_FAULTS if dtcs else DTC_STATE_CLEAN
self._last_error = ""
gui_app.push_widget(alert_dialog(tr("Diagnostic trouble codes cleared successfully.")))
except Exception as err:
self._dtc_state = DTC_STATE_UNAVAILABLE
self._last_error = str(err)
gui_app.push_widget(alert_dialog(tr("Failed to clear trouble codes: {}").format(err)))
finally:
self._clear_in_progress = False
threading.Thread(target=_task, daemon=True).start()
def _worker_loop(self):
# 1. Fetch status and available signals once upon entry. DTC scans are
# explicitly user-triggered so an empty, unscanned result is never shown
# as a clean vehicle.
try:
self._status = self._client.status()
self._available_signals = self._client.list_signals()
except Exception as err:
self._last_error = str(err)
# 2. Main telemetry polling loop (~500ms cycle)
while not self._stop_event.is_set():
try:
was_diagnostic_ready = self._diagnostic_ready()
self._status = self._client.status()
if self._status.last_error:
self._last_error = self._status.last_error
elif self._diagnostic_ready() and not was_diagnostic_ready:
# Clear a connection failure once the daemon has recovered, while
# preserving errors from a diagnostic operation made in ready state.
self._last_error = ""
if self._diagnostic_ready():
if not self._available_signals:
self._available_signals = self._client.list_signals()
sig_ids = [s["id"] for s in self._available_signals]
if sig_ids:
# Query in batches
readings = self._client.read_signals(sig_ids)
self._live_telemetry.update(readings)
else:
# Never present stale readings as live while the link is only
# connecting/initializing or after it has failed.
self._live_telemetry.clear()
self._dtcs.clear()
self._dtc_state = DTC_STATE_UNAVAILABLE
except Exception as err:
self._last_error = str(err)
self._stop_event.wait(0.5)
def _render(self, rect: rl.Rectangle):
header_rect = rl.Rectangle(rect.x, rect.y, rect.width, HEADER_HEIGHT)
self._render_header(header_rect)
content_rect = rl.Rectangle(rect.x, rect.y + HEADER_HEIGHT, rect.width, rect.height - HEADER_HEIGHT)
self._render_content(content_rect)
def _render_header(self, rect: rl.Rectangle):
rl.draw_rectangle_rec(rect, PANEL_BACKGROUND)
line_y = int(rect.y + rect.height - 1)
rl.draw_line(int(rect.x), line_y, int(rect.x + rect.width), line_y, ROW_BORDER)
# Title & Adapter status subtitle
gui_label(rl.Rectangle(rect.x + HEADER_PADDING, rect.y + 26, 600, 68), tr("OBDyssey Diagnostics"), font_size=64, font_weight=FontWeight.BOLD)
status = self._status
if status and (getattr(status, "diagnostic_ready", False) or getattr(status, "connected", False) or status.state == "ready"):
volt_str = f"{status.adapter_voltage:.1f}V" if status.adapter_voltage is not None else ""
sub_parts = [status.adapter_name or status.elm_identity or "OBDII Adapter"]
if volt_str:
sub_parts.append(volt_str)
if status.profile:
sub_parts.append(f"Profile: {status.profile}")
subtitle = "".join(sub_parts)
sub_color = TEXT_CONNECTED
elif status and (getattr(status, "link_connected", False) or status.state == "initializing"):
subtitle = tr("Adapter connected • Preparing diagnostics...")
sub_color = TEXT_WARNING
elif status and status.state in ("connecting", "reconnecting"):
subtitle = tr("Connecting to adapter...")
sub_color = TEXT_WARNING
elif status and status.state == "error":
subtitle = tr("Connection failed")
sub_color = TEXT_DANGER
else:
subtitle = tr("Adapter disconnected")
sub_color = TEXT_SECONDARY
gui_label(rl.Rectangle(rect.x + HEADER_PADDING, rect.y + 104, 750, 44), subtitle, font_size=38, color=sub_color)
error_text = (status.last_error if status else "") or self._last_error
if error_text:
# BlueZ errors can include a long diagnostic detail. Keep the status
# readable on the fixed-width header while retaining the full value in
# the daemon status/API and logs.
error_text = error_text.replace("\n", " ")
if len(error_text) > 105:
error_text = error_text[:102] + "..."
gui_label(rl.Rectangle(rect.x + HEADER_PADDING, rect.y + 150, 1000, 25), f"{tr('Error')}: {error_text}", font_size=24, color=TEXT_DANGER)
# Action buttons on right
btn_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
cur_x = rect.x + rect.width - HEADER_PADDING
# Back button
cur_x -= 180
self._back_button.render(rl.Rectangle(cur_x, btn_y, 180, BUTTON_HEIGHT))
# Retry is the only UI-initiated connection action. The daemon remains
# responsible for its background reconnect policy.
show_retry = bool(not status or status.state in ("idle", "error", "disabled"))
if show_retry:
cur_x -= (BUTTON_GAP + 180)
self._retry_button.set_enabled(not self._retry_in_progress)
self._retry_button.set_text(tr("Retrying...") if self._retry_in_progress else tr("Retry"))
self._retry_button.render(rl.Rectangle(cur_x, btn_y, 180, BUTTON_HEIGHT))
# Clear DTCs button
cur_x -= (BUTTON_GAP + 260)
self._clear_button.set_enabled(not self._clear_in_progress and self._diagnostic_ready())
self._clear_button.set_text(tr("Clearing...") if self._clear_in_progress else tr("Clear Codes"))
self._clear_button.render(rl.Rectangle(cur_x, btn_y, 260, BUTTON_HEIGHT))
# Scan DTCs button
cur_x -= (BUTTON_GAP + 240)
self._dtc_button.set_enabled(not self._dtc_scan_in_progress and self._diagnostic_ready())
self._dtc_button.set_text(tr("Scanning...") if self._dtc_scan_in_progress else tr("Scan DTCs"))
self._dtc_button.render(rl.Rectangle(cur_x, btn_y, 240, BUTTON_HEIGHT))
def _render_content(self, rect: rl.Rectangle):
# Total content height calculation
telemetry_items = list(self._live_telemetry.items())
num_cards = len(telemetry_items)
cols = 3
card_margin = 25
card_width = (rect.width - 2 * HEADER_PADDING - (cols - 1) * card_margin) / cols
card_height = 150
rows_count = (num_cards + cols - 1) // cols
telemetry_height = rows_count * (card_height + card_margin) + 70 if num_cards > 0 else 0
dtc_height = 120 + max(1, len(self._dtcs)) * 140
total_height = max(rect.height + 10, telemetry_height + dtc_height + 100)
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, total_height)
offset = self._scroll_panel.update(rect, content_rect)
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
cur_y = rect.y + HEADER_PADDING + offset
# Section 1: Live Telemetry Grid
if telemetry_items:
gui_label(rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 50),
tr("Live Sensor Telemetry"), font_size=46, font_weight=FontWeight.BOLD)
cur_y += 65
for idx, (sig_id, val) in enumerate(telemetry_items):
col = idx % cols
row = idx // cols
card_x = rect.x + HEADER_PADDING + col * (card_width + card_margin)
card_y = cur_y + row * (card_height + card_margin)
card_rect = rl.Rectangle(card_x, card_y, card_width, card_height)
if rl.check_collision_recs(card_rect, rect):
rl.draw_rectangle_rounded(card_rect, 0.12, 16, CARD_BACKGROUND)
# Name & Unit
name, default_unit = COMMON_SIGNAL_NAMES.get(sig_id, (sig_id.replace("SAE_", "").replace("BOLT_", "").replace("_", " ").title(), ""))
gui_label(rl.Rectangle(card_x + 20, card_y + 15, card_width - 40, 36), name, font_size=32, color=TEXT_SECONDARY)
# Value
if val is None:
val_str = "--"
val_color = TEXT_SECONDARY
elif isinstance(val, (int, float)):
val_str = f"{val:,.1f}" if isinstance(val, float) and not val.is_integer() else f"{int(val):,}"
if default_unit:
val_str += f" {default_unit}"
val_color = TEXT_CONNECTED
else:
val_str = str(val)
val_color = rl.WHITE
gui_label(rl.Rectangle(card_x + 20, card_y + 60, card_width - 40, 65), val_str, font_size=52, font_weight=FontWeight.BOLD, color=val_color)
cur_y += rows_count * (card_height + card_margin) + 30
# Section 2: DTC Fault Codes
gui_label(rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 50),
tr("Diagnostic Trouble Codes (DTC)"), font_size=46, font_weight=FontWeight.BOLD)
cur_y += 65
dtc_state = self._dtc_state
if self._dtcs:
dtc_state = DTC_STATE_FAULTS
if dtc_state == DTC_STATE_IN_PROGRESS:
state_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 120)
if rl.check_collision_recs(state_rect, rect):
rl.draw_rectangle_rounded(state_rect, 0.12, 16, CARD_BACKGROUND)
gui_label(rl.Rectangle(state_rect.x + 30, state_rect.y + 35, state_rect.width - 60, 50),
tr("Scanning diagnostic trouble codes..."), font_size=42, color=TEXT_WARNING)
cur_y += 140
elif dtc_state == DTC_STATE_UNAVAILABLE:
state_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 120)
if rl.check_collision_recs(state_rect, rect):
rl.draw_rectangle_rounded(state_rect, 0.12, 16, CARD_BACKGROUND)
gui_label(rl.Rectangle(state_rect.x + 30, state_rect.y + 25, state_rect.width - 60, 40),
tr("DTC status unavailable"), font_size=42, color=TEXT_SECONDARY)
gui_label(rl.Rectangle(state_rect.x + 30, state_rect.y + 72, state_rect.width - 60, 30),
tr("Connect to the adapter and scan to check vehicle codes."), font_size=26, color=TEXT_SECONDARY)
cur_y += 140
elif dtc_state == DTC_STATE_CLEAN and not self._dtcs:
# Clean state card, only after a successful scan.
clean_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 120)
if rl.check_collision_recs(clean_rect, rect):
rl.draw_rectangle_rounded(clean_rect, 0.12, 16, CARD_BACKGROUND)
gui_label(rl.Rectangle(clean_rect.x + 30, clean_rect.y + 35, clean_rect.width - 60, 50),
tr("✓ No diagnostic trouble codes detected. System normal."), font_size=42, color=TEXT_CONNECTED)
cur_y += 140
else:
for dtc in self._dtcs:
dtc_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 120)
if rl.check_collision_recs(dtc_rect, rect):
rl.draw_rectangle_rounded(dtc_rect, 0.12, 16, CARD_BACKGROUND)
code = dtc.get("code", "DTC")
source = dtc.get("source", "OBD")
ecu = dtc.get("ecu")
desc = dtc.get("description") or tr("Diagnostic trouble code reported by vehicle ECU")
# Code badge
badge_rect = rl.Rectangle(dtc_rect.x + 25, dtc_rect.y + 25, 180, 70)
rl.draw_rectangle_rounded(badge_rect, 0.2, 12, rl.Color(80, 20, 20, 255))
gui_label(badge_rect, code, font_size=44, font_weight=FontWeight.BOLD, color=TEXT_DANGER, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
# Description & ECU info
gui_label(rl.Rectangle(dtc_rect.x + 230, dtc_rect.y + 20, dtc_rect.width - 250, 42), desc, font_size=38, font_weight=FontWeight.BOLD)
info_str = f"Status: Confirmed • Source: {source}" + (f" • ECU: {ecu}" if ecu else "")
gui_label(rl.Rectangle(dtc_rect.x + 230, dtc_rect.y + 65, dtc_rect.width - 250, 36), info_str, font_size=32, color=TEXT_SECONDARY)
cur_y += 140
rl.end_scissor_mode()