""" Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos Consolidated IQ.Pilot settings panels (display, visuals, network, maps, steering and its lane-change / steering-assist sub-panels) in one module. """ from iqpilot.cereal import car from collections.abc import Callable from enum import Enum from enum import IntEnum from iqpilot.common.constants import CV from iqpilot.common.params import Params from iqpilot.selfdrive.ui.ui_state import OnroadBrightness from iqpilot.common.swaglog import cloudlog from iqpilot.selfdrive.controls.lib.helpers.lane_change import AutoLaneChangeMode from iqpilot.ui.onroad.offline_tiles import offline_map_root from iqpilot.selfdrive.ui.layouts.settings.software import time_ago from iqpilot.selfdrive.ui.ui_state import device, ui_state from iqpilot.selfdrive.ui.ui_state import ui_state from iqpilot.system.hardware.hw import Paths from iqpilot.system.ui.iqwidgets.lib.styles import style from iqpilot.system.ui.iqwidgets.lib.utils import WideButtonAction from iqpilot.system.ui.iqwidgets.widgets.list_view import IQListItem, toggle_item from iqpilot.system.ui.iqwidgets.widgets.list_view import OptionControl from iqpilot.system.ui.iqwidgets.widgets.list_view import multiple_button_item, toggle_item from iqpilot.system.ui.iqwidgets.widgets.list_view import option_item, toggle_item, ToggleAction from iqpilot.system.ui.iqwidgets.widgets.list_view import progress_item from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item, multiple_button_item, IQListItem, IQLineSeparator from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item, option_item, IQLineSeparator from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item, simple_button_item, option_item, IQLineSeparator from iqpilot.system.ui.iqwidgets.widgets.list_view import PickerGroup, PickerItem, PickerDialog from iqpilot.system.ui.lib.application import gui_app from iqpilot.system.ui.lib.multilang import tr from iqpilot.system.ui.lib.multilang import tr, tr_noop from iqpilot.system.ui.widgets import DialogResult, Widget from iqpilot.system.ui.widgets import Widget from iqpilot.system.ui.widgets.button import Button, ButtonStyle from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog from iqpilot.system.ui.widgets.list_view import text_item from iqpilot.system.ui.widgets.network import NavButton from iqpilot.system.ui.widgets.network import NetworkUI, PanelType as NetworkPanelType, TEAL from iqpilot.system.ui.widgets.scroller_tici import Scroller from pathlib import Path from time import monotonic import datetime import os import platform import pyray as rl import requests import shutil import threading import time from iqpilot.cereal import custom from dataclasses import dataclass from iqpilot.selfdrive.car.vehicle_catalog import load_catalog from iqpilot.selfdrive.iqmodeld.models.helpers import select_default_model, is_default_bundle from iqpilot.selfdrive.iqmodeld.models.runners.model_runner import CUSTOM_MODEL_PATH from iqpilot.selfdrive.ui.layouts.settings import settings as OP from iqpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout from iqpilot.system.hardware import HARDWARE from iqpilot.system.ui.iqwidgets.lib.styles import metrics from iqpilot.system.ui.iqwidgets.widgets.list_view import ( option_item as option_item, multiple_button_item as multiple_button_item, button_item as button_item, dual_button_item as dual_button_item, NavSectionButton, Spacer, ) from iqpilot.system.ui.iqwidgets.widgets.list_view import IQListItem from iqpilot.system.ui.iqwidgets.widgets.list_view import IQListItem, IQMultipleButtonAction, IQToggleAction, IQLineSeparator, toggle_item_iq from iqpilot.system.ui.iqwidgets.widgets.list_view import button_item, toggle_item from iqpilot.system.ui.iqwidgets.widgets.list_view import NoticeModal from iqpilot.system.ui.iqwidgets.widgets.list_view import PickerDialog, PickerItem, PickerGroup from iqpilot.system.ui.lib.application import gui_app, MousePos from iqpilot.system.ui.lib.multilang import tr_noop from iqpilot.system.ui.lib.text_measure import measure_text_cached from iqpilot.system.ui.lib.wifi_manager import WifiManager from iqpilot.system.ui.widgets import DialogResult from iqpilot.system.ui.widgets.button import ButtonStyle from iqpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog from iqpilot.system.ui.widgets.list_view import button_item from iqpilot.system.ui.widgets.option_dialog import MultiOptionDialog from iqpilot.system.ui.widgets.scroller_tici import LineSeparator from iqpilot.system.ui.widgets.toggle import ON_COLOR import re from iqpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout from iqpilot.selfdrive.ui.layouts.settings.device import DeviceLayout from iqpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout, time_ago from functools import partial from iqdbc.car.hyundai.values import CAR, CANFD_UNSUPPORTED_LONGITUDINAL_CAR, UNSUPPORTED_LONGITUDINAL_CAR from iqdbc.car.subaru.values import CAR, SubaruFlags from iqdbc.car.volkswagen.values import CAR, VolkswagenFlags from iqpilot.common.basedir import BASEDIR from iqpilot.system.ui.iqwidgets.lib.styles import ink from iqpilot.system.ui.iqwidgets.widgets.list_view import multiple_button_item from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item from iqpilot.system.ui.lib.application import gui_app, FontWeight from iqpilot.system.ui.widgets.list_view import ButtonAction import json ONROAD_BRIGHTNESS_TIMER_VALUES = {0: 15, 1: 30, **{i: (i - 1) * 60 for i in range(2, 12)}} def _fmt_seconds(value) -> str: return f"{value} s" if value < 60 else f"{int(value / 60)} m" def onroad_brightness_label(val) -> str: if val == OnroadBrightness.AUTO: return tr("Auto") if val == OnroadBrightness.AUTO_DARK: return tr("Auto — Dim") return f"{(val - 1) * 5} %" class DisplayLayout(Widget): def __init__(self): super().__init__() self._params = Params() self._scroller = Scroller(self._build_rows(), line_separator=True, spacing=0) def _build_rows(self): self._force_mici_ui = toggle_item( title=lambda: tr("Force Mici UI"), description=lambda: tr("Run the compact comma 4 (mici) interface on this device") if self._params.get_bool("ForceSmallUI") else "", param="ForceSmallUI", ) self._brightness_row = option_item( param="Brightness", title=lambda: tr("Display Brightness"), description="", min_value=0, max_value=100, value_change_step=5, label_callback=lambda v: tr("Default") if v == 0 else f"{v} %", inline=True, ) self._onroad_brightness_row = option_item( param="OnroadScreenOffBrightness", title=lambda: tr("Driving Brightness"), description="", min_value=0, max_value=21, value_change_step=1, label_callback=onroad_brightness_label, inline=True, ) self._dim_delay_row = option_item( param="OnroadScreenOffTimer", title=lambda: tr("Dim Delay"), description="", min_value=0, max_value=11, value_change_step=1, value_map=ONROAD_BRIGHTNESS_TIMER_VALUES, label_callback=_fmt_seconds, inline=True, ) self._idle_close_row = option_item( param="InteractivityTimeout", title=lambda: tr("Auto-Close Settings"), description=lambda: tr("How long the settings screen may sit untouched before it closes itself."), min_value=0, max_value=120, value_change_step=10, label_callback=lambda v: tr("Default") if not v else _fmt_seconds(v), inline=True, ) return [ self._force_mici_ui, self._brightness_row, self._onroad_brightness_row, self._dim_delay_row, self._idle_close_row, ] update_onroad_brightness = staticmethod(onroad_brightness_label) def _update_state(self): super()._update_state() for row in self._scroller._items: action = row.action_item if isinstance(action, ToggleAction) and action.toggle.param_key is not None: action.set_state(self._params.get_bool(action.toggle.param_key)) elif isinstance(action, OptionControl) and action.param_key is not None: action.set_value(self._params.get(action.param_key, return_default=True)) mode = self._params.get("OnroadScreenOffBrightness", return_default=True) self._dim_delay_row.action_item.set_enabled(mode not in (OnroadBrightness.AUTO, OnroadBrightness.AUTO_DARK)) def _render(self, rect): self._scroller.render(rect) def show_event(self): self._scroller.show_event() CHEVRON_INFO_DESCRIPTION = { "enabled": tr_noop("Pins live readouts (gap, speed, time-to-lead) to the chevron marking the car ahead. " "Needs IQ.Pilot to be the one controlling gas and brake."), "disabled": tr_noop("Unavailable — IQ.Pilot longitudinal control is not active on this car."), } _HUD_TOGGLES = { "IQBlindSpotAlerts": ( lambda: tr("Blind Spot Alerts"), lambda: tr("Flashes a side warning whenever the car reports something sitting in your blind spot (BSM-equipped cars only)."), ), "IQExpandedStatus": ( lambda: tr("Expanded Status Bar"), lambda: tr("Bring back the classic UI's wide offroad status strip: temperature, vehicle, and Konn3kt state at a glance."), ), "IQSteerEffortArc": ( lambda: tr("Steering Effort Arc"), lambda: tr("Trace an arc over the road view showing how much steering IQ.Pilot is applying while lateral control runs."), ), "IQRoadNameOverlay": ( lambda: tr("Road Name Overlay"), lambda: tr("Show the current road's name over the driving view." "
Requires offline map data for your region to be installed."), ), "IQBlinkerIndicators": ( lambda: tr("Blinker Indicators"), lambda: tr("Mirror the car's blinkers as arrows on the driving screen."), ), "IQAccelMeter": ( lambda: tr("Acceleration Meter"), lambda: tr("Draw a bar along the left edge tracking measured acceleration and braking — what the car is actually " "doing right now, not the planner's request."), ), } _SECTION_TITLE_COLOR = rl.Color(16, 185, 169, 255) class VisualsLayout(Widget): def __init__(self): super().__init__() self._params = Params() self._display_layout = DisplayLayout() self._scroller = Scroller(self._build_rows(), line_separator=True, spacing=0) def _build_rows(self): self._toggles = { key: toggle_item(title=title, description=desc, param=key, initial_state=ui_state.params.get_bool(key)) for key, (title, desc) in _HUD_TOGGLES.items() } self._chevron_info = multiple_button_item( title=lambda: tr("Lead Vehicle Readouts"), description="", buttons=[lambda: tr("Off"), lambda: tr("Distance"), lambda: tr("Speed"), lambda: tr("Time"), lambda: tr("All")], param="IQLeadReadouts", inline=False, ) self._dev_ui_info = toggle_item( title=lambda: tr("Onroad Developer UI"), description=lambda: tr("Overlay a bar of live control metrics (steering, lateral accel, lead data) while driving."), initial_state=bool(int(ui_state.params.get("IQDevUIInfo", return_default=True))), callback=lambda on: ui_state.params.put("IQDevUIInfo", 3 if on else 0), ) display_rows = self._display_layout._scroller._items[1:] return [ IQListItem(title=lambda: tr("Display"), description="", action_item=None, inline=True, title_color=_SECTION_TITLE_COLOR), IQLineSeparator(20), *display_rows, IQLineSeparator(60), *self._toggles.values(), self._chevron_info, self._dev_ui_info, ] def _sync_chevron_row(self): if ui_state.has_longitudinal_control: self._chevron_info.set_description(tr(CHEVRON_INFO_DESCRIPTION["enabled"])) self._chevron_info.action_item.set_selected_button(ui_state.params.get("IQLeadReadouts", return_default=True)) self._chevron_info.action_item.set_enabled(True) else: self._chevron_info.set_description(tr(CHEVRON_INFO_DESCRIPTION["disabled"])) self._chevron_info.action_item.set_enabled(False) ui_state.params.put("IQLeadReadouts", 0) def _update_state(self): super()._update_state() for key, row in self._toggles.items(): row.action_item.set_state(self._params.get_bool(key)) self._dev_ui_info.action_item.set_state(bool(int(ui_state.params.get("IQDevUIInfo", return_default=True)))) self._sync_chevron_row() def _render(self, rect): self._scroller.render(rect) def show_event(self): self._display_layout.show_event() self._scroller.show_event() if not ui_state.has_longitudinal_control: self._chevron_info.set_description(tr(CHEVRON_INFO_DESCRIPTION["disabled"])) self._chevron_info.show_description(True) _IDLE_BG = rl.Color(52, 55, 62, 255) class ScanPhase(Enum): IDLE = 0 RUNNING = 1 FAILED = 2 class IQNetworkUI(NetworkUI): def __init__(self, wifi_manager): super().__init__(wifi_manager) self._phase = ScanPhase.IDLE self.scan_button = Button(lambda: tr("Searching...") if self._phase == ScanPhase.RUNNING else tr("Scan"), self._start_scan, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT, font_size=60, border_radius=30) self.scan_button.set_rect(rl.Rectangle(0, 0, 400, 100)) self._wifi_manager.add_callbacks(networks_updated=self._on_networks_updated) def _set_phase(self, phase: ScanPhase): self._phase = phase running = phase == ScanPhase.RUNNING self.scan_button.set_enabled(not running) self._wifi_manager._scanning = running def _start_scan(self): self._set_phase(ScanPhase.RUNNING) threading.Thread(target=self._scan_worker, daemon=True).start() def _scan_worker(self): try: self._wifi_manager._update_networks() self._wifi_manager._request_scan() self._wifi_manager._last_network_update = time.monotonic() except Exception: cloudlog.exception("IQNetworkUI scan failed") self._phase = ScanPhase.FAILED def _on_networks_updated(self, networks): if self._phase == ScanPhase.RUNNING: self._set_phase(ScanPhase.IDLE) def _render(self, rect: rl.Rectangle): super()._render(rect) if self._phase == ScanPhase.FAILED: self._set_phase(ScanPhase.IDLE) if self._current_panel == NetworkPanelType.WIFI: self.scan_button.set_position(self._rect.x, self._rect.y + 20) r = self.scan_button.rect bg = TEAL if self.scan_button.enabled else _IDLE_BG rl.draw_rectangle_rounded(r, 30 / (min(r.width, r.height) / 2), 10, bg) self.scan_button.render() MAP_PATH = Path(Paths.mapd_root()) / "offline" OFFLINE_TILES_PATH = offline_map_root() / "regions" _BOUNDS_BASE_URL = "https://raw.githubusercontent.com/pfeiferj/openpilot-mapd/main/" _REGION_PARAMS = ("OsmDownloadedDate", "OsmLocal", "OsmLocationName", "OsmLocationTitle", "OsmStateName", "OsmStateTitle") def _fmt_bytes(n: int) -> str: return f"{n / 1024 ** 2:.2f} MB" if n < 1024 ** 3 else f"{n / 1024 ** 3:.2f} GB" def _tree_size(*roots: Path) -> int: total = 0 pending = [p for p in roots if p.exists()] while pending: try: for entry in os.scandir(pending.pop()): if entry.is_file(): total += entry.stat().st_size elif entry.is_dir(): pending.append(entry.path) except OSError: pass return total class IQMapsLayout(Widget): def __init__(self): super().__init__() self._current_percent = 0 self._last_sync = 0 self._mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else ui_state.params self._build_rows() self._refresh_disk_usage() self._progress.set_visible(False) self._tile_progress.set_visible(False) self._state_btn.set_visible(False) self._mapd_version.action_item.set_text(ui_state.params.get("MapdVersion") or "Loading...") self._scroller = Scroller(self.items, line_separator=True, spacing=0) def _build_rows(self): self._mapd_version = text_item(tr("Routing Engine Version"), lambda: ui_state.params.get("MapdVersion") or "Loading...") self._online_maps_toggle = toggle_item( tr("Online On-Screen Maps"), tr("Pull live Mapbox tiles for the on-screen map whenever the device has internet."), param="OnlineOSMaps", ) self._offline_maps_toggle = toggle_item( tr("Offline On-Screen Maps"), tr("Draw the on-screen map from tiles stored on the device. Combined with Online, stored tiles " "take over the moment connectivity drops. Tiles come down with your selected region."), param="OfflineOSMaps", ) self._delete_maps_btn = IQListItem(tr("Stored Maps"), action_item=WideButtonAction(tr("DELETE"), enabled=True), callback=self._confirm_wipe) self._progress = progress_item(tr("Fetching Map")) self._tile_progress = progress_item(tr("Downloading Map Tiles")) self._update_btn = IQListItem(tr("Map Database"), action_item=WideButtonAction(tr("CHECK"), enabled=True), callback=self._confirm_db_refresh) self._country_btn = IQListItem(tr("Country"), action_item=WideButtonAction(tr("SELECT"), enabled=True), callback=lambda: self._open_region_picker("Country")) self._state_btn = IQListItem(tr("State"), action_item=WideButtonAction(tr("SELECT"), enabled=True), callback=lambda: self._open_region_picker("State")) self.items = [self._mapd_version, self._online_maps_toggle, self._offline_maps_toggle, self._delete_maps_btn, self._progress, self._tile_progress, self._update_btn, self._country_btn, self._state_btn] def _refresh_disk_usage(self): def worker(): self._delete_maps_btn.action_item.set_value(_fmt_bytes(_tree_size(MAP_PATH, OFFLINE_TILES_PATH))) threading.Thread(target=worker, daemon=True).start() def _confirm_wipe(self): self._ask(tr("This will delete ALL downloaded maps\n\nAre you sure you want to delete all maps?"), tr("Yes, delete all maps"), self._start_wipe) def _start_wipe(self): self._delete_maps_btn.action_item.set_enabled(False) self._delete_maps_btn.action_item.set_text(tr("REMOVING...")) threading.Thread(target=self._wipe_worker, daemon=True).start() def _wipe_worker(self): for root in (MAP_PATH, OFFLINE_TILES_PATH): if root.exists(): shutil.rmtree(root) for param in _REGION_PARAMS: ui_state.params.remove(param) self._delete_maps_btn.action_item.set_enabled(True) self._delete_maps_btn.action_item.set_text(tr("DELETE")) self._refresh_disk_usage() @staticmethod def _ask(msg, confirm_text, then): gui_app.set_modal_overlay(ConfirmDialog(msg, confirm_text), lambda res: then() if res == DialogResult.CONFIRM else None) def _confirm_db_refresh(self): self._ask(tr("This will start the download process and it might take a while to complete."), tr("Start Download"), self._request_db_download) def _request_db_download(self): ui_state.params.put_bool("OsmDbUpdatesCheck", True) nations = [c] if (c := ui_state.params.get("OsmLocationName") or "") else [] states = [s] if (s := ui_state.params.get("OsmStateName") or "") else [] if "US" in nations and states: if any(x.lower() == "all" for x in states): states = [x for x in states if x.lower() != "all"] else: nations.remove("US") self._mem_params.put("OSMDownloadLocations", {"nations": nations, "states": states}) def _open_region_picker(self, region_type): btn = self._country_btn if region_type == "Country" else self._state_btn btn.action_item.set_enabled(False) btn.action_item.set_text(tr("LOADING...")) threading.Thread(target=self._region_picker_worker, args=(region_type, btn), daemon=True).start() @staticmethod def _fetch_regions(region_type): url = _BOUNDS_BASE_URL + ("nation_bounding_boxes.json" if region_type == "Country" else "us_states_bounding_boxes.json") try: data = requests.get(url, timeout=10).json() return sorted((PickerItem(ref=k, data={'display_name': v['full_name']}) for k, v in data.items()), key=lambda n: n.data['display_name']) except Exception: return [] def _region_picker_worker(self, region_type, btn): locations = self._fetch_regions(region_type) if region_type == "State": locations.insert(0, PickerItem(ref="All", data={'display_name': tr("All states (~6.0 GB)")})) btn.action_item.set_enabled(True) btn.action_item.set_text(tr("SELECT")) key = "OsmLocation" if region_type == "Country" else "OsmState" current = ui_state.params.get(f"{key}Name") or "" dialog = PickerDialog(tr(f"Select {region_type}"), [PickerGroup(folder="", nodes=locations)], current_ref=current, search_prompt=tr("Perform a search")) done = lambda res: self._on_region_picked(region_type, locations, key, res, dialog.selection_ref) # noqa: E731 dialog.on_exit = done gui_app.set_modal_overlay(dialog, callback=done) def _on_region_picked(self, region_type, locations, key, res, ref): if res != DialogResult.CONFIRM or not ref: if region_type == "State" and res == DialogResult.CANCEL: if ui_state.params.get("OsmLocationName") == "US" and not ui_state.params.get("OsmStateName"): for param in ("OsmLocationName", "OsmLocationTitle", "OsmLocal"): ui_state.params.remove(param) self._sync_rows() return if region_type == "Country": ui_state.params.put_bool("OsmLocal", True) ui_state.params.remove("OsmStateName") ui_state.params.remove("OsmStateTitle") ui_state.params.put(f"{key}Name", ref) title = next((n.data['display_name'] for n in locations if n.ref == ref), ref) ui_state.params.put(f"{key}Title", title) if ref == "US" and region_type == "Country": self._open_region_picker("State") else: self._confirm_db_refresh() def _sync_tile_progress(self): if not self._mem_params.get("OfflineTilesDownloadRequest"): self._tile_progress.set_visible(False) return device._reset_interactive_timeout() self._refresh_disk_usage() self._tile_progress.set_visible(True) progress = self._mem_params.get("OfflineTilesDownloadProgress") or {} total = progress.get("total_bytes", 0) done = progress.get("downloaded_bytes", 0) if total > 0: pct = max(0.0, min(100.0, done / total * 100.0)) self._tile_progress.action_item.update(pct, f"{int(pct)}% - Downloading Map Tiles", show_progress=True) else: self._tile_progress.action_item.update(0.0, tr("Preparing Map Tiles..."), show_progress=False) def _sync_db_progress(self, downloading: bool): self._progress.set_visible(True) progress = ui_state.params.get("OSMDownloadProgress") or {} total = progress.get('total_files', 0) done = progress.get('downloaded_files', 0) failed = total > 0 and not downloading and done < total if failed: self._current_percent = 0.0 bar_text, btn_text = "0% - Downloading Maps", tr("Download failed — try again.") elif total > 0 and downloading: self._current_percent = max(0.0, min(100.0, done / total * 100.0)) pct = int(self._current_percent) bar_text, btn_text = f"{pct}% - Downloading Maps", f"{done}/{total} ({pct}%)" else: self._current_percent = 0.0 bar_text, btn_text = "0% - Downloading Maps", tr("Fetching Maps...") self._progress.action_item.update(self._current_percent, bar_text, show_progress=total > 0 and downloading and not failed) self._update_btn.action_item.set_value(btn_text) def _set_rows_busy(self, busy: bool): for row in (self._update_btn, self._country_btn, self._state_btn, self._delete_maps_btn): row.action_item.set_enabled(not busy) def _sync_rows(self): downloading = bool(self._mem_params.get("OSMDownloadLocations")) self._sync_tile_progress() self._country_btn.set_enabled(not downloading) self._state_btn.set_enabled(not downloading) self._state_btn.set_visible(ui_state.params.get("OsmLocationName") == "US") self._update_btn.set_visible(bool(ui_state.params.get("OsmLocationName"))) self._country_btn.action_item.set_value(ui_state.params.get("OsmLocationTitle") or "") self._state_btn.action_item.set_value(ui_state.params.get("OsmStateTitle") or "") if downloading or ui_state.params.get_bool("OsmDbUpdatesCheck"): if downloading: device._reset_interactive_timeout() self._refresh_disk_usage() self._sync_db_progress(downloading) self._set_rows_busy(downloading) # TODO-IQ: introduce CANCEL database download with mapd return self._progress.set_visible(False) self._set_rows_busy(False) dt = None if ts := ui_state.params.get("OsmDownloadedDate"): try: if float(ts) > 0: dt = datetime.datetime.fromtimestamp(float(ts), tz=datetime.UTC) except (ValueError, TypeError): dt = None self._update_btn.action_item.set_value(tr("Checked {}").format(time_ago(dt))) def _update_state(self): now = monotonic() if now - self._last_sync >= 1.0: self._last_sync = now self._sync_rows() def _render(self, rect): self._scroller.render(rect) def show_event(self): self._scroller.show_event() _TIMER_LABELS = { -1: lambda: tr("Off"), 0: lambda: tr("Nudge"), 1: lambda: tr("No Nudge"), 2: lambda: f"0.5 {tr('s')}", 3: lambda: f"1 {tr('s')}", 4: lambda: f"2 {tr('s')}", 5: lambda: f"3 {tr('s')}", } class LaneChangeSettingsLayout(Widget): def __init__(self, back_btn_callback: Callable): super().__init__() self._back_button = NavButton(tr("Back")) self._back_button.set_click_callback(back_btn_callback) self._scroller = Scroller(self._build_rows(), line_separator=False, spacing=0) def _build_rows(self): self._lane_change_timer = option_item( title=lambda: tr("Blinker-Initiated Lane Change"), param="IQLaneChangeTimer", description=lambda: tr("Delay before a blinker-triggered lane change starts on its own — no wheel nudge " "needed once a delay is set (default is Nudge).
Use the blinker for this only " "when traffic and the road actually allow the maneuver."), min_value=-1, max_value=5, value_change_step=1, label_callback=lambda x: _TIMER_LABELS[int(x)](), ) self._bsm_delay = toggle_item( param="IQLaneChangeBsmDelay", title=lambda: tr("Hold Lane Change for Blind Spot"), description=lambda: tr("Hold the automatic lane change while blind spot monitoring reports a car in the " "target lane, releasing it once the lane is clear."), ) self._edge_guard = toggle_item( param="IQEdgeGuard", title=lambda: tr("Lane Edge Guard"), description=lambda: tr("Blocks lane changes when a road edge is detected on the target side."), ) self._continuous = toggle_item( param="LaneChangeContinuous", title=lambda: tr("Auto Lane Change: Continuous Changes"), description=lambda: tr("Normally one maneuver ends the lane change even with the blinker held. Turn this on " "to chain further changes while the blinker stays on — each follow-up still wants a " "wheel nudge."), ) return [ self._lane_change_timer, IQLineSeparator(40), self._bsm_delay, IQLineSeparator(40), self._edge_guard, IQLineSeparator(40), self._continuous, ] def _update_state(self): super()._update_state() has_bsm = bool(ui_state.CP and ui_state.CP.enableBsm) if not has_bsm and ui_state.params.get_bool("IQLaneChangeBsmDelay"): ui_state.params.remove("IQLaneChangeBsmDelay") timer_armed = ui_state.params.get("IQLaneChangeTimer", return_default=True) > AutoLaneChangeMode.NUDGE self._bsm_delay.action_item.set_enabled(has_bsm and timer_armed) def _render(self, rect): self._back_button.set_position(self._rect.x, self._rect.y + 20) self._back_button.render() below_button = self._back_button.rect.height + 40 self._scroller.render(rl.Rectangle(rect.x, rect.y + below_button, rect.width, rect.height - below_button)) def show_event(self): self._scroller.show_event() SAB_BRAKE_RESPONSE_OPTIONS = [ (tr_noop("Stay Engaged"), tr_noop("Remain Active: braking never interrupts steering assistance.")), (tr_noop("Standby"), tr_noop("Standby: braking parks steering assistance; it rejoins once you're off the pedal.")), (tr_noop("Disengage"), tr_noop("Disengage: braking shuts steering assistance off entirely.")), ] _MODE_DISENGAGE = 2 _MODE_STANDBY = 1 SAB_AVAILABILITY_DESC = tr("Note: on cars lacking an LFA/LKAS button, turning this off keeps steering assistance " "from arming as cruise availability flips.") SAB_DRIVER_INTERVENTION_DESC = tr("Pick what a brake-pedal press does to steering assistance.") SAB_STEERING_OVERRIDE_DESC = tr( "Steering assistance stands by while you steer yourself and rejoins the moment you let go. It stays armed the whole time; only the torque stops." ) DEFAULT_TO_OFF = tr("Locked OFF on this platform; the vehicle can't support other choices.") STATUS_DISENGAGE_ONLY = tr("This platform is limited to Disengage mode.") class SabSettingsLayout(Widget): def __init__(self, back_btn_callback: Callable): super().__init__() self._back_button = NavButton(tr("Back")) self._back_button.set_click_callback(back_btn_callback) self._scroller = Scroller(self._build_rows(), line_separator=True, spacing=0) def _build_rows(self): self._main_cruise_toggle = toggle_item( title=lambda: tr("Availability While Cruise Changes"), description=SAB_AVAILABILITY_DESC, param="AolMainCruiseAllowed", ) self._disengage_on_brake_toggle = toggle_item( title=lambda: tr("Driver Intervention Handling"), description=SAB_DRIVER_INTERVENTION_DESC, initial_state=False, callback=self._on_brake_shortcut_toggled, ) self._steering_mode = multiple_button_item( param="AolSteeringMode", title=lambda: tr("Brake Response Mode"), description="", buttons=[lambda label=label: tr(label) for label, _ in SAB_BRAKE_RESPONSE_OPTIONS], inline=False, button_width=350, callback=self._refresh_mode_description, ) self._pause_on_steering_override_toggle = toggle_item( title=lambda: tr("Pause While You Steer"), description=SAB_STEERING_OVERRIDE_DESC, param="AolPauseOnSteeringOverride", ) return [self._main_cruise_toggle, self._disengage_on_brake_toggle, self._steering_mode, self._pause_on_steering_override_toggle] @staticmethod def _has_limited_sab_options() -> bool: brand = "" if ui_state.is_offroad(): if bundle := ui_state.params.get("CarPlatformBundle"): brand = bundle.get("brand", "") if not brand and ui_state.CP: brand = ui_state.CP.brand return brand == "rivian" def _on_brake_shortcut_toggled(self, enabled: bool): current = int(ui_state.params.get("AolSteeringMode", return_default=True)) if enabled: target = _MODE_DISENGAGE elif current == _MODE_DISENGAGE: target = _MODE_STANDBY else: return ui_state.params.put("AolSteeringMode", target) self._steering_mode.action_item.set_selected_button(target) def _refresh_mode_description(self, selected: int): lines = [tr("Pick how steering assistance reacts once the driver brakes in IQ.Pilot."), ""] for i, (_, blurb) in enumerate(SAB_BRAKE_RESPONSE_OPTIONS): text = tr(blurb) lines.append(f"{text}" if i == selected else text) self._steering_mode.set_description("
".join(lines)) self._steering_mode.show_description(True) def _apply_limited_platform(self): ui_state.params.remove("AolMainCruiseAllowed") ui_state.params.put_bool("AolUnifiedEngagementMode", True) ui_state.params.put("AolSteeringMode", _MODE_DISENGAGE) self._main_cruise_toggle.action_item.set_enabled(False) self._main_cruise_toggle.action_item.set_state(False) self._main_cruise_toggle.set_description(f"{DEFAULT_TO_OFF}
{SAB_AVAILABILITY_DESC}") self._disengage_on_brake_toggle.action_item.set_enabled(False) self._disengage_on_brake_toggle.action_item.set_state(True) self._disengage_on_brake_toggle.set_description(STATUS_DISENGAGE_ONLY) self._steering_mode.action_item.set_enabled(False) self._steering_mode.set_description(STATUS_DISENGAGE_ONLY) self._steering_mode.action_item.set_selected_button(_MODE_DISENGAGE) def _apply_full_platform(self): self._main_cruise_toggle.action_item.set_enabled(True) self._main_cruise_toggle.set_description(SAB_AVAILABILITY_DESC) self._disengage_on_brake_toggle.action_item.set_enabled(True) self._disengage_on_brake_toggle.set_description(SAB_DRIVER_INTERVENTION_DESC) self._steering_mode.action_item.set_enabled(True) def _update_state(self): super()._update_state() if ui_state.params.get_bool("AolEnabled"): ui_state.params.put_bool("AolUnifiedEngagementMode", True) current = int(ui_state.params.get("AolSteeringMode", return_default=True)) self._disengage_on_brake_toggle.action_item.set_state(current == _MODE_DISENGAGE) self._refresh_mode_description(self._steering_mode.action_item.get_selected_button()) if self._has_limited_sab_options(): self._apply_limited_platform() else: self._apply_full_platform() def _render(self, rect): self._back_button.set_position(self._rect.x, self._rect.y + 20) self._back_button.render() below_button = self._back_button.rect.height + 40 self._scroller.render(rl.Rectangle(rect.x, rect.y + below_button, rect.width, rect.height - below_button)) def show_event(self): self._scroller.show_event() class PanelType(IntEnum): STEERING = 0 SAB = 1 LANE_CHANGE = 2 class SteeringLayout(Widget): def __init__(self): super().__init__() self._current_panel = PanelType.STEERING self._lane_change_settings_layout = LaneChangeSettingsLayout(lambda: self._set_current_panel(PanelType.STEERING)) self._sab_settings_layout = SabSettingsLayout(lambda: self._set_current_panel(PanelType.STEERING)) items = self._initialize_items() self._scroller = Scroller(items, line_separator=False, spacing=0) for ctrl, key in [(self._lane_turn_value_control, "IQLaneTurnValue"), (self._delay_control, "IQSoftwareSteerDelay")]: ctrl.action_item.set_value(int(float(ui_state.params.get(key, return_default=True)) * 100)) def _initialize_items(self): self._aol_base_desc = tr("Enable Always on Lateral (AOL). Disable this toggle to return to stock IQ.Pilot steering engagement behavior.") self._sab_limited_desc = tr("This platform supports a limited set of steering assistance behavior options.") self._sab_full_desc = tr("This platform supports the full steering assistance behavior configuration.") self._sab_check_compat_desc = tr("Start the vehicle to check steering assistance behavior compatibility.") self._aol_toggle = toggle_item( param="AolEnabled", title=lambda: tr("Always on Lateral (AOL)"), description=self._aol_base_desc, ) self._aol_settings_button = simple_button_item( button_text=lambda: tr("Steering Assistance Behavior"), button_width=800, callback=lambda: self._set_current_panel(PanelType.SAB) ) self._lane_change_settings_button = simple_button_item( button_text=lambda: tr("Lane Change Options"), button_width=800, callback=lambda: self._set_current_panel(PanelType.LANE_CHANGE) ) self._nnff_toggle = toggle_item( param="NeuralNetworkFeedForward", title=lambda: tr("Neural Network Feed Forward (NNFF)"), description="" ) self._lane_turn_desire_toggle = toggle_item( lambda: tr("Low-Speed Turn Planning"), lambda: tr("If you're driving at 20 mph (32 km/h) or below and have your blinker on," " the car will plan a turn in that direction at the nearest drivable path." " This prevents situations (like at red lights) where the car might plan the wrong turn direction."), param="IQLaneTurnDesire" ) self._lane_turn_value_control = option_item( tr("Adjust Lane Turn Desire Speed"), "IQLaneTurnValue", 500, 2000, tr("Sets the speed threshold used by Lane Turn Desires. Below this value, turn desires can plan into the nearest drivable turn path. Default is 19 mph."), int(round(100 / CV.MPH_TO_KPH)), None, True, "", style.BUTTON_ACTION_WIDTH, None, True, lambda v: f"{int(round(v * (CV.MPH_TO_KPH if ui_state.is_metric else 1)))}" f" {'km/h' if ui_state.is_metric else 'mph'}" ) self._steer_delay_toggle = toggle_item(lambda: tr("Self-Tuning Steer Delay"), "", param="IQLiveSteerDelay") self._delay_control = option_item( tr("Manual Delay Offset"), "IQSoftwareSteerDelay", 5, 50, tr("How much lead time to add on top of the car's own steering rack delay while self-tuning is off. Default is 0.2 s."), 1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True, lambda v: f"{float(v):.2f}s" ) items = [ self._aol_toggle, self._nnff_toggle, IQLineSeparator(40), self._aol_settings_button, IQLineSeparator(40), self._lane_change_settings_button, IQLineSeparator(40), self._lane_turn_desire_toggle, self._lane_turn_value_control, IQLineSeparator(40), self._steer_delay_toggle, self._delay_control, ] return items def _set_current_panel(self, panel: PanelType): self._current_panel = panel def _update_state(self): super()._update_state() steering_supported = True if ui_state.CP is not None: sab_desc = self._sab_limited_desc if self._sab_settings_layout._has_limited_sab_options() else self._sab_full_desc self._aol_toggle.set_description(f"{sab_desc}

{self._aol_base_desc}") if ui_state.CP.steerControlType == car.CarParams.SteerControlType.angle: ui_state.params.remove("NeuralNetworkFeedForward") steering_supported = False else: self._aol_toggle.set_description(f"{self._sab_check_compat_desc}

{self._aol_base_desc}") ui_state.params.remove("NeuralNetworkFeedForward") steering_supported = False self._aol_toggle.action_item.set_enabled(ui_state.is_offroad()) self._aol_settings_button.action_item.set_enabled(ui_state.is_offroad() and self._aol_toggle.action_item.get_state()) self._nnff_toggle.action_item.set_enabled(ui_state.is_offroad() and steering_supported) turn_desire = ui_state.params.get_bool("IQLaneTurnDesire") live_delay = ui_state.params.get_bool("IQLiveSteerDelay") self._lane_turn_desire_toggle.action_item.set_state(turn_desire) self._lane_turn_value_control.set_visible(turn_desire) self._steer_delay_toggle.action_item.set_state(live_delay) self._delay_control.set_visible(not live_delay) new_step = int(round(100 / CV.MPH_TO_KPH)) if ui_state.is_metric else 100 if self._lane_turn_value_control.action_item.value_change_step != new_step: self._lane_turn_value_control.action_item.value_change_step = new_step delay_desc = tr("Let IQ.Pilot measure how long your steering takes to respond and keep that figure up to date. " "Switch it off to pin the timing yourself.") if live_delay: delay_desc += f"
{tr('Measured:')} {ui_state.sm['lateralDelay'].lateralDelay:.3f} s" elif ui_state.CP: sw = float(ui_state.params.get("IQSoftwareSteerDelay", "0.2")) cp = ui_state.CP.steerActuatorDelay delay_desc += f"
{tr('Rack:')} {cp:.2f} s + {tr('Offset:')} {sw:.2f} s = {tr('Total:')} {cp + sw:.2f} s" self._steer_delay_toggle.set_description(delay_desc) def _render(self, rect): if self._current_panel == PanelType.LANE_CHANGE: self._lane_change_settings_layout.render(rect) elif self._current_panel == PanelType.SAB: self._sab_settings_layout.render(rect) else: self._scroller.render(rect) def show_event(self): self._set_current_panel(PanelType.STEERING) self._scroller.show_event() class CruiseLayout(Widget): def __init__(self): super().__init__() self._params = Params() self._scroller = Scroller(self._initialize_items(), line_separator=True, spacing=0) @staticmethod def _toggle_item(title: str, description: str, param: str): return IQListItem( title=lambda: tr(title), description=lambda: tr(description), action_item=IQToggleAction(initial_state=Params().get_bool(param), callback=lambda state: Params().put_bool(param, state), param=param), ) @staticmethod def _mode_item(title: str, description: str, param: str, labels: list[str], width: int, inline: bool = False): return IQListItem( title=lambda: tr(title), description=lambda: tr(description), action_item=IQMultipleButtonAction(buttons=[lambda s=s: tr(s) for s in labels], button_width=width, param=param), inline=inline, ) @staticmethod def _option_item(title: str, description: str, param: str, minimum: int, maximum: int, step: int = 1, label_callback=None, use_float_scaling: bool = False): return IQListItem( title=lambda: tr(title), description=lambda: tr(description), action_item=OptionControl( param=param, min_value=minimum, max_value=maximum, value_change_step=step, use_float_scaling=use_float_scaling, label_callback=label_callback, ), ) def _initialize_items(self): ms_to_mph = 2.23694 def speed_label(value: float | int) -> str: return f"{int(round(value * ms_to_mph))} mph" def seconds_label(value: float | int) -> str: return f"{float(value):.1f}s" def distance_label(value: float | int) -> str: value = int(value) return "Stock" if value == 0 else f"{value:+d} m" items = [ IQListItem(title=lambda: tr("Speed Limit Control"), description="", action_item=None, inline=True, title_color=rl.Color(16, 185, 169, 255)), IQLineSeparator(20), self._mode_item( "IQ Speed Limit Mode", "Choose how IQ.Pilot handles speed limit data. Control adjusts cruise speed. Warning only highlights overspeed.", "IQSpeedAssistMode", ["Off", "Info", "Warn", "Control"], 180, ), self._mode_item( "IQ SLC Policy", "Select how IQ.Pilot resolves conflicting speed limit sources.", "SLCPolicy", ["Map", "Priority", "Combined"], 180, ), self._toggle_item( "IQ SLC Confirm Higher", "Require confirmation before IQ.Pilot accepts a higher detected speed limit.", "SpeedLimitConfirmationHigher", ), self._toggle_item( "IQ SLC Confirm Lower", "Require confirmation before IQ.Pilot accepts a lower detected speed limit.", "SpeedLimitConfirmationLower", ), self._toggle_item( "IQ SLC Auto Confirm", "Automatically accept speed limit changes after a 5-second timeout without requiring a cruise button press.", "SLCAutoConfirm", ), self._toggle_item( "IQ SLC Fallback Set Speed", "If no speed limit is available, use the current set speed as the controller target.", "SLCFallbackSetSpeed", ), self._toggle_item( "IQ SLC Fallback Previous", "If a new limit is denied, reuse the previous accepted speed limit when possible.", "SLCFallbackPreviousSpeedLimit", ), self._toggle_item( "IQ SLC Fallback Experimental", "When no speed limit is available, let IQ.Dynamic request experimental longitudinal behavior.", "SLCFallbackExperimentalMode", ), self._toggle_item( "IQ SLC Online Filler", "Use online sources (TomTom + Mapbox) to fill in missing speed limits when local map data is unavailable.", "SLCOnlineFiller", ), self._option_item( "IQ Map Lookahead Higher", "How far ahead IQ.Pilot should apply an upcoming higher map speed limit before the limit changes.", "MapSpeedLookaheadHigher", 100, 1000, step=50, use_float_scaling=True, label_callback=seconds_label, ), self._option_item( "IQ Map Lookahead Lower", "How far ahead IQ.Pilot should apply an upcoming lower map speed limit before the limit changes.", "MapSpeedLookaheadLower", 100, 1000, step=50, use_float_scaling=True, label_callback=seconds_label, ), IQLineSeparator(60), IQListItem(title=lambda: tr("IQ.Dynamic"), description="", action_item=None, inline=True, title_color=rl.Color(16, 185, 169, 255)), IQLineSeparator(20), self._toggle_item( "IQ.Dynamic Curves", "Allow IQ.Dynamic to enter blended control for curves and strong vision slowdown cues.", "IQDynamicConditionalCurves", ), self._toggle_item( "IQ.Dynamic Slower Lead", "Allow IQ.Dynamic to switch toward blended control when a slower lead vehicle is detected.", "IQDynamicConditionalSlowerLead", ), self._toggle_item( "IQ.Dynamic Stopped Lead", "Allow IQ.Dynamic to react more aggressively when a lead vehicle is nearly stopped.", "IQDynamicConditionalStoppedLead", ), self._toggle_item( "IQ.Dynamic Model Stops", "Allow IQ.Dynamic to switch toward blended control for stop-sign and stop-light style vision stops.", "IQDynamicConditionalModelStops", ), self._toggle_item( "IQ.Dynamic SLC Fallback", "Allow IQ.Dynamic to request blended control when Speed Limit Controller has no usable target.", "IQDynamicConditionalSLCFallback", ), self._option_item( "IQ.Dynamic Low Speed", "Below this speed, IQ.Dynamic prefers blended control when no lead is present.", "IQDynamicConditionalSpeed", 500, 3500, step=50, use_float_scaling=True, label_callback=speed_label, ), self._option_item( "IQ.Dynamic Lead Speed", "Below this speed, IQ.Dynamic prefers blended control even with a tracked lead.", "IQDynamicConditionalLeadSpeed", 500, 4000, step=50, use_float_scaling=True, label_callback=speed_label, ), self._option_item( "IQ.Dynamic Model Stop Time", "Sets the vision stop prediction time horizon used by IQ.Dynamic and IQ Force Stops. Shorter values react later. Longer values react earlier.", "IQDynamicModelStopTime", 100, 600, step=25, use_float_scaling=True, label_callback=seconds_label, ), self._toggle_item( "IQ Force Stops", "Bring the car to a complete stop for stop signs and stop lights, and feather the brake in the final meters of " "every stop so it settles gently instead of rocking on its suspension. Yields full braking authority when a lead " "is close. Override with the accelerator.", "IQForceStops", ), self._option_item( "IQ Custom Stop Distance", "Nudge how far back IQ.Pilot stops behind a stopped lead vehicle or a model-held stop (red light). Positive " "stops further back, negative settles in closer. Works whether IQ Force Stops is on or off.", "IQCustomStopDistance", -2, 2, step=1, label_callback=distance_label, ), ] return items def _render(self, rect): self._scroller.render(rect) def show_event(self): self._scroller.show_event() class IQDeveloperLayout(DeveloperLayout): def __init__(self): super().__init__() self.error_log_path = os.path.join(Paths.crash_log_root(), "error.log") self._is_release_branch: bool = self._is_release or ui_state.params.get_bool("IsReleaseIqBranch") self._is_development_branch: bool = ui_state.params.get_bool("IsTestedBranch") or ui_state.params.get_bool("IsDevelopmentBranch") self._initialize_items() for item in self.items: self._scroller.add_widget(item) def _initialize_items(self): self.error_log_btn = button_item(lambda: tr("Crash Log"), lambda: tr("VIEW"), lambda: tr("View the error log for IQ.Pilot crashes."), callback=self._on_error_log_clicked) self.items: list = [self.error_log_btn] def _on_delete_confirm(self, result): if result == DialogResult.CONFIRM: if os.path.exists(self.error_log_path): os.remove(self.error_log_path) def _on_error_log_closed(self, result, log_exists): if result == DialogResult.CONFIRM and log_exists: dialog2 = ConfirmDialog(tr("Delete this log?"), tr("Yes"), tr("No"), rich=False) gui_app.set_modal_overlay(dialog2, callback=self._on_delete_confirm) def _on_error_log_clicked(self): text = "" if os.path.exists(self.error_log_path): text = f"{datetime.datetime.fromtimestamp(os.path.getmtime(self.error_log_path)).strftime('%d-%b-%Y %H:%M:%S').upper()}

" try: with open(self.error_log_path) as file: text += file.read() except Exception: pass dialog = NoticeModal(text=text, callback=lambda result: self._on_error_log_closed(result, os.path.exists(self.error_log_path))) gui_app.set_modal_overlay(dialog) def _update_state(self): self.error_log_btn.set_visible(not self._is_release_branch) UPDATES_DESCRIPTIONS = { 'disable_updates_offroad': tr_noop( "Turns off over-the-air update checks entirely.
Reboot for this to take effect." ), 'disable_updates_onroad': tr_noop( "Put the device in Always Offroad, or shut the car down, before touching these." ), 'install_mode_offroad': tr_noop( "Pick whether a ready update just waits for your go-ahead, or downloads and installs on its own." ), 'install_mode_onroad': tr_noop( "Put the device in Always Offroad, or shut the car down, before changing how updates install." ) } INSTALL_MODE_DOWNLOAD_ONLY = "download_only" INSTALL_MODE_DOWNLOAD_AND_INSTALL = "download_and_install" INSTALL_MODE_OPTIONS = [ INSTALL_MODE_DOWNLOAD_ONLY, INSTALL_MODE_DOWNLOAD_AND_INSTALL, ] INSTALL_MODE_LABELS = { INSTALL_MODE_DOWNLOAD_ONLY: tr_noop("Predownload Only"), INSTALL_MODE_DOWNLOAD_AND_INSTALL: tr_noop("Predownload + Preinstall"), } class IQSoftwareLayout(SoftwareLayout): def __init__(self): super().__init__() self.disable_updates_toggle = toggle_item( lambda: tr("Pause Updates"), description="", initial_state=ui_state.params.get_bool("DisableUpdates"), callback=self._on_disable_updates_toggled, ) install_mode = ui_state.params.get("UpdaterInstallMode") or INSTALL_MODE_DOWNLOAD_AND_INSTALL if install_mode not in INSTALL_MODE_OPTIONS: install_mode = INSTALL_MODE_DOWNLOAD_AND_INSTALL self._install_mode_dialog = None self.install_mode_btn = button_item( lambda: tr("Update Install Mode"), lambda: tr("CHANGE"), description="", callback=self._on_change_install_mode, ) self.install_mode_btn.action_item.set_value(tr(INSTALL_MODE_LABELS[install_mode])) try: os_version = HARDWARE.get_os_version() or "unknown" except Exception: os_version = "unknown" self.iqos_version_item = text_item(lambda: tr("IQ.OS Version"), os_version) self._scroller.add_widget(self.iqos_version_item) self._scroller.add_widget(self.disable_updates_toggle) self._scroller.add_widget(self.install_mode_btn) def _handle_reboot(self, result): if result == DialogResult.CONFIRM: ui_state.params.put_bool("DisableUpdates", self.disable_updates_toggle.action_item.get_state()) ui_state.params.put_bool("DoReboot", True) else: self.disable_updates_toggle.action_item.set_state(ui_state.params.get_bool("DisableUpdates")) def _on_disable_updates_toggled(self, enabled): dialog = ConfirmDialog(tr("This needs a reboot to apply. Restart now?"), tr("Reboot")) gui_app.set_modal_overlay(dialog, callback=self._handle_reboot) def _on_change_install_mode(self): current_mode = ui_state.params.get("UpdaterInstallMode") or INSTALL_MODE_DOWNLOAD_AND_INSTALL if current_mode not in INSTALL_MODE_OPTIONS: current_mode = INSTALL_MODE_DOWNLOAD_AND_INSTALL labels = [tr(INSTALL_MODE_LABELS[mode]) for mode in INSTALL_MODE_OPTIONS] current_label = tr(INSTALL_MODE_LABELS[current_mode]) self._install_mode_dialog = MultiOptionDialog(tr("Update Install Mode"), labels, current_label) def handle_selection(result): if result == DialogResult.CONFIRM and self._install_mode_dialog is not None and self._install_mode_dialog.selection: selected_label = self._install_mode_dialog.selection for mode in INSTALL_MODE_OPTIONS: if selected_label == tr(INSTALL_MODE_LABELS[mode]): ui_state.params.put("UpdaterInstallMode", mode) self.install_mode_btn.action_item.set_value(selected_label) break self._install_mode_dialog = None gui_app.set_modal_overlay(self._install_mode_dialog, callback=handle_selection) def _on_uninstall(self): def handle_uninstall_confirmation(result): if result == DialogResult.CONFIRM: ui_state.params.put_bool("DoUninstall", True) gui_app.request_close() dialog = ConfirmDialog(tr("Are you sure you want to uninstall?"), tr("Uninstall")) gui_app.set_modal_overlay(dialog, callback=handle_uninstall_confirmation) def _on_select_branch(self): current_git_branch = ui_state.params.get("GitBranch") or "" branches_str = ui_state.params.get("UpdaterAvailableBranches") or "" branches = [b for b in branches_str.split(",") if b] current_target = ui_state.params.get("UpdaterTargetBranch") or "" top_level_branches = [current_git_branch, "release-mici", "release-tizi", "staging", "dev", "master"] if HARDWARE.get_device_type() == "tici": top_level_branches = ["release-new", "release-tici", "staging-tici"] branches = [b for b in branches if b in ("release-new", "beta") or b.endswith("-tici")] pinned_nodes = [PickerItem(b, {'display_name': b}) for b in top_level_branches if b in branches] other_nodes = [PickerItem(b, {'display_name': b}) for b in sorted(branches) if b not in top_level_branches and not b.endswith("-prebuilt")] folders = [PickerGroup("", pinned_nodes + other_nodes)] def _on_branch_selected(result): if result == DialogResult.CONFIRM and self._branch_dialog is not None: selection = self._branch_dialog.selection_ref if selection: ui_state.params.put("UpdaterTargetBranch", selection) self._branch_btn.action_item.set_value(selection) os.system("pkill -SIGUSR1 -f system.updated.updated") self._branch_dialog = None self._branch_dialog = PickerDialog(tr("Select a branch"), folders, current_target, "", on_exit=_on_branch_selected) gui_app.set_modal_overlay(self._branch_dialog, callback=_on_branch_selected) def _update_state(self): super()._update_state() self.disable_updates_toggle.action_item.set_enabled(ui_state.is_offroad()) self.disable_updates_toggle.set_visible(True) self.install_mode_btn.action_item.set_enabled(ui_state.is_offroad()) self.install_mode_btn.set_visible(True) disable_updates_desc = tr(UPDATES_DESCRIPTIONS["disable_updates_offroad"] if ui_state.is_offroad() else UPDATES_DESCRIPTIONS["disable_updates_onroad"]) self.disable_updates_toggle.set_description(disable_updates_desc) install_mode_desc = tr(UPDATES_DESCRIPTIONS["install_mode_offroad"] if ui_state.is_offroad() else UPDATES_DESCRIPTIONS["install_mode_onroad"]) self.install_mode_btn.set_description(install_mode_desc) install_mode = ui_state.params.get("UpdaterInstallMode") or INSTALL_MODE_DOWNLOAD_AND_INSTALL if install_mode not in INSTALL_MODE_OPTIONS: install_mode = INSTALL_MODE_DOWNLOAD_AND_INSTALL self.install_mode_btn.action_item.set_value(tr(INSTALL_MODE_LABELS[install_mode])) waiting = getattr(self, "_waiting_for_updater", False) self._download_btn.action_item.set_loading(waiting) if waiting: self._download_btn.action_item.set_enabled(False) _OFFROAD_SHUTDOWN_MINUTES = (0, 5, 10, 15, 30, 60, 120, 180, 300, 600, 1440, 1800) offroad_time_options = dict(enumerate(_OFFROAD_SHUTDOWN_MINUTES)) FORCE_ONROAD_DURATION_SEC = 10 * 60 FORCE_ONROAD_PARAM = "ForceOnroadUntil" class IQDeviceLayout(DeviceLayout): MENU_ROOT = 0 MENU_SYSTEM = 1 MENU_MAINTENANCE = 2 def __init__(self): DeviceLayout.__init__(self) self._scroller._line_separator = None self._submenu = self.MENU_ROOT def _initialize_items(self): DeviceLayout._initialize_items(self) self._always_offroad_btn = self._left_button(lambda: tr("Keep Device Offroad"), self._handle_always_offroad) self._force_onroad_btn = self._left_button(lambda: tr("Force On-Road (10 min)"), self._handle_force_onroad) self._max_time_offroad = option_item( title=lambda: tr("Offroad Shutdown Timer"), description=lambda: tr("Powers the device down once it has sat this long with the engine off.\n(30h by default)"), param="MaxTimeOffroad", min_value=0, max_value=11, value_change_step=1, on_value_changed=None, enabled=True, icon="", value_map=offroad_time_options, label_width=360, use_float_scaling=False, inline=True, label_callback=self._update_max_time_offroad_label ) self._device_wake_mode = multiple_button_item( title=lambda: tr("Boot State"), description=self.wake_mode_description, param="DeviceBootMode", buttons=[lambda: tr("Default"), lambda: tr("Offroad")], button_width=364, callback=None, inline=True, ) self._change_language_btn = button_item(lambda: tr("Change Language"), lambda: tr("CHANGE"), callback=self._show_language_dialog) self._driver_camera_btn = button_item(lambda: tr("Driver Camera Check"), lambda: tr("PREVIEW"), callback=self._show_driver_camera) self._reg_and_training = self._left_button(lambda: tr("Regulatory"), self._on_regulatory) self._onroad_uploads_and_reset_settings = dual_button_item( left_text=lambda: tr("Upload While Driving"), left_callback=lambda: ui_state.params.put_bool("OnroadUploads", not ui_state.params.get_bool("OnroadUploads")), right_text=lambda: tr("Restore Defaults"), right_callback=self._reset_settings ) self._power_buttons = dual_button_item( left_text=lambda: tr("Reboot"), right_text=lambda: tr("Power Off"), left_callback=self._reboot_prompt, right_callback=self._power_off_prompt ) self._submenu_system_btn = self._section_button(lambda: tr("System"), "icons/iq/sec_system.png", self.MENU_SYSTEM) self._submenu_maintenance_btn = self._section_button(lambda: tr("Maintenance"), "icons/iq/sec_maintenance.png", self.MENU_MAINTENANCE) self._submenu_back_btn = self._left_button(lambda: tr("Back"), self._go_back) self._submenu_top_separator = LineSeparator(height=10) self._submenu_gap = Spacer(10) self._submenu_bottom_separator = LineSeparator(height=10) self._system_sep_a = LineSeparator() self._system_sep_b = LineSeparator() self._system_sep_c = LineSeparator() self._system_sep_d = LineSeparator() items = [ text_item(lambda: tr("Dongle ID"), self._params.get("DongleId") or (lambda: tr("N/A"))), LineSeparator(), text_item(lambda: tr("Serial"), self._params.get("HardwareSerial") or (lambda: tr("N/A"))), self._system_sep_a, self._reset_calib_btn, self._system_sep_b, self._change_language_btn, self._system_sep_c, self._device_wake_mode, self._system_sep_d, self._max_time_offroad, self._submenu_top_separator, self._submenu_system_btn, self._submenu_maintenance_btn, self._submenu_back_btn, self._driver_camera_btn, self._reg_and_training, self._onroad_uploads_and_reset_settings, self._submenu_gap, self._submenu_bottom_separator, self._power_buttons, ] return items def _left_button(self, text, callback): item = dual_button_item(left_text=text, left_callback=callback, right_text="", right_callback=None) item.action_item.right_button.set_visible(False) return item def _section_button(self, text, icon, submenu): item = self._left_button(text, lambda: self._set_submenu(submenu)) item.action_item.left_button = NavSectionButton(text, icon, lambda: self._set_submenu(submenu)) return item def _set_submenu(self, submenu: int): self._submenu = submenu self._scroller.scroll_panel.set_offset(0) def _go_back(self): self._set_submenu(self.MENU_ROOT) def _offroad_transition(self): self._power_buttons.action_item.right_button.set_visible(ui_state.is_offroad()) @staticmethod def wake_mode_description() -> str: def_str = tr("Default: comes up ready to drive, engagement available straight away.") offrd_str = tr("Offroad: lands in Always Offroad every time it boots or wakes.") header = tr("Sets which state the device settles into after a boot or a wake from sleep.") return f"{header}\n\n{def_str}\n{offrd_str}" @staticmethod def _reset_settings(): def _do_reset(result: int): if result == DialogResult.CONFIRM: for _key in ui_state.params.all_keys(): ui_state.params.remove(_key) HARDWARE.reboot() def _second_confirm(result: int): if result == DialogResult.CONFIRM: gui_app.set_modal_overlay(ConfirmDialog( text=tr("There's no undo once this runs — last chance to back out."), confirm_text=tr("Confirm") ), callback=_do_reset) gui_app.set_modal_overlay(ConfirmDialog( text=tr("Are you sure you want to reset all IQ.Pilot settings to default? Once the settings are reset, there is no going back."), confirm_text=tr("Reset") ), callback=_second_confirm) @staticmethod def _handle_always_offroad(): if ui_state.engaged: gui_app.set_modal_overlay(alert_dialog(tr("Disengage before forcing offroad"))) return _offroad_mode_state = ui_state.params.get_bool("IQAlwaysOffroad") _offroad_mode_str = tr("Leave Always Offroad mode now?") if _offroad_mode_state else \ tr("Switch the device into Always Offroad mode?") def _set_always_offroad(result: int): if result == DialogResult.CONFIRM and not ui_state.engaged: if _offroad_mode_state: ui_state.params.put(FORCE_ONROAD_PARAM, 0) ui_state.params.put_bool("IQAlwaysOffroad", not _offroad_mode_state) gui_app.set_modal_overlay(ConfirmDialog(_offroad_mode_str, tr("Confirm")), callback=lambda result: _set_always_offroad(result)) @staticmethod def _handle_force_onroad(): if ui_state.engaged: gui_app.set_modal_overlay(alert_dialog(tr("Disengage before forcing on-road"))) return if not ui_state.is_offroad(): gui_app.set_modal_overlay(alert_dialog(tr("Force On-Road can only be started while offroad"))) return now = int(time.time()) force_onroad_until = ui_state.params.get(FORCE_ONROAD_PARAM, return_default=True) is_active = force_onroad_until > now if is_active: prompt = tr("Force On-Road is already active. Reset the timer to 10 minutes?") else: prompt = tr("Force IQ.Pilot on-road for 10 minutes? Always Offroad will be enabled automatically and it will return to offroad after the timer.") def _set_force_onroad(result: int): if result == DialogResult.CONFIRM and not ui_state.engaged: ui_state.params.put_bool("IQAlwaysOffroad", True) ui_state.params.put(FORCE_ONROAD_PARAM, int(time.time()) + FORCE_ONROAD_DURATION_SEC) gui_app.set_modal_overlay(ConfirmDialog(prompt, tr("Confirm")), callback=_set_force_onroad) @staticmethod def _update_max_time_offroad_label(value: int) -> str: label = tr("Never Sleep") if value == 0 else f"{value}" + tr("m") if value < 60 else f"{value // 60}" + tr("h") label += tr(" (Default)") if value == 1800 else "" return label def _update_state(self): super()._update_state() always_offroad = ui_state.params.get_bool("IQAlwaysOffroad") now = int(time.time()) force_onroad_until = ui_state.params.get(FORCE_ONROAD_PARAM, return_default=True) force_onroad_active = force_onroad_until > now offroad_mode_btn_text = tr("Exit Offroad Mode") if always_offroad else tr("Keep Device Offroad") offroad_mode_btn_style = ButtonStyle.PRIMARY if always_offroad else ButtonStyle.DANGER self._always_offroad_btn.action_item.left_button.set_text(offroad_mode_btn_text) self._always_offroad_btn.action_item.left_button.set_button_style(offroad_mode_btn_style) if self._scroller._items.__contains__(self._always_offroad_btn): self._scroller._items.remove(self._always_offroad_btn) if ui_state.is_offroad() and not always_offroad: self._scroller._items.insert(len(self._scroller._items) - 1, self._always_offroad_btn) else: self._scroller._items.insert(0, self._always_offroad_btn) if self._scroller._items.__contains__(self._force_onroad_btn): self._scroller._items.remove(self._force_onroad_btn) self._scroller._items.insert(self._scroller._items.index(self._always_offroad_btn) + 1, self._force_onroad_btn) if force_onroad_active: remaining = force_onroad_until - now minutes = remaining // 60 seconds = remaining % 60 force_onroad_text = f"{tr('Forced On-Road Active')} ({minutes}:{seconds:02d})" force_onroad_style = ButtonStyle.PRIMARY else: force_onroad_text = tr("Force On-Road (10 min)") force_onroad_style = ButtonStyle.NORMAL self._force_onroad_btn.action_item.left_button.set_text(force_onroad_text) self._force_onroad_btn.action_item.left_button.set_button_style(force_onroad_style) self._force_onroad_btn.action_item.left_button.set_enabled(ui_state.is_offroad()) self._onroad_uploads_and_reset_settings.action_item.left_button.set_button_style( ButtonStyle.PRIMARY if ui_state.params.get_bool("OnroadUploads") else ButtonStyle.NORMAL ) self._driver_camera_btn.set_enabled(ui_state.is_offroad()) self._reg_and_training.action_item.left_button.set_enabled(ui_state.is_offroad()) self._onroad_uploads_and_reset_settings.action_item.right_button.set_enabled(ui_state.is_offroad()) root_menu = self._submenu == self.MENU_ROOT system_menu = self._submenu == self.MENU_SYSTEM maintenance_menu = self._submenu == self.MENU_MAINTENANCE self._submenu_system_btn.set_visible(root_menu) self._submenu_maintenance_btn.set_visible(root_menu) self._submenu_back_btn.set_visible(not root_menu) self._always_offroad_btn.set_visible(system_menu) self._force_onroad_btn.set_visible(system_menu) self._reset_calib_btn.set_visible(system_menu) self._change_language_btn.set_visible(system_menu) self._system_sep_a.set_visible(system_menu) self._system_sep_b.set_visible(system_menu) self._system_sep_c.set_visible(system_menu) self._system_sep_d.set_visible(system_menu) self._device_wake_mode.set_visible(system_menu) self._max_time_offroad.set_visible(system_menu) self._driver_camera_btn.set_visible(system_menu) self._reg_and_training.set_visible(system_menu) self._onroad_uploads_and_reset_settings.set_visible(maintenance_menu) self._submenu_gap.set_visible(maintenance_menu) self._submenu_bottom_separator.set_visible(maintenance_menu) self._power_buttons.set_visible(maintenance_menu) self._submenu_top_separator.set_visible(root_menu or system_menu or maintenance_menu) if gui_app.iqpilot_ui(): from iqpilot.system.ui.iqwidgets.widgets.list_view import button_item as button_item _ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle" _DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex" _RUNNER_CACHE_KEY = "ModelRunnerTypeCache" def _big_model_options() -> list[tuple[str, str]]: try: from iqpilot.selfdrive.iqmodeld.emac_model_meta import big_models return big_models(ui_state.params) except Exception: return [] def _big_model_label(key: str) -> str: for name, display in _big_model_options(): if name == key: return display return key def _refresh_big_catalog() -> None: def worker(): try: from iqpilot.selfdrive.iqmodeld.emac_model_meta import refresh_catalog refresh_catalog(ui_state.params) except Exception: pass threading.Thread(target=worker, daemon=True).start() class ModelsLayout(Widget): def __init__(self): super().__init__() self.model_manager = None self._refreshing = False self._refresh_start = 0.0 self.download_status = None self.prev_download_status = None self.model_dialog = None self._big_model_dialog = None self.last_cache_calc_time = 0 self._initialize_items() self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") self._scroller = Scroller(self.items, line_separator=True, spacing=0) def _initialize_items(self): self.current_model_item = IQListItem( title=lambda: tr("Active Model"), description="", action_item=WideButtonAction(lambda: tr("SELECT")), callback=self._handle_current_model_clicked ) self.big_model_item = button_item( lambda: tr("Big Model"), lambda: tr("CHANGE"), tr("Only works with external compute connected over USB."), self._handle_big_model_clicked, ) self.big_model_item.action_item.set_value(self._big_model_value()) self.small_on_mac_item = toggle_item_iq( lambda: tr("Active Model on eMac"), tr("Run the selected small model on the Mac instead of a big model."), initial_state=ui_state.params.get_bool("IQEmacSmallModel"), callback=self._on_small_on_mac_toggled, param="IQEmacSmallModel", ) self.supercombo_label = progress_item(tr("Combined Model")) self.vision_label = progress_item(tr("Vision Weights")) self.policy_label = progress_item(tr("Policy Weights")) self.refresh_item = button_item(lambda: tr("Reload Model List"), lambda: tr("REFRESH"), "", self._on_refresh_models) self.clear_cache_item = IQListItem( title=lambda: tr("Purge Model Cache"), description="", action_item=WideButtonAction(lambda: tr("CLEAR")), callback=self._clear_cache ) self.redownload_item = button_item(lambda: tr("Redownload Current Model"), lambda: tr("REDOWNLOAD"), "", self._redownload_model) self.cancel_download_item = button_item(tr("Stop Download"), tr("Cancel"), "", self._cancel_model_request) self.items = [self.current_model_item, self.big_model_item, self.small_on_mac_item, self.cancel_download_item, self.supercombo_label, self.vision_label, self.policy_label, self.redownload_item, self.refresh_item, self.clear_cache_item] def _is_downloading(self): return (self.model_manager and self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.IQModelManager.DownloadStatus.downloading) @staticmethod def _has_download_request() -> bool: try: return int(ui_state.params.get(_DOWNLOAD_INDEX_KEY)) >= 0 except (TypeError, ValueError): return False @staticmethod def _has_active_bundle_param() -> bool: return bool(ui_state.params.get(_ACTIVE_BUNDLE_KEY)) def _has_model_request(self) -> bool: return self._has_download_request() @staticmethod def _calculate_cache_size(): cache_size = 0.0 if os.path.exists(CUSTOM_MODEL_PATH): cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2) return cache_size @staticmethod def _bundle_index(bundle) -> int | None: try: return int(getattr(bundle, "index", -1)) except (TypeError, ValueError): return None @classmethod def _bundle_matches(cls, left, right) -> bool: if left is None or right is None: return False left_index = cls._bundle_index(left) right_index = cls._bundle_index(right) if left_index is not None and right_index is not None and left_index == right_index: return True for attr in ("ref", "internalName", "displayName"): left_value = getattr(left, attr, None) if left_value and left_value == getattr(right, attr, None): return True return False @staticmethod def _safe_model_path(filename: str) -> str | None: if not filename or os.path.basename(filename) != filename: return None root = os.path.realpath(CUSTOM_MODEL_PATH) path = os.path.realpath(os.path.join(root, filename)) try: if os.path.commonpath([root, path]) != root: return None except ValueError: return None return path def _remove_bundle_files(self, bundle) -> None: for model in getattr(bundle, "models", []) or []: for artifact in (getattr(model, "metadata", None), getattr(model, "artifact", None)): filename = getattr(artifact, "fileName", "") if artifact is not None else "" path = self._safe_model_path(filename) if path is None: continue for candidate in (path, f"{path}.download"): try: if os.path.isfile(candidate): os.remove(candidate) except OSError: pass def _clear_cache(self): def _callback(response): if response == DialogResult.CONFIRM: ui_state.params.put_bool("ModelManager_ClearCache", True) self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") gui_app.set_modal_overlay(ConfirmDialog(tr("This will delete ALL downloaded models from the cache except the currently active model. Are you sure?"), tr("Clear Cache")), callback=_callback) def _redownload_target_bundle(self): if not self.model_manager: return None selected = self.model_manager.selectedBundle if selected and selected.status == custom.IQModelManager.DownloadStatus.failed: return selected active = self.model_manager.activeBundle if self._has_active_bundle_param() and active and active.ref: return active return None def _redownload_target_index(self) -> int | None: target = self._redownload_target_bundle() if not target: return None try: return int(target.index) except (TypeError, ValueError): pass for bundle in self.model_manager.availableBundles: if bundle.ref and bundle.ref == target.ref: return int(bundle.index) if bundle.internalName and bundle.internalName == target.internalName: return int(bundle.index) return None def _can_redownload(self) -> bool: return bool(ui_state.is_offroad() and not self._is_downloading() and not self._has_model_request() and self._redownload_target_index() is not None) def _cancel_model_request(self): ui_state.params.remove(_DOWNLOAD_INDEX_KEY) def _redownload_model(self): index = self._redownload_target_index() if index is None: return def _callback(response): if response == DialogResult.CONFIRM: target = self._redownload_target_bundle() if target is not None: self._remove_bundle_files(target) if self._bundle_matches(getattr(self.model_manager, "activeBundle", None), target): ui_state.params.remove(_ACTIVE_BUNDLE_KEY) ui_state.params.remove(_RUNNER_CACHE_KEY) ui_state.params.put(_DOWNLOAD_INDEX_KEY, index) gui_app.set_modal_overlay(ConfirmDialog(tr("Clear the selected model cache and download it again?"), tr("Redownload")), callback=_callback) def _handle_bundle_download_progress(self): labels = {custom.IQModelManager.Model.Type.supercombo: self.supercombo_label, custom.IQModelManager.Model.Type.vision: self.vision_label, custom.IQModelManager.Model.Type.policy: self.policy_label} for label in labels.values(): label.set_visible(False) self.cancel_download_item.set_visible(False) if not self.model_manager or (not self.model_manager.selectedBundle and (not self._has_active_bundle_param() or not self.model_manager.activeBundle)): return bundle = self.model_manager.selectedBundle if self._is_downloading() or ( self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.IQModelManager.DownloadStatus.failed ) else (self.model_manager.activeBundle if self._has_active_bundle_param() else None) if not bundle: return self.download_status = bundle.status status_changed = self.prev_download_status != self.download_status self.prev_download_status = self.download_status self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and self._has_download_request()) if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: self.last_cache_calc_time = current_time self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") if self.download_status == custom.IQModelManager.DownloadStatus.downloading: device._reset_interactive_timeout() DS = custom.IQModelManager.DownloadStatus bundle_downloading = bundle.status == DS.downloading for model in bundle.models: label = labels.get(getattr(model.type, 'raw', model.type)) if label is None: continue label.set_visible(True) p = model.artifact.downloadProgress text, show, color, indeterminate = self._model_label_state(p, bundle, bundle_downloading, status_changed) label.action_item.update(p.progress, text, show, color, indeterminate=indeterminate) def _model_label_state(self, p, bundle, bundle_downloading, status_changed): DS = custom.IQModelManager.DownloadStatus name = bundle.displayName live = p.status == DS.downloading or (bundle_downloading and p.status not in (DS.downloaded, DS.cached, DS.failed)) if live: if p.progress > 0: return f"{int(p.progress)}% - {name}", True, rl.GRAY, False return f"{tr('downloading')} - {name}", True, rl.GRAY, True if p.status in (DS.downloaded, DS.cached): status_text = tr("from cache" if p.status == DS.cached else "downloaded") return f"{name} - {status_text if status_changed else tr('ready')}", False, ON_COLOR, False if p.status == DS.failed: return f"download failed - {name}", False, rl.RED, False return f"pending - {name}", False, rl.GRAY, False @staticmethod def _show_reset_params_dialog(): def _callback(response): if response == DialogResult.CONFIRM: ui_state.params.remove("CalibrationParams") ui_state.params.remove("LiveTorqueParameters") msg = tr("The selected model changed. We suggest resetting calibration. Would you like to do that now?") gui_app.set_modal_overlay(ConfirmDialog(msg, tr("Reset Calibration")), callback=_callback) def _on_model_selected(self, result): if result != DialogResult.CONFIRM: return selected_ref = self.model_dialog.selection_ref if selected_ref == "Default": active = self.model_manager.activeBundle if self.model_manager else None had_custom_model = bool(active and active.ref and not is_default_bundle(active)) select_default_model(ui_state.params) if had_custom_model: self._show_reset_params_dialog() elif selected_bundle := next((bundle for bundle in self.model_manager.availableBundles if bundle.ref == selected_ref), None): ui_state.params.put(_DOWNLOAD_INDEX_KEY, selected_bundle.index) if self.model_manager.activeBundle and selected_bundle.generation != self.model_manager.activeBundle.generation: self._show_reset_params_dialog() self.model_dialog = None @staticmethod def _bundle_to_node(bundle): return PickerItem(bundle.ref, {'display_name': bundle.displayName, 'short_name': bundle.internalName}) def _get_folders(self, favorites): bundles = self.model_manager.availableBundles folders = {} for bundle in bundles: folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle) folders_list = [PickerGroup("", [PickerItem("Default", {'display_name': tr("Default (CD210)"), 'short_name': "Default"})])] for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True): folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True) name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "") folders_list.append(PickerGroup(name, [self._bundle_to_node(bundle) for bundle in folder_bundles])) if favorites and (fav_bundles := [bundle for bundle in bundles if bundle.ref in favorites]): folders_list.insert(1, PickerGroup("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles])) return folders_list def _handle_current_model_clicked(self): favs = ui_state.params.get("IQModelFavorites") favorites = set(favs.split(';')) if favs else set() folders_list = self._get_folders(favorites) active_ref = self.model_manager.activeBundle.ref if self._has_active_bundle_param() and self.model_manager.activeBundle else "Default" self.model_dialog = PickerDialog(tr("Choose a Model"), folders_list, active_ref, "IQModelFavorites", get_folders_fn=self._get_folders, on_exit=self._on_model_selected) gui_app.set_modal_overlay(self.model_dialog, callback=self._on_model_selected) def _on_small_on_mac_toggled(self, state: bool) -> None: p = ui_state.params p.put_bool("IQEmacSmallModel", bool(state)) p.put_bool("IQEmacEnabled", True if state else bool(p.get("IQEmacModel"))) self.big_model_item.action_item.set_value(self._big_model_value()) @staticmethod def _big_model_value() -> str: if not ui_state.params.get_bool("IQEmacEnabled"): return tr("Off") if ui_state.params.get_bool("IQEmacSmallModel"): return tr("Active model (eMac)") key = ui_state.params.get("IQEmacModel") key = key.decode() if isinstance(key, bytes) else (key or "") return _big_model_label(key) if key in [n for n, _ in _big_model_options()] else tr("Off") def _handle_big_model_clicked(self): options = _big_model_options() if len(options) <= 1: _refresh_big_catalog() keys = [n for n, _ in options] labels = [tr("Off")] + [d for _, d in options] self._big_model_dialog = MultiOptionDialog(tr("Big Model"), labels, self._big_model_value()) def handle_selection(result): if result == DialogResult.CONFIRM and self._big_model_dialog is not None and self._big_model_dialog.selection: selected = self._big_model_dialog.selection if selected == tr("Off"): ui_state.params.put_bool("IQEmacEnabled", ui_state.params.get_bool("IQEmacSmallModel")) else: for key in keys: if _big_model_label(key) == selected: ui_state.params.put("IQEmacModel", key) ui_state.params.put_bool("IQEmacEnabled", True) break self.big_model_item.action_item.set_value(self._big_model_value()) self._big_model_dialog = None gui_app.set_modal_overlay(self._big_model_dialog, callback=handle_selection) def _on_refresh_models(self): _refresh_big_catalog() ui_state.params.put("ModelManager_LastSyncTime", 0) self._refreshing = True self._refresh_start = time.monotonic() self.refresh_item.action_item.set_enabled(False) def _update_refresh_state(self): if not self._refreshing: return try: last_sync = int(ui_state.params.get("ModelManager_LastSyncTime", return_default=True) or 0) except (TypeError, ValueError): last_sync = 0 elapsed = time.monotonic() - self._refresh_start if (last_sync > 0 and elapsed > 1.0) or elapsed > 20.0: self._refreshing = False self.refresh_item.action_item.set_loading(False) self.refresh_item.action_item.set_enabled(True) else: self.refresh_item.action_item.set_loading(True) self.refresh_item.action_item.set_enabled(False) def _update_state(self): self._update_refresh_state() self.model_manager = ui_state.sm["iqModelManager"] self._handle_bundle_download_progress() active = self.model_manager.activeBundle if self.model_manager else None if is_default_bundle(active): active_name = active.displayName or tr("Default (CD210)") elif self._has_active_bundle_param() and active and active.ref: active_name = active.internalName else: active_name = tr("Default (CD210)") self.current_model_item.action_item.set_value(active_name) self.redownload_item.action_item.set_enabled(self._can_redownload()) target = self._redownload_target_bundle() self.redownload_item.action_item.set_value(target.internalName if target else "") if not ui_state.is_offroad(): self.current_model_item.action_item.set_enabled(False) self.current_model_item.set_description(tr("Reachable only with the car switched off or Always Offroad turned on.")) else: self.current_model_item.action_item.set_enabled(True) self.current_model_item.set_description("") def _render(self, rect): self._scroller.render(rect) def show_event(self): self._scroller.show_event() OP.PANEL_COLOR = rl.Color(10, 10, 10, 255) ICON_SIZE = 70 OP.PanelType = IntEnum( "PanelType", [es.name for es in OP.PanelType] + [ "MODELS", "CRUISE", "STEERING", "VISUALS", "NAVIGATION", "VEHICLE", ], start=0, ) @dataclass class PanelInfo(OP.PanelInfo): icon: str = "" class SidebarEntry(Widget): def __init__(self, hub, panel_type, panel_info): super().__init__() self._hub = hub self.panel_type = panel_type self.panel_info = panel_info @staticmethod def _draw_active_pill(rect: rl.Rectangle, left_x: float): pill = rl.Rectangle(left_x - 50, rect.y, OP.SIDEBAR_WIDTH - 50, OP.NAV_BTN_HEIGHT) rl.draw_rectangle_rounded(pill, 0.2, 5, OP.CLOSE_BTN_COLOR) def _render(self, rect): active = self.panel_type == self._hub._current_panel x = rect.x + 90 if active: self._draw_active_pill(rect, x) if self.panel_info.icon: icon = gui_app.texture(self.panel_info.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True) rl.draw_texture(icon, int(x), int(rect.y + (OP.NAV_BTN_HEIGHT - icon.height) / 2), rl.WHITE) x += ICON_SIZE + 20 label_h = measure_text_cached(self._hub._font_medium, self.panel_info.name, 65).y rl.draw_text_ex(self._hub._font_medium, self.panel_info.name, rl.Vector2(x, rect.y + (OP.NAV_BTN_HEIGHT - label_h) / 2), 55, 0, OP.TEXT_SELECTED if active else OP.TEXT_NORMAL) self.panel_info.button_rect = rect class IQSettingsLayout(OP.SettingsLayout): def __init__(self): OP.SettingsLayout.__init__(self) self._nav_items: list[Widget] = [] self._sidebar_scroller = Scroller([], spacing=0, line_separator=False, pad_end=False) wifi_manager = WifiManager() wifi_manager.set_active(False) iq_asset = "offroad" self._panels = { OP.PanelType.DEVICE: PanelInfo(tr_noop("Device"), IQDeviceLayout(), icon=f"{iq_asset}/icon_home.png"), OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), IQNetworkUI(wifi_manager), icon="icons/network.png"), OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon=f"{iq_asset}/icon_toggle.png"), OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), IQSoftwareLayout(), icon=f"{iq_asset}/icon_software.png"), OP.PanelType.MODELS: PanelInfo(tr_noop("Models"), ModelsLayout(), icon=f"{iq_asset}/icon_models.png"), OP.PanelType.CRUISE: PanelInfo(tr_noop("Cruise"), CruiseLayout(), icon=f"{iq_asset}/icon_longitudinal.png"), OP.PanelType.STEERING: PanelInfo(tr_noop("Steering"), SteeringLayout(), icon="icons_mici/wheel.png"), OP.PanelType.VISUALS: PanelInfo(tr_noop("Visuals"), VisualsLayout(), icon=f"{iq_asset}/icon_visuals.png"), OP.PanelType.VEHICLE: PanelInfo(tr_noop("Vehicle"), VehicleLayout(), icon=f"{iq_asset}/icon_vehicle.png"), OP.PanelType.DEVELOPER: PanelInfo(tr_noop("Developer"), IQDeveloperLayout(), icon="icons/shell.png"), } self._hidden_from_sidebar = {OP.PanelType.CRUISE} self._panels[OP.PanelType.TOGGLES].instance.set_cruise_panel_callback( lambda: self.set_current_panel(OP.PanelType.CRUISE) ) def _sidebar_entries(self): for panel_type, panel_info in self._panels.items(): if panel_type not in self._hidden_from_sidebar: yield panel_type, panel_info def _populate_sidebar(self, rect: rl.Rectangle): for panel_type, panel_info in self._sidebar_entries(): entry = SidebarEntry(self, panel_type, panel_info) entry.rect.width = rect.width - 100 entry.rect.height = OP.NAV_BTN_HEIGHT self._nav_items.append(entry) self._sidebar_scroller.add_widget(entry) def _draw_close_button(self, rect: rl.Rectangle) -> rl.Rectangle: btn = rl.Rectangle(rect.x + metrics.GUTTER * 3, rect.y + metrics.GUTTER * 2, metrics.CLOSE_BTN, metrics.CLOSE_BTN) pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and rl.check_collision_point_rec(rl.get_mouse_position(), btn)) rl.draw_rectangle_rounded(btn, 1.0, 20, OP.CLOSE_BTN_PRESSED if pressed else OP.CLOSE_BTN_COLOR) icon = self._close_icon dest = rl.Rectangle(btn.x + (btn.width - icon.width) / 2, btn.y + (btn.height - icon.height) / 2, icon.width, icon.height) tint = rl.Color(220, 220, 220, 255) if pressed else rl.WHITE rl.draw_texture_pro(icon, rl.Rectangle(0, 0, icon.width, icon.height), dest, rl.Vector2(0, 0), 0, tint) return btn def _draw_sidebar(self, rect: rl.Rectangle): rl.draw_rectangle_rec(rect, OP.SIDEBAR_COLOR) self._close_btn_rect = self._draw_close_button(rect) if not self._nav_items: self._populate_sidebar(rect) nav_rect = rl.Rectangle(rect.x, self._close_btn_rect.height + metrics.GUTTER * 4, rect.width, rect.height - 300) self._sidebar_scroller.render(nav_rect) def _handle_mouse_release(self, mouse_pos: MousePos) -> bool: if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect): if self._close_callback: self._close_callback() return True if self._sidebar_scroller.scroll_panel.is_touch_valid(): for panel_type, panel_info in self._sidebar_entries(): if rl.check_collision_point_rec(mouse_pos, panel_info.button_rect): self.set_current_panel(panel_type) return True return False def show_event(self): super().show_event() self._panels[self._current_panel].instance.show_event() self._sidebar_scroller.show_event() class BrandPanel: def __init__(self): self.items: list = [] def update_settings(self) -> None: pass _TUNING_BLURBS = ( lambda: tr("Factory-default longitudinal tuning."), lambda: tr("Dynamic tuning: livelier gas/brake response."), lambda: tr("Predictive tuning: anticipates and smooths accel changes."), ) class HyundaiSettings(BrandPanel): def __init__(self): super().__init__() self.alpha_long_available = False self.longitudinal_tuning_item = multiple_button_item( tr("Longitudinal Tune Profile"), "", [tr("Off"), tr("Dynamic"), tr("Predictive")], button_width=300, param="IQHyundaiLongTune", inline=False, callback=lambda index: ui_state.params.put("IQHyundaiLongTune", index)) self.items = [self.longitudinal_tuning_item] def _alpha_long_supported(self) -> bool: if bundle := ui_state.params.get("CarPlatformBundle"): return CAR[bundle.get("platform")] not in (UNSUPPORTED_LONGITUDINAL_CAR | CANFD_UNSUPPORTED_LONGITUDINAL_CAR) if ui_state.CP: return ui_state.CP.alphaLongitudinalAvailable return False def update_settings(self): self.alpha_long_available = self._alpha_long_supported() selected = int(ui_state.params.get("IQHyundaiLongTune") or "0") if not ui_state.is_offroad(): desc, usable = tr("Unavailable while the car is onroad."), False elif not ui_state.has_longitudinal_control: desc, usable = tr("Requires IQ.Pilot Longitudinal Control (Alpha) to be enabled."), False else: blurb = _TUNING_BLURBS[selected] if selected < len(_TUNING_BLURBS) else _TUNING_BLURBS[0] desc, usable = blurb(), True row = self.longitudinal_tuning_item row.action_item.set_enabled(usable) row.set_description(desc) row.show_description(True) row.action_item.set_selected_button(selected) row.set_visible(self.alpha_long_available) _UNSUPPORTED_FLAGS = SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID class SubaruSettings(BrandPanel): def __init__(self): super().__init__() self._supported = False self.stop_and_go_toggle = toggle_item(tr("Creep from Standstill (Beta)"), "", param="IQSubaruCreepAssist", callback=lambda _: self.update_settings()) self.stop_and_go_manual_parking_brake_toggle = toggle_item( tr("Creep from Standstill — Manual Handbrake (Beta)"), "", param="IQSubaruCreepAssistManualBrake", callback=lambda _: self.update_settings()) self.items = [self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle] def _platform_flags(self) -> int: if bundle := ui_state.params.get("CarPlatformBundle"): return CAR[bundle.get("platform")].config.flags if ui_state.CP: return ui_state.CP.flags return 0 def _blocker_text(self) -> str: if not self._supported: return tr("Not available on this Subaru platform.") if not ui_state.is_offroad(): return tr("Flip on Always Offroad from the Device panel, or power the car down, to change this.") return "" def update_settings(self): self._supported = not (self._platform_flags() & _UNSUPPORTED_FLAGS) blocker = self._blocker_text() usable = self._supported and ui_state.is_offroad() rows = ( (self.stop_and_go_toggle, tr("Automatically resume from a stop while following traffic, on Subaru platforms where " "the beta implementation applies.")), (self.stop_and_go_manual_parking_brake_toggle, tr("Stop-and-go variant for Subaru Global cars with a manual handbrake. Leave this off on " "cars with an electric parking brake. Thanks to martinl for this implementation!")), ) for row, body in rows: row.action_item.set_enabled(usable) row.set_description(f"{blocker}

{body}" if blocker else body) COOP_STEERING_MIN_KMH = 23 OEM_STEERING_MIN_KMH = 48 KM_TO_MILE = 0.621371 def _speed_text(kmh: int) -> str: if ui_state.is_metric: return f"{kmh} km/h" return f"{round(kmh * KM_TO_MILE)} mph" class TeslaSettings(BrandPanel): def __init__(self): super().__init__() self.torque_blend_toggle = toggle_item(tr("VTB (Virtual Torque Blending)"), "", param="IQTeslaTorqueBlend") self.fsd_visualization_toggle = toggle_item(tr("FSD Visualization"), "", param="IQTeslaFsdVisualization") self.items = [self.torque_blend_toggle, self.fsd_visualization_toggle] def update_settings(self): caution = tr("Warning: steering may oscillate in turns below {}; turn this off if you feel it.").format( _speed_text(OEM_STEERING_MIN_KMH)) body = (f"{caution}

" f"{tr('Lets you nudge the wheel while engaged without fully disengaging steering.')}
" f"{tr('Active above {} only.').format(_speed_text(COOP_STEERING_MIN_KMH))}") if not ui_state.is_offroad(): blocker = tr("Flip on Always Offroad from the Device panel, or power the car down, to change this.") body = f"{blocker}

{body}" self.torque_blend_toggle.set_description(body) visualization_body = tr("Shows Tesla's FSD / Autosteer visualization while IQ.Pilot controls steering. " + "Requires an offroad restart or reboot.") if not ui_state.is_offroad(): visualization_body = f"{blocker}

{visualization_body}" self.fsd_visualization_toggle.set_description(visualization_body) for row in self.items: row.action_item.set_enabled(ui_state.is_offroad()) class ToyotaSettings(BrandPanel): def __init__(self): super().__init__() self.enforce_stock_longitudinal = toggle_item( lambda: tr("Keep Factory Gas and Brake"), description=lambda: tr("Keeps gas and brakes with the factory Toyota system; IQ.Pilot steers only."), initial_state=ui_state.params.get_bool("IQToyotaFactoryLong"), callback=self._on_toggled, enabled=lambda: not ui_state.engaged, ) self.items = [self.enforce_stock_longitudinal] @staticmethod def _apply(enabled: bool): ui_state.params.put_bool("IQToyotaFactoryLong", enabled) if enabled and ui_state.params.get_bool("AlphaLongitudinalEnabled"): ui_state.params.put_bool("AlphaLongitudinalEnabled", False) ui_state.params.put_bool("OnroadCycleRequested", True) def _on_toggled(self, state: bool): if not state: self._apply(False) return def after_confirm(result: int): if result == DialogResult.CONFIRM: self._apply(True) else: self.enforce_stock_longitudinal.action_item.set_state(False) row = self.enforce_stock_longitudinal prompt = f"

{row.title}


{row.description}

" gui_app.set_modal_overlay(ConfirmDialog(prompt, tr("Enable"), rich=True), callback=after_confirm) DESCRIPTIONS = { 'pqhca5or7Toggle': tr_noop( 'Use HCA Status 7 instead of Status 5 for steering control on PQ platform vehicles. ' 'This may help with compatibility on some older Volkswagen models.' ), 'AllowLateralWhenLongUnavailable': tr_noop( 'Allow lateral control (steering) to remain active even when longitudinal control (gas/brake) ' 'is temporarily unavailable due to a cruise control fault.' ), 'iqMqbAccResume': tr_noop( 'Allow IQ.Pilot to use MQB ACC resume behavior on supported FtS with Extended Hold without Auto Resume Volkswagen MQB vehicles.' ), 'iqMqbSteeringLockout': tr_noop( 'Enable MQB steering lockout handling on Volkswagen MQB vehicles with low speed LKAS faults.' ), 'EnableCurvatureController': tr_noop( 'Close the steering loop on MEB and MQB Evo vehicles. IQ.Pilot corrects its steering command against ' 'the curvature the car is actually holding, instead of sending the planned curvature straight through.' ), 'EnableSmoothSteer': tr_noop( 'Low-pass the planned curvature before it reaches the rack on MEB and MQB Evo vehicles, which damps ' 'lateral wobble. On by default for these cars.' ), } class VolkswagenSettings(BrandPanel): def __init__(self): super().__init__() self.pq_hca_toggle = toggle_item( lambda: tr("PQ HCA Status 7 Mode"), description=lambda: tr(DESCRIPTIONS["pqhca5or7Toggle"]), initial_state=ui_state.params.get_bool("pqhca5or7Toggle"), callback=self._on_pq_hca_toggle, enabled=lambda: not ui_state.engaged, ) self.lateral_when_long_unavailable = toggle_item( lambda: tr("Lateral Control When Cruise Faulted"), description=lambda: tr(DESCRIPTIONS["AllowLateralWhenLongUnavailable"]), initial_state=ui_state.params.get_bool("AllowLateralWhenLongUnavailable"), callback=self._on_lateral_when_long_unavailable, enabled=lambda: not ui_state.engaged, ) self.mqb_acc_resume = toggle_item( lambda: tr("MQB ACC Resume"), description=lambda: tr(DESCRIPTIONS["iqMqbAccResume"]), initial_state=ui_state.params.get_bool("iqMqbAccResume"), callback=self._on_mqb_acc_resume, enabled=lambda: not ui_state.engaged, ) self.mqb_steering_lockout = toggle_item( lambda: tr("MQB Steering Lockout"), description=lambda: tr(DESCRIPTIONS["iqMqbSteeringLockout"]), initial_state=ui_state.params.get_bool("iqMqbSteeringLockout"), callback=self._on_mqb_steering_lockout, enabled=lambda: not ui_state.engaged, ) self.curvature_controller = toggle_item( lambda: tr("Curvature Controller"), description=lambda: tr(DESCRIPTIONS["EnableCurvatureController"]), initial_state=ui_state.params.get_bool("EnableCurvatureController"), callback=self._on_curvature_controller, enabled=lambda: not ui_state.engaged, ) self.smooth_steer = toggle_item( lambda: tr("Smooth Steering"), description=lambda: tr(DESCRIPTIONS["EnableSmoothSteer"]), initial_state=ui_state.params.get_bool("EnableSmoothSteer"), callback=self._on_smooth_steer, enabled=lambda: not ui_state.engaged, ) self.items = [self.pq_hca_toggle, self.lateral_when_long_unavailable, self.mqb_acc_resume, self.mqb_steering_lockout, self.curvature_controller, self.smooth_steer] def _flags(self) -> VolkswagenFlags: bundle = ui_state.params.get("CarPlatformBundle") if bundle: platform = bundle.get("platform") if platform: try: return CAR[platform].config.flags except (KeyError, AttributeError): return VolkswagenFlags(0) elif ui_state.CP: return ui_state.CP.flags return VolkswagenFlags(0) def _uses_hca_status_toggle(self) -> bool: return bool(self._flags() & (VolkswagenFlags.PQ | VolkswagenFlags.MLB)) def _is_mqb(self) -> bool: flags = self._flags() return not bool(flags & (VolkswagenFlags.PQ | VolkswagenFlags.MLB | VolkswagenFlags.MEB | VolkswagenFlags.MEB_GEN2 | VolkswagenFlags.MQB_EVO)) def _supports_lateral_when_faulted(self) -> bool: return not bool(self._flags() & VolkswagenFlags.MLB) def _is_curvature_car(self) -> bool: return bool(self._flags() & (VolkswagenFlags.MEB | VolkswagenFlags.MQB_EVO)) def _on_pq_hca_toggle(self, state: bool): ui_state.params.put_bool("pqhca5or7Toggle", state) def _on_lateral_when_long_unavailable(self, state: bool): ui_state.params.put_bool("AllowLateralWhenLongUnavailable", state) def _on_mqb_acc_resume(self, state: bool): ui_state.params.put_bool("iqMqbAccResume", state) def _on_mqb_steering_lockout(self, state: bool): ui_state.params.put_bool("iqMqbSteeringLockout", state) def _on_curvature_controller(self, state: bool): ui_state.params.put_bool("EnableCurvatureController", state) def _on_smooth_steer(self, state: bool): ui_state.params.put_bool("EnableSmoothSteer", state) def update_settings(self): self.pq_hca_toggle.set_visible(self._uses_hca_status_toggle()) self.lateral_when_long_unavailable.set_visible(self._supports_lateral_when_faulted()) is_mqb = self._is_mqb() self.mqb_acc_resume.set_visible(is_mqb) self.mqb_steering_lockout.set_visible(is_mqb) is_curvature_car = self._is_curvature_car() self.curvature_controller.set_visible(is_curvature_car) self.smooth_steer.set_visible(is_curvature_car) _REGISTRY: dict[str, type[BrandPanel]] = { "hyundai": HyundaiSettings, "subaru": SubaruSettings, "tesla": TeslaSettings, "toyota": ToyotaSettings, "volkswagen": VolkswagenSettings, } def brand_settings_for(brand: str) -> BrandPanel | None: cls = _REGISTRY.get(brand) return cls() if cls else None class BrandPanelFactory: create_brand_settings = staticmethod(brand_settings_for) class FingerprintStatus(IntEnum): NONE = 0 # nothing identified, nothing forced AUTO = 1 # car identified itself over CAN FORCED = 2 # user pinned a platform manually STATUS_COLORS = { FingerprintStatus.NONE: ink.STATUS_WARN, FingerprintStatus.AUTO: ink.STATUS_GOOD, FingerprintStatus.FORCED: ink.STATUS_INFO, } class VehicleSelection: def __init__(self): self.platforms: dict = load_catalog() @staticmethod def forced_bundle(): return ui_state.params.get("CarPlatformBundle") def status(self) -> FingerprintStatus: if self.forced_bundle(): return FingerprintStatus.FORCED if ui_state.CP and ui_state.CP.carFingerprint != "MOCK": return FingerprintStatus.AUTO return FingerprintStatus.NONE def display_name(self) -> str: if bundle := self.forced_bundle(): return bundle.get("name", "") if ui_state.CP and ui_state.CP.carFingerprint != "MOCK": return ui_state.CP.carFingerprint return tr("No vehicle set") def force(self, platform_name: str) -> bool: data = self.platforms.get(platform_name) if not data: return False ui_state.params.put("CarPlatformBundle", {**data, "name": platform_name}) return True @staticmethod def clear(): ui_state.params.remove("CarPlatformBundle") def picker_folders(self) -> list[PickerGroup]: def node_for(name: str) -> PickerItem: info = self.platforms[name] years = ' '.join(map(str, info.get('year', []))) return PickerItem(name, { 'display_name': name, 'search_tags': f"{name} {info.get('make')} {years} {info.get('model', name)}", }) names = sorted(self.platforms) makes = sorted({self.platforms[n].get('make') for n in names}) return [PickerGroup(make, [node_for(n) for n in names if self.platforms[n].get('make') == make]) for make in makes] class VehiclePicker(Button): def __init__(self, on_platform_change: Callable[[], None] | None = None): super().__init__(tr("Vehicle"), self._on_clicked, button_style=ButtonStyle.NORMAL) self.set_rect(rl.Rectangle(0, 0, 0, 120)) self.selection = VehicleSelection() self._on_platform_change = on_platform_change self.refresh() @property def text(self): return self._label._text @property def status(self) -> FingerprintStatus: return self.selection.status() @property def color(self) -> rl.Color: return STATUS_COLORS[self.status] def set_parent_rect(self, parent_rect): super().set_parent_rect(parent_rect) self._rect.width = parent_rect.width def refresh(self): self.set_text(self.selection.display_name()) self.set_enabled(True) def _notify(self): self.refresh() if self._on_platform_change: self._on_platform_change() def _on_clicked(self): if self.selection.forced_bundle(): self.selection.clear() self._notify() else: self._open_picker() def _open_picker(self): dialog = PickerDialog( tr("Pick your vehicle"), self.selection.picker_folders(), search_prompt=tr("Search make or model"), search_title=tr("Find your vehicle"), search_subtitle=tr("Type a year then a model — for example, 2021 Toyota Corolla:"), search_funcs=[lambda node: node.data.get('display_name', ''), lambda node: node.data.get('search_tags', '')], ) done = partial(self._on_picked, dialog) dialog.on_exit = done gui_app.set_modal_overlay(dialog, callback=done) def _on_picked(self, dialog, res): if res != DialogResult.CONFIRM or not dialog.selection_ref: return when = tr("Applies right away.") if ui_state.is_offroad else \ tr("Applies the next time the device goes offroad.") def confirm(result): if result == DialogResult.CONFIRM and self.selection.force(dialog.selection_ref): self._notify() gui_app.set_modal_overlay(ConfirmDialog(when, tr("Confirm")), callback=confirm) _LEGEND = ( (FingerprintStatus.AUTO, lambda: tr("Detected automatically")), (FingerprintStatus.FORCED, lambda: tr("Chosen manually")), (FingerprintStatus.NONE, lambda: tr("Undetected and unset")), ) class LegendWidget(Widget): def __init__(self, platform_selector: VehiclePicker): super().__init__() self.set_rect(rl.Rectangle(0, 0, 0, 350)) self._selector = platform_selector self._font = gui_app.font(FontWeight.NORMAL) self._bold_font = gui_app.font(FontWeight.BOLD) def _render(self, rect): x = rect.x + 20 y = rect.y + 20 rl.draw_text_ex(self._font, tr("Pin a vehicle to skip auto-detection."), rl.Vector2(x, y), 40, 0, ink.CAPTION) y += 80 rl.draw_text_ex(self._font, tr("Colour key for fingerprint state:"), rl.Vector2(x, y), 40, 0, ink.CAPTION) y += 80 active = self._selector.status for status, label in _LEGEND: font = self._bold_font if status == active else self._font text_color = rl.WHITE if status == active else ink.CAPTION text = f"- {label()}" ts = measure_text_cached(font, text, 40) chip_cy = (y - 7) + ts.y / 2 rl.draw_rectangle_rounded(rl.Rectangle(x, chip_cy - 18, 36, 36), 0.45, 10, STATUS_COLORS[status]) rl.draw_text_ex(font, text, rl.Vector2(x + 56, y - 7), 40, 0, text_color) y += 50 _STATUS_BADGES = { FingerprintStatus.AUTO: lambda: tr("AUTO"), FingerprintStatus.FORCED: lambda: tr("MANUAL"), FingerprintStatus.NONE: lambda: tr("NONE"), } class VehicleLayout(Widget): def __init__(self): super().__init__() self._brand_settings = None self._current_brand = None self._platform_selector = VehiclePicker(self._on_vehicle_changed) self._vehicle_item = IQListItem(title=self._platform_selector.text, action_item=ButtonAction(text=tr("SELECT")), callback=self._platform_selector._on_clicked) self._legend_widget = LegendWidget(self._platform_selector) self._refresh_vehicle_row() self.items = [self._vehicle_item, self._legend_widget] self._scroller = Scroller(self.items, line_separator=True, spacing=0) @staticmethod def get_brand(): bundle = ui_state.params.get("CarPlatformBundle") if bundle: return bundle.get("brand", "") fingerprinted = ui_state.CP and ui_state.CP.carFingerprint != "MOCK" return ui_state.CP.brand if fingerprinted else "" def _refresh_vehicle_row(self): status = self._platform_selector.status self._vehicle_item._title = self._platform_selector.text self._vehicle_item.title_color = rl.WHITE self._vehicle_item.title_badge = (_STATUS_BADGES[status](), STATUS_COLORS[status]) forced = ui_state.params.get("CarPlatformBundle") is not None self._vehicle_item.action_item.set_text(tr("REMOVE") if forced else tr("SELECT")) def _sync_brand_panel(self): brand = self.get_brand() if brand == self._current_brand: return self._current_brand = brand self._brand_settings = brand_settings_for(brand) brand_rows = self._brand_settings.items if self._brand_settings else [] self.items = [self._vehicle_item, self._legend_widget, *brand_rows] self._scroller = Scroller(self.items, line_separator=True, spacing=0) def _on_vehicle_changed(self): self._refresh_vehicle_row() self._sync_brand_panel() def _update_state(self): self._on_vehicle_changed() if self._brand_settings: self._brand_settings.update_settings() self._platform_selector.refresh() def _render(self, rect): self._scroller.render(rect) def show_event(self): self._scroller.show_event()