This commit is contained in:
firestarsdog
2026-08-30 04:28:08 -04:00
parent 6f49c1cfc9
commit 2334df399b
46 changed files with 9916 additions and 87 deletions
+1
View File
@@ -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),
+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, 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)
+492
View File
@@ -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()