mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-09 09:35:50 +08:00
OBDyssey
This commit is contained in:
@@ -202,6 +202,7 @@ procs = [
|
||||
# StarPilot variables
|
||||
procs += [
|
||||
PythonProcess("bluetooth_managerd", "starpilot.system.bluetooth.daemon", bluetooth_enabled, enabled=TICI),
|
||||
PythonProcess("obdysseyd", "starpilot.system.obdyssey.daemon", always_run, enabled=TICI),
|
||||
PythonProcess("wheel_controlsd", "starpilot.system.wheel_controls.wheel_controlsd", wheel_controls_enabled, enabled=TICI, nice=19),
|
||||
PythonProcess("the_galaxy", "starpilot.system.the_galaxy.the_galaxy", always_run, nice=10),
|
||||
PythonProcess("galaxy", "starpilot.system.galaxy.galaxy", always_run, nice=10),
|
||||
|
||||
@@ -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, address: str = ""):
|
||||
from openpilot.system.ui.widgets.obdyssey import OBDysseyScreen
|
||||
gui_app.push_widget(OBDysseyScreen(adapter_address=address))
|
||||
|
||||
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(device.address)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
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 = {
|
||||
"RPM": ("Engine RPM", "rpm"),
|
||||
"VSS": ("Speed", "km/h"),
|
||||
"ECT": ("Coolant Temp", "°C"),
|
||||
"LOAD_PCT": ("Engine Load", "%"),
|
||||
"TP": ("Throttle Position", "%"),
|
||||
"IAT": ("Intake Air Temp", "°C"),
|
||||
"MAF": ("MAF Air Flow", "g/s"),
|
||||
"FLI": ("Fuel Level", "%"),
|
||||
"VPWR": ("Module Voltage", "V"),
|
||||
"AAT": ("Ambient Temp", "°C"),
|
||||
"BAT_SOC": ("EV Battery SOC", "%"),
|
||||
"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"),
|
||||
}
|
||||
|
||||
# The screen is a smoke-test consumer until a demand-driven watch API exists.
|
||||
# Keep its sample set intentionally small; it must not turn every OBDb signal
|
||||
# into a continuously polled vehicle request.
|
||||
SMOKE_TEST_SIGNAL_IDS = (
|
||||
"RPM",
|
||||
"VSS",
|
||||
"ECT",
|
||||
"SAE_ENGINE_RPM",
|
||||
"SAE_VEHICLE_SPEED",
|
||||
"SAE_ENGINE_COOLANT_TEMP",
|
||||
"BOLT_HVBAT_SOC",
|
||||
"BOLT_HVBAT_VOLTAGE",
|
||||
)
|
||||
|
||||
|
||||
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, adapter_address: str = ""):
|
||||
super().__init__()
|
||||
self._client = client or OBDysseyClient()
|
||||
self.params = params or Params()
|
||||
self._adapter_address = adapter_address
|
||||
|
||||
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._connect_adapter()
|
||||
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 _connect_adapter(self):
|
||||
address = getattr(self, "_adapter_address", "")
|
||||
return self._client.connect(address) if address else self._client.connect()
|
||||
|
||||
def _read_smoke_test_signals(self):
|
||||
if not self._diagnostic_ready():
|
||||
return
|
||||
available = {signal["id"]: signal for signal in self._client.list_signals()}
|
||||
sample_ids = [signal_id for signal_id in SMOKE_TEST_SIGNAL_IDS if signal_id in available]
|
||||
self._available_signals = [available[signal_id] for signal_id in sample_ids]
|
||||
if sample_ids:
|
||||
readings = self._client.read_signals(sample_ids)
|
||||
# API v2 separates successful values from per-signal errors. Keep a
|
||||
# small compatibility path for developer fakes implementing the old
|
||||
# flat dictionary contract.
|
||||
if isinstance(readings, dict) and "signals" in readings:
|
||||
self._live_telemetry = dict(readings.get("signals", {}))
|
||||
else:
|
||||
self._live_telemetry = dict(readings or {})
|
||||
|
||||
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 = ""
|
||||
if dtcs:
|
||||
gui_app.push_widget(alert_dialog(tr("Clear request completed, but diagnostic trouble codes remain.")))
|
||||
else:
|
||||
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):
|
||||
# Fetch status once upon entry. Auto-connect only through the daemon's
|
||||
# explicit/validated adapter selection.
|
||||
try:
|
||||
self._status = self._client.status()
|
||||
if not self._diagnostic_ready() and (not self._status or self._status.state in ("idle", "disabled")):
|
||||
self._connect_adapter()
|
||||
self._status = self._client.status()
|
||||
self._read_smoke_test_signals()
|
||||
except Exception as err:
|
||||
self._last_error = str(err)
|
||||
|
||||
# Main status loop. Vehicle reads remain demand-driven; this screen takes
|
||||
# one small sample after connection/recovery rather than polling all PIDs.
|
||||
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 was_diagnostic_ready:
|
||||
self._read_smoke_test_signals()
|
||||
# A ready → ready status refresh is not a lifecycle transition.
|
||||
# Preserve the last successful telemetry and DTC results.
|
||||
elif was_diagnostic_ready:
|
||||
# Only clear stale readings when leaving a usable backend state;
|
||||
# don't repeatedly erase an already-empty/error state on every poll.
|
||||
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):
|
||||
# Build list of signals to display
|
||||
signals_to_render: list[tuple[str, Any, str, str]] = []
|
||||
if self._available_signals:
|
||||
for sig in self._available_signals:
|
||||
sig_id = sig["id"]
|
||||
val = self._live_telemetry.get(sig_id, None)
|
||||
fallback_name = sig_id.replace("SAE_", "").replace("BOLT_", "").replace("_", " ").title()
|
||||
common_info = COMMON_SIGNAL_NAMES.get(sig_id, (sig.get("name") or fallback_name, sig.get("unit") or ""))
|
||||
name = sig.get("name") or common_info[0]
|
||||
unit = sig.get("unit") or common_info[1]
|
||||
signals_to_render.append((sig_id, val, name, unit))
|
||||
elif self._live_telemetry:
|
||||
for sig_id, val in self._live_telemetry.items():
|
||||
name, unit = COMMON_SIGNAL_NAMES.get(sig_id, (sig_id.replace("SAE_", "").replace("BOLT_", "").replace("_", " ").title(), ""))
|
||||
signals_to_render.append((sig_id, val, name, unit))
|
||||
|
||||
num_cards = len(signals_to_render)
|
||||
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 140
|
||||
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
|
||||
gui_label(rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 50),
|
||||
tr("Sample Sensor Readings"), font_size=46, font_weight=FontWeight.BOLD)
|
||||
cur_y += 65
|
||||
|
||||
if signals_to_render:
|
||||
for idx, (_sig_id, val, name, unit) in enumerate(signals_to_render):
|
||||
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
|
||||
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 unit:
|
||||
val_str += f" {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
|
||||
else:
|
||||
state_rect = rl.Rectangle(rect.x + HEADER_PADDING, cur_y, rect.width - 2 * HEADER_PADDING, 100)
|
||||
if rl.check_collision_recs(state_rect, rect):
|
||||
rl.draw_rectangle_rounded(state_rect, 0.12, 16, CARD_BACKGROUND)
|
||||
connecting = self._status and self._status.state in ("connecting", "reconnecting", "initializing")
|
||||
msg = tr("Connecting to adapter to load sensor telemetry...") if connecting else tr(
|
||||
"Connect to adapter to view live sensor telemetry."
|
||||
)
|
||||
gui_label(rl.Rectangle(state_rect.x + 30, state_rect.y + 30, state_rect.width - 60, 40),
|
||||
msg, font_size=36, color=TEXT_SECONDARY)
|
||||
cur_y += 130
|
||||
|
||||
# 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()
|
||||
Reference in New Issue
Block a user