IQ.Pilot Release Commit @ 4fcea4d

This commit is contained in:
IQ.Lvbs CI [bot]
2026-07-20 11:06:57 -05:00
commit 7b20edda67
4602 changed files with 1122468 additions and 0 deletions

3
iqpilot/ui/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""

View File

@@ -0,0 +1,3 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""

View File

View File

@@ -0,0 +1,202 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
from collections.abc import Callable
import pyray as rl
from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.iqpilot.widgets.list_view import IQListItem, IQToggleAction, SafeIQToggleAction
from openpilot.system.ui.iqpilot.widgets.list_view import OptionControl
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.network import NavButton
from openpilot.system.ui.widgets.scroller_tici import Scroller
from opendbc.car.volkswagen.values import CAR, VolkswagenFlags
def _toggle_item(title: str, description: str, param: str) -> IQListItem:
return IQListItem(
title=lambda t=title: tr(t),
description=lambda d=description: tr(d),
action_item=IQToggleAction(
initial_state=Params().get_bool(param),
callback=lambda state, p=param: Params().put_bool(p, state),
param=param,
),
)
def _safe_toggle_item(title: str, description: str, param: str, default_on: bool = True) -> IQListItem:
return IQListItem(
title=lambda t=title: tr(t),
description=lambda d=description: tr(d),
action_item=SafeIQToggleAction(
param=param,
default_on=default_on,
),
)
def _option_item(title: str, description: str, param: str, minimum: int, maximum: int,
step: int = 1, label_callback=None, use_float_scaling: bool = False) -> IQListItem:
return IQListItem(
title=lambda t=title: tr(t),
description=lambda d=description: tr(d),
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,
),
)
class IQDynamicLayout(Widget):
def __init__(self, back_btn_callback: Callable):
super().__init__()
self._params = Params()
self._back_button = NavButton(tr("Back"))
self._back_button.set_click_callback(back_btn_callback)
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
@staticmethod
def _is_pq() -> bool:
bundle = ui_state.params.get("CarPlatformBundle")
if bundle:
platform = bundle.get("platform")
if platform:
try:
return bool(CAR[platform].config.flags & VolkswagenFlags.PQ)
except (KeyError, AttributeError):
return False
elif ui_state.CP:
return bool(ui_state.CP.flags & VolkswagenFlags.PQ)
return False
def _initialize_items(self):
ms_to_mph = 2.23694
def speed_label(value: float | int) -> str:
try:
speed_ms = float(value)
except (TypeError, ValueError):
return "-- mph"
return f"{int(round(speed_ms * ms_to_mph))} mph"
def distance_label(value: float | int) -> str:
try:
distance_m = float(value)
except (TypeError, ValueError):
return "-- m"
if self._params.get_bool("IsMetric"):
return f"{distance_m:.1f} m"
return f"{distance_m * 3.28084:.0f} ft"
def seconds_label(value: float | int) -> str:
try:
seconds = float(value)
except (TypeError, ValueError):
return "--.-s"
return f"{seconds:.1f}s"
self._blend_radar_toggle = _toggle_item(
"Blend IQ.Pilot + Stock ACC Radar",
"VW PQ only. Keep the factory ACC radar engaged and use its acceleration as IQ.Dynamic's ACC "
"(chill) command, while IQ.Pilot still owns blended/e2e control for low speed, stops, and traffic.",
"IQDynamicBlendStockRadar",
)
self._pq_only_items = [self._blend_radar_toggle]
return [
self._blend_radar_toggle,
_toggle_item(
"IQ.Dynamic Curves",
"Allow IQ.Dynamic to enter blended control for curves and strong vision slowdown cues.",
"IQDynamicConditionalCurves",
),
_toggle_item(
"IQ.Dynamic Slower Lead",
"Allow IQ.Dynamic to switch toward blended control when a slower lead vehicle is detected.",
"IQDynamicConditionalSlowerLead",
),
_toggle_item(
"IQ.Dynamic Stopped Lead",
"Allow IQ.Dynamic to react more aggressively when a lead vehicle is nearly stopped.",
"IQDynamicConditionalStoppedLead",
),
_toggle_item(
"IQ.Dynamic Model Stops",
"Allow IQ.Dynamic to switch toward blended control for stop-sign and stop-light style vision stops.",
"IQDynamicConditionalModelStops",
),
_toggle_item(
"IQ.Dynamic SLC Fallback",
"Allow IQ.Dynamic to request blended control when Speed Limit Controller has no usable target.",
"IQDynamicConditionalSLCFallback",
),
_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,
),
_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",
),
_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,
),
_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,
),
_option_item(
"IQ.Dynamic Min Force Stop Length",
"When IQ Force Stops is enabled, keep at least this much stopping distance in the force-stop ramp. Set to 0 to disable the minimum.",
"IQDynamicMinimumForceStopLength",
0, 5000,
step=50,
use_float_scaling=True,
label_callback=distance_label,
),
]
def _render(self, rect: rl.Rectangle):
is_pq = self._is_pq()
for item in self._pq_only_items:
item.set_visible(is_pq)
self._back_button.set_position(self._rect.x, self._rect.y + 20)
self._back_button.render()
content_rect = rl.Rectangle(
rect.x,
rect.y + self._back_button.rect.height + 40,
rect.width,
rect.height - self._back_button.rect.height - 40,
)
self._scroller.render(content_rect)
def show_event(self):
self._scroller.show_event()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import requests
import threading
import time
import pyray as rl
from openpilot.common.api import api_get
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.common.time_helpers import system_time_valid
from openpilot.selfdrive.ui.lib.api_helpers import get_token
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.iqpilot.konn3kt.registration import UNREGISTERED_DONGLE_ID
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
_STATS_PARAM = "ApiCache_DriveStats"
_POLL_SECONDS = 30
class _DriveStatsSource:
"""Owns the konn3kt drive-stats fetch. Seeds from the cached param, then keeps it fresh on a
background poll while the device is offroad and awake. Read `snapshot` for the latest data."""
def __init__(self):
self._params = Params()
self._http = requests.Session()
self.snapshot = self._params.get(_STATS_PARAM) or {}
self._alive = True
self._worker = threading.Thread(target=self._poll, daemon=True)
self._worker.start()
def close(self) -> None:
self._alive = False
try:
if self._worker.is_alive():
self._worker.join(timeout=1.0)
except Exception:
pass
def _poll(self) -> None:
while self._alive:
if not ui_state.started and device._awake:
self._pull_once()
time.sleep(_POLL_SECONDS)
def _pull_once(self) -> None:
try:
dongle_id = self._params.get("DongleId")
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
return
# at boot the clock isn't NTP-synced, so the token can't be minted yet — skip quietly
if not system_time_valid():
return
resp = api_get(f"v1.1/devices/{dongle_id}/stats", access_token=get_token(dongle_id), session=self._http)
if resp.status_code == 200:
payload = resp.json()
self.snapshot = payload
self._params.put(_STATS_PARAM, payload)
except Exception as e:
cloudlog.error(f"Failed to fetch drive stats: {e}")
class TripsLayout(Widget):
PARAM_KEY = _STATS_PARAM # retained for external references
UPDATE_INTERVAL = _POLL_SECONDS
_CARD_FILL = rl.Color(38, 40, 46, 255)
_CARD_EDGE = rl.Color(255, 255, 255, 18)
_ACCENT = rl.Color(30, 200, 168, 255)
_ACCENT_DIM = rl.Color(93, 202, 165, 255)
_UNIT = rl.Color(138, 139, 144, 255)
_RULE = rl.Color(255, 255, 255, 16)
def __init__(self):
super().__init__()
self._params = Params()
self._source = _DriveStatsSource()
# one shared height so the three columns line up; tinted teal at draw time
self._ic_drives = gui_app.texture("icons_mici/wheel.png", 64, 64, keep_aspect_ratio=True)
self._ic_distance = gui_app.texture("icons/road.png", 88, 64, keep_aspect_ratio=True)
self._ic_hours = gui_app.texture("../../iqpilot/selfdrive/assets/icons/clock.png", 64, 64, keep_aspect_ratio=True)
def __del__(self):
self._source.close()
def _columns(self, bucket: dict, is_metric: bool):
routes = int(bucket.get("routes", 0))
distance = bucket.get("distance", 0)
distance_val = int(distance * CV.MPH_TO_KPH) if is_metric else int(distance)
hours = int(bucket.get("minutes", 0) / 60)
dist_unit = tr("KM") if is_metric else tr("Miles")
return (
(self._ic_drives, str(routes), tr("Drives")),
(self._ic_distance, str(distance_val), dist_unit),
(self._ic_hours, str(hours), tr("Hours")),
)
def _paint_card(self, x, y, width, height, title, columns) -> None:
card = rl.Rectangle(x, y, width, height)
rl.draw_rectangle_rounded(card, 0.10, 20, self._CARD_FILL)
rl.draw_rectangle_rounded_lines_ex(card, 0.10, 20, 2, self._CARD_EDGE)
# heading: teal tick + muted-teal caption
pad = 44
label_y = y + 36
tick_h = 30
title_size = 34 * FONT_SCALE
rl.draw_rectangle_rounded(rl.Rectangle(x + pad, label_y, 6, tick_h), 0.5, 6, self._ACCENT)
rl.draw_text_ex(gui_app.font(FontWeight.BOLD), title,
rl.Vector2(x + pad + 22, label_y + (tick_h - title_size) / 2), title_size, 4, self._ACCENT_DIM)
col_width = width / 3
content_top = label_y + tick_h + 20
content_bottom = y + height - 30
number_font = gui_app.font(FontWeight.BOLD)
unit_font = gui_app.font(FontWeight.MEDIUM)
number_size = 84 * FONT_SCALE
unit_size = 30 * FONT_SCALE
unit_spacing = 2.0
icon_gap = 16
num_gap = 14
# vertical rules between the three columns
for i in (1, 2):
dx = x + col_width * i
rl.draw_line_ex(rl.Vector2(dx, content_top + 4), rl.Vector2(dx, content_bottom - 4), 1, self._RULE)
for idx, (icon, value, unit) in enumerate(columns):
center_x = x + col_width * idx + col_width / 2
unit = unit.upper()
val_w = measure_text_cached(number_font, value, int(number_size)).x
unit_w = measure_text_cached(unit_font, unit, int(unit_size)).x + unit_spacing * max(0, len(unit) - 1)
block_h = icon.height + icon_gap + number_size + num_gap + unit_size
start_y = content_top + max(0.0, (content_bottom - content_top - block_h) / 2)
rl.draw_texture(icon, int(center_x - icon.width / 2), int(start_y), self._ACCENT)
num_y = start_y + icon.height + icon_gap
rl.draw_text_ex(number_font, value, rl.Vector2(center_x - val_w / 2, num_y), number_size, 0, rl.WHITE)
unit_y = num_y + number_size + num_gap
rl.draw_text_ex(unit_font, unit, rl.Vector2(center_x - unit_w / 2, unit_y), unit_size, unit_spacing, self._UNIT)
def _render(self, rect: rl.Rectangle):
is_metric = self._params.get_bool("IsMetric")
stats = self._source.snapshot
spacing = 28
card_height = (rect.height - spacing) / 2
self._paint_card(rect.x, rect.y, rect.width, card_height, tr("ALL TIME"),
self._columns(stats.get("all", {}), is_metric))
self._paint_card(rect.x, rect.y + card_height + spacing, rect.width, card_height, tr("PAST WEEK"),
self._columns(stats.get("week", {}), is_metric))
return -1

View File

@@ -0,0 +1,3 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""

View File

@@ -0,0 +1,3 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""

View File

@@ -0,0 +1,31 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
ACTIVE_CONFIDENCE_TOP = rl.Color(0x22, 0xB8, 0xB9, 0xFF)
ACTIVE_CONFIDENCE_BOTTOM = rl.Color(0x0C, 0x94, 0x96, 0xFF)
MEDIUM_CONFIDENCE_TOP = rl.Color(255, 200, 0, 255)
MEDIUM_CONFIDENCE_BOTTOM = rl.Color(255, 115, 0, 255)
LOW_CONFIDENCE_TOP = rl.Color(255, 0, 21, 255)
LOW_CONFIDENCE_BOTTOM = rl.Color(255, 0, 89, 255)
class IQConfidenceBall:
@staticmethod
def get_animate_status_probs():
if ui_state.status == UIStatus.LAT_ONLY:
return ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs
# UIStatus.LONG_ONLY
return ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs
@staticmethod
def get_lat_long_dot_colors(confidence: float) -> tuple[rl.Color, rl.Color]:
if confidence > 0.5:
return ACTIVE_CONFIDENCE_TOP, ACTIVE_CONFIDENCE_BOTTOM
if confidence > 0.2:
return MEDIUM_CONFIDENCE_TOP, MEDIUM_CONFIDENCE_BOTTOM
return LOW_CONFIDENCE_TOP, LOW_CONFIDENCE_BOTTOM

View File

@@ -0,0 +1,32 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
from openpilot.iqpilot.ui.onroad.hud_overlays import BlindSpotIndicators
class IQMiciHudRenderer(HudRenderer):
"""Stock Mici HUD extended with IQ.Pilot's own onroad overlays.
Overlays live in a list so the renderer stays overlay-agnostic — each just needs
update()/render(rect); blind-spot state is surfaced by any overlay that exposes it.
"""
def __init__(self):
super().__init__()
self._overlays = [BlindSpotIndicators()]
def _update_state(self) -> None:
super()._update_state()
for overlay in self._overlays:
overlay.update()
def _render(self, rect: rl.Rectangle) -> None:
super()._render(rect)
for overlay in self._overlays:
overlay.render(rect)
def _has_blind_spot_detected(self) -> bool:
return any(getattr(overlay, "detected", False) for overlay in self._overlays)

View File

@@ -0,0 +1,10 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from openpilot.selfdrive.ui.ui_state import UIStatus
IQ_LANE_LINE_COLORS = {
UIStatus.LAT_ONLY: rl.Color(0x0C, 0x94, 0x96, 0xFF),
UIStatus.LONG_ONLY: rl.Color(0x0C, 0x94, 0x96, 0xFF),
}

View File

@@ -0,0 +1,20 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from openpilot.iqpilot.ui.onroad.hud_overlays import RoadNameBanner
class RoadNameRendererMici(RoadNameBanner):
"""Compact capsule tuned for the mici's small panel."""
TYPE_SIZE = 28
FLOOR_WIDTH = 120
SIDE_PAD = 28
MARGIN = 200
DROP = 8
BAR_H = TYPE_SIZE + 14
CURVE = 0.35
SEGS = 8
BACKDROP_A = 140
INK_A = 210
INNER_PAD = 16

View File

View File

@@ -0,0 +1,18 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from openpilot.selfdrive.ui.ui_state import UIStatus
BORDER_COLORS_IQ = {
UIStatus.LAT_ONLY: rl.Color(0x0C, 0x94, 0x96, 0xFF),
UIStatus.LONG_ONLY: rl.Color(0x96, 0x1C, 0xA8, 0xFF), # Purple for longitudinal-only state
}
class AugmentedRoadViewIQ:
def __init__(self):
pass
def update_fade_out_bottom_overlay(self, _content_rect):
pass

View File

@@ -0,0 +1,162 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import time
import numpy as np
import pyray as rl
from openpilot.common.params import Params
from openpilot.selfdrive.ui import UI_BORDER_SIZE
from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer, BTN_SIZE, ARC_LENGTH
from openpilot.iqpilot.ui.onroad.hud_overlays import DeveloperUiRenderer
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
# LongitudinalPersonality ordinals (matches cereal enum: relaxed=0, standard=1, aggressive=2)
_PERSONALITY_RELAXED = 0
_PERSONALITY_STANDARD = 1
_PERSONALITY_AGGRESSIVE = 2
PERSONALITY_COLORS = {
_PERSONALITY_RELAXED: rl.Color(0x17, 0xC9, 0x64, 0xFF), # green
_PERSONALITY_STANDARD: rl.Color(0x0C, 0x94, 0x96, 0xFF), # teal
_PERSONALITY_AGGRESSIVE: rl.Color(0xE8, 0x2C, 0x2C, 0xFF), # red
}
PERSONALITY_NAMES = {
_PERSONALITY_RELAXED: "Relaxed",
_PERSONALITY_STANDARD: "Standard",
_PERSONALITY_AGGRESSIVE: "Aggressive",
}
_TOAST_DURATION = 2.0 # seconds
_TOAST_FONT_SIZE = 52
_TOAST_PAD_X = 52
_TOAST_PAD_Y = 22
_TOAST_BOTTOM_MARGIN = UI_BORDER_SIZE + 36
_TOAST_RADIUS = 0.45
_TOAST_FADE = 0.25 # fade-in / fade-out window
class DriverStateRendererIQ(DriverStateRenderer):
def __init__(self):
super().__init__()
self._params = Params()
self._personality: int = _PERSONALITY_STANDARD
self._personality_color: rl.Color = PERSONALITY_COLORS[_PERSONALITY_STANDARD]
self._toast_end_time: float = 0.0
self._toast_text: str = ""
self._toast_color: rl.Color = rl.WHITE
self._font = gui_app.font(FontWeight.SEMI_BOLD)
self.dev_ui_offset = DeveloperUiRenderer.get_bottom_dev_ui_offset()
self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", BTN_SIZE, BTN_SIZE)
self._dm_person = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_person.png", 118, 118)
self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", 118, 118)
def _update_state(self):
super()._update_state()
personality = self._params.get("LongitudinalPersonality", return_default=True)
if personality is not None:
self._personality = int(personality)
self._personality_color = PERSONALITY_COLORS.get(self._personality, PERSONALITY_COLORS[_PERSONALITY_STANDARD])
def cycle_personality(self):
next_p = (self._personality + 1) % 3
self._params.put_nonblocking("LongitudinalPersonality", next_p)
self._personality = next_p
self._personality_color = PERSONALITY_COLORS[next_p]
self._toast_text = PERSONALITY_NAMES[next_p]
self._toast_color = PERSONALITY_COLORS[next_p]
self._toast_end_time = time.monotonic() + _TOAST_DURATION
def _render(self, _):
fade = max(0.35, 1.0 - self.dm_fade_state)
alpha = int(255 * fade)
pc = self._personality_color
rl.draw_texture(
self._dm_background,
int(self.position_x - self._dm_background.width / 2),
int(self.position_y - self._dm_background.height / 2),
rl.Color(pc.r, pc.g, pc.b, alpha),
)
rl.draw_texture(
self._dm_person,
int(self.position_x - self._dm_person.width / 2),
int(self.position_y - self._dm_person.height / 2),
rl.Color(255, 255, 255, int(alpha * 0.9)),
)
if self.is_active:
dest_rect = rl.Rectangle(self.position_x, self.position_y, self._dm_cone.width, self._dm_cone.height)
rl.draw_texture_pro(
self._dm_cone,
rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height),
dest_rect,
rl.Vector2(dest_rect.width / 2, dest_rect.height / 2),
180.0,
rl.Color(pc.r, pc.g, pc.b, alpha),
)
else:
rl.draw_circle(int(self.position_x), int(self.position_y), 14, rl.Color(255, 255, 255, alpha))
self._draw_personality_toast()
def _draw_personality_toast(self):
now = time.monotonic()
remaining = self._toast_end_time - now
if remaining <= 0 or not self._toast_text:
return
elapsed = _TOAST_DURATION - remaining
fade_in = min(1.0, elapsed / _TOAST_FADE)
fade_out = min(1.0, remaining / _TOAST_FADE)
a = int(255 * fade_in * fade_out)
text_size = measure_text_cached(self._font, self._toast_text, _TOAST_FONT_SIZE)
toast_w = text_size.x + _TOAST_PAD_X * 2
toast_h = text_size.y + _TOAST_PAD_Y * 2
cx = self._rect.x + self._rect.width / 2
toast_x = cx - toast_w / 2
toast_y = self._rect.y + self._rect.height - _TOAST_BOTTOM_MARGIN - toast_h
tc = self._toast_color
toast_rect = rl.Rectangle(toast_x, toast_y, toast_w, toast_h)
rl.draw_rectangle_rounded(toast_rect, _TOAST_RADIUS, 10, rl.Color(tc.r, tc.g, tc.b, a))
rl.draw_text_ex(
self._font, self._toast_text,
rl.Vector2(toast_x + _TOAST_PAD_X, toast_y + _TOAST_PAD_Y),
_TOAST_FONT_SIZE, 0,
rl.Color(255, 255, 255, a),
)
def _pre_calculate_drawing_elements(self):
"""Pre-calculate all drawing elements based on the current rectangle"""
width, height = self._rect.width, self._rect.height
offset = UI_BORDER_SIZE + BTN_SIZE // 2
self.position_x = self._rect.x + (width - offset if self.is_rhd else offset)
self.position_y = self._rect.y + height - offset - self.dev_ui_offset
positioned_keypoints = self.face_keypoints_transformed + np.array([self.position_x, self.position_y])
for i in range(len(positioned_keypoints)):
self.face_lines[i].x = positioned_keypoints[i][0]
self.face_lines[i].y = positioned_keypoints[i][1]
delta_x = -self.driver_pose_sins[1] * ARC_LENGTH / 2.0
delta_y = -self.driver_pose_sins[0] * ARC_LENGTH / 2.0
h_width = abs(delta_x)
self.h_arc_data = self._calculate_arc_data(
delta_x, h_width, self.position_x, self.position_y - ARC_LENGTH / 2,
self.driver_pose_sins[1], self.driver_pose_diff[1], is_horizontal=True
)
v_height = abs(delta_y)
self.v_arc_data = self._calculate_arc_data(
delta_y, v_height, self.position_x - ARC_LENGTH / 2, self.position_y,
self.driver_pose_sins[0], self.driver_pose_diff[0], is_horizontal=False
)

View File

@@ -0,0 +1,950 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
Consolidated IQ.Pilot onroad overlays. Every HUD widget that decorates the
driving view — the accel strip, blind-spot flags, speed readout, road-name
capsule, turn indicators, nav-provider badge and lead chevron labels — lives
here and paints through the shared canvas facade. Grouping them keeps one
import surface and one drawing vocabulary for the whole overlay layer.
"""
import math
import time
import numpy as np
import pyray as rl
from cereal import car, custom
from openpilot.common.constants import CV
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.onroad.hud_renderer import COLORS, FONT_SIZES, UI_CONFIG
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import IconSide, TURN_SIGNAL_BLINK_PERIOD
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.iqpilot.lib import canvas
from opendbc.car.volkswagen.values import VolkswagenFlags
# --- shared state access -----------------------------------------------------
def _feed():
return ui_state.sm
def _speed_scale() -> float:
return CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
# ============================================================================
# Accel strip
# ============================================================================
_STRIP_WIDTH = 28
_STRIP_CEILING = 0.85
_STRIP_EMA = 5.0
_STRIP_GO = canvas.shade(0, 245, 0, 200)
_STRIP_STOP = canvas.shade(245, 0, 0, 200)
class RocketFuel:
def __init__(self):
self._eased = 0.0
def _reach(self) -> float:
mag = abs(self._eased)
return 0.0 if mag == 0.0 else max(0.0, _STRIP_CEILING - 0.1 / mag)
def render(self, rect, sm) -> None:
if not ui_state.rocket_fuel:
return
self._eased += (sm['carState'].aEgo - self._eased) / _STRIP_EMA
reach = self._reach() * rect.height / 2.0
if reach <= 0.0:
return
mid = rect.y + rect.height / 2.0
top, tint = (mid - reach, _STRIP_GO) if self._eased > 0.0 else (mid, _STRIP_STOP)
canvas.slab(rect.x, top, _STRIP_WIDTH, reach, tint)
# ============================================================================
# Blind-spot flags
# ============================================================================
_BS_INSET = 20
_BS_DROP = 100
_BS_MIN = 0.01
class _BlindSide:
def __init__(self, name: str):
self.texture = gui_app.texture(f'icons_mici/onroad/blind_spot_{name}.png', 108, 128)
self.glow = FirstOrderFilter(0, 0.15, 1 / gui_app.target_fps)
self.on_left = name == "left"
def feed(self, present: bool):
self.glow.update(1.0 if present else 0.0)
def lit(self) -> bool:
return self.glow.x > _BS_MIN
def place(self, rect):
tex = self.texture
x = rect.x + _BS_INSET if self.on_left else rect.x + rect.width - _BS_INSET - tex.width
canvas.stamp(tex, x, rect.y + _BS_DROP, canvas.shade(255, 255, 255, int(255 * self.glow.x)))
class BlindSpotIndicators:
def __init__(self):
self._left = _BlindSide("left")
self._right = _BlindSide("right")
def update(self) -> None:
cs = _feed()['carState']
self._left.feed(cs.leftBlindspot)
self._right.feed(cs.rightBlindspot)
@property
def detected(self) -> bool:
return self._left.lit() or self._right.lit()
def render(self, rect) -> None:
if not ui_state.blindspot:
return
for side in (self._left, self._right):
if side.lit():
side.place(rect)
# ============================================================================
# Speed readout
# ============================================================================
_MQB_CLUSTER_EXEMPT = (VolkswagenFlags.PQ | VolkswagenFlags.MLB | VolkswagenFlags.MEB |
VolkswagenFlags.MEB_GEN2 | VolkswagenFlags.MQB_EVO)
class SpeedRenderer:
def __init__(self):
self.speed: float = 0.0
self._cluster_ever_live: bool = False
self._heavy = gui_app.font(FontWeight.BOLD)
self._mid = gui_app.font(FontWeight.MEDIUM)
def _source_speed(self, cs) -> float:
cp = ui_state.CP
if cp is not None and cp.brand == "volkswagen" and not (cp.flags & _MQB_CLUSTER_EXEMPT):
return cs.vEgoCluster
self._cluster_ever_live = self._cluster_ever_live or cs.vEgoCluster != 0.0
return cs.vEgoCluster if self._cluster_ever_live else cs.vEgo
def update(self) -> None:
self.speed = max(0.0, self._source_speed(_feed()['carState']) * _speed_scale())
def _stack(self, face, text: str, size: int, rect, top: float, color) -> float:
extent = canvas.span(face, text, size)
canvas.glyphs(face, text, canvas.Pt(rect.x + (rect.width - extent.x) / 2, top), size, color)
return extent.y
def render(self, rect) -> None:
top = rect.y + 52
number_h = self._stack(self._heavy, str(round(self.speed)), FONT_SIZES.current_speed, rect, top, COLORS.WHITE)
unit = tr("km/h") if ui_state.is_metric else tr("mph")
self._stack(self._mid, unit, FONT_SIZES.speed_unit, rect, top + number_h - 10, COLORS.WHITE_TRANSLUCENT)
# ============================================================================
# Road-name capsule
# ============================================================================
def clip_to_width(font, words: str, size: int, limit: float) -> str:
if canvas.span(font, words, size).x <= limit:
return words
trimmed = words
while len(trimmed) > 3 and canvas.span(font, trimmed + "...", size).x > limit:
trimmed = trimmed[:-1]
return trimmed + "..."
class RoadNameBanner(Widget):
TYPE_SIZE = 46
FLOOR_WIDTH = 200
SIDE_PAD = 40
MARGIN = 40
DROP = -4
BAR_H = 60
CURVE = 0.2
SEGS = 10
BACKDROP_A = 120
INK_A = 200
INNER_PAD = 20
def __init__(self):
super().__init__()
self.road_name = ""
self._face = gui_app.font(FontWeight.SEMI_BOLD)
def update(self):
sm = _feed()
if sm.recv_frame["carState"] < ui_state.started_frame:
return
if sm.updated["iqLiveData"]:
self.road_name = sm["iqLiveData"].roadName
def _render(self, rect):
if not self.road_name or not ui_state.road_name_toggle:
return
natural = canvas.span(self._face, self.road_name, self.TYPE_SIZE).x
bar_w = max(self.FLOOR_WIDTH, min(natural + self.SIDE_PAD, rect.width - self.MARGIN))
bar = canvas.Box(rect.x + (rect.width - bar_w) / 2, rect.y + self.DROP, bar_w, self.BAR_H)
canvas.panel(bar, self.CURVE, self.SEGS, canvas.shade(0, 0, 0, self.BACKDROP_A))
label = clip_to_width(self._face, self.road_name, self.TYPE_SIZE, bar.width - self.INNER_PAD)
extent = canvas.span(self._face, label, self.TYPE_SIZE)
canvas.glyphs(self._face, label,
canvas.Pt(bar.x + (bar.width - extent.x) / 2, bar.y + (bar.height - extent.y) / 2),
self.TYPE_SIZE, canvas.shade(255, 255, 255, self.INK_A))
RoadNameRenderer = RoadNameBanner
ellipsize = clip_to_width
# ============================================================================
# Turn indicators
# ============================================================================
from dataclasses import dataclass, field
_ARROW = 'signal'
_WARN = 'blind_spot'
@dataclass(frozen=True)
class TurnSignalConfig:
left_x: int = 80
left_y: int = 190
right_x: int = 80
right_y: int = 190
size: int = 150
class _IndicatorLamp(Widget):
def __init__(self, direction: IconSide):
super().__init__()
self.mode: str | None = None
self._epoch = 0.0
self._glow = FirstOrderFilter(0.0, 0.3, 1 / gui_app.target_fps)
self._art = {
_ARROW: gui_app.texture(f'icons_mici/onroad/turn_signal_{direction}.png', 120, 109),
_WARN: gui_app.texture(f'icons_mici/onroad/blind_spot_{direction}.png', 120, 109),
}
def set_mode(self, mode: str | None):
if mode != self.mode or mode is None:
self._epoch = 0.0
self.mode = mode
def _pulse(self) -> int:
# onroad/offroad run at different target_fps (set_target_fps only takes effect after this
# widget is constructed at offroad startup), so re-derive dt each frame instead of trusting
# the fps baked in at __init__ — otherwise the glow decays ~3x too slowly onroad and never
# visibly dims before the next reset, reading as a static-on arrow instead of a blink.
self._glow.dt = 1 / gui_app.target_fps
self._glow.update_alpha(0.3)
if time.monotonic() - self._epoch > TURN_SIGNAL_BLINK_PERIOD:
self._epoch = time.monotonic()
self._glow.x = 255 * 2
else:
self._glow.update(255 * 0.2)
return int(min(self._glow.x, 255))
def _render(self, _):
if self.mode is None:
return
alpha = self._pulse() if self.mode == _ARROW else 255
tex = self._art[self.mode]
canvas.stamp(tex, self._rect.x + (self._rect.width - tex.width) / 2,
self._rect.y + (self._rect.height - tex.height) / 2, canvas.shade(255, 255, 255, alpha))
def _lamp_modes(event_name: str, cs, remembered):
if event_name == 'preLaneChangeLeft':
return _ARROW, None, IconSide.left
if event_name == 'preLaneChangeRight':
return None, _ARROW, IconSide.right
if event_name == 'laneChange':
if remembered == IconSide.left:
return _ARROW, None, remembered
if remembered == IconSide.right:
return None, _ARROW, remembered
return None, None, remembered
if event_name == 'laneChangeBlocked':
side = IconSide.left if cs.leftBlinker else IconSide.right if cs.rightBlinker else remembered
if side == IconSide.left:
return _WARN, None, remembered
if side == IconSide.right:
return None, _WARN, remembered
return None, None, remembered
left = _WARN if cs.leftBlindspot else _ARROW if cs.leftBlinker else None
right = _WARN if cs.rightBlindspot else _ARROW if cs.rightBlinker else None
return left, right, None
class TurnSignalController:
def __init__(self, config: TurnSignalConfig | None = None):
self._config = config or TurnSignalConfig()
self._lamps = {IconSide.left: _IndicatorLamp(IconSide.left),
IconSide.right: _IndicatorLamp(IconSide.right)}
self._remembered: IconSide | None = None
def update(self):
sm = _feed()
alert = sm['selfdriveState'].alertType
event_name = alert.split('/')[0] if alert else ''
left, right, self._remembered = _lamp_modes(event_name, sm['carState'], self._remembered)
self._lamps[IconSide.left].set_mode(left)
self._lamps[IconSide.right].set_mode(right)
def render(self, rect):
if not ui_state.turn_signals:
return
c = self._config
mid_x = rect.x + rect.width / 2
spots = {
IconSide.left: canvas.Box(mid_x - c.left_x - c.size, rect.y + c.left_y, c.size, c.size),
IconSide.right: canvas.Box(mid_x + c.right_x, rect.y + c.right_y, c.size, c.size),
}
for side, lamp in self._lamps.items():
if lamp.mode is not None:
lamp.render(spots[side])
@property
def config(self) -> TurnSignalConfig:
return self._config
@config.setter
def config(self, new_config: TurnSignalConfig):
self._config = new_config
# ============================================================================
# Nav-influence provider badge
# ============================================================================
_PROVIDER_TAGS = {0: "", 1: "NAV", 2: "MBX", 3: "VIS", 4: "OSM"}
_NAV_TEX_W, _NAV_TEX_H = 256, 128
_NAV_BADGE_W = 160
_NAV_FONT = 36
_NAV_SHIFT = -260
class NavInfluenceRenderer(Widget):
def __init__(self):
super().__init__()
self.engaged = False
self.valid = False
self.provider = 0
self.long_override = False
self._streak = 0
self.font = gui_app.font(FontWeight.BOLD)
self._offscreen = rl.load_render_texture(_NAV_TEX_W, _NAV_TEX_H)
def update(self):
sm = _feed()
if sm.updated["iqPlan"]:
nav = sm["iqPlan"].iqNavState.nav
self.engaged = nav.engaged
self.valid = nav.valid
self.provider = getattr(nav.provider, "raw", nav.provider)
if sm.updated["carControl"]:
self.long_override = sm["carControl"].cruiseControl.override
self._streak = self._streak + 1 if (self.engaged and self.valid) else 0
def _blinked_off(self) -> bool:
fps = gui_app.target_fps
return self.engaged and (self._streak % fps) < (fps / 2.5)
def _bake(self, label: str):
extent = canvas.span(self.font, label, _NAV_FONT)
badge = canvas.Box((_NAV_TEX_W - _NAV_BADGE_W) // 2, (_NAV_TEX_H - extent.y - 10) // 2,
_NAV_BADGE_W, int(extent.y + 10))
rl.begin_texture_mode(self._offscreen)
rl.clear_background(canvas.CLEAR)
canvas.panel(badge, 0.2, 10, COLORS.OVERRIDE if self.long_override else canvas.shade(0, 255, 0, 255))
rl.rl_set_blend_factors(rl.RL_ZERO, rl.RL_ONE_MINUS_SRC_ALPHA, 0x8006)
rl.rl_set_blend_mode(rl.BLEND_CUSTOM)
canvas.glyphs(self.font, label,
canvas.Pt(badge.x + (badge.width - extent.x) / 2, badge.y + (badge.height - extent.y) / 2),
_NAV_FONT, canvas.WHITE)
rl.rl_set_blend_mode(rl.BLEND_ALPHA)
rl.end_texture_mode()
def _render(self, rect):
if not self.valid or self._blinked_off():
return
label = _PROVIDER_TAGS.get(int(self.provider), "NAV")
if not label:
return
self._bake(label)
ax = rect.x + rect.width / 2 + _NAV_SHIFT - _NAV_TEX_W / 2
ay = (rect.height / 4 - 40) - _NAV_TEX_H / 2
canvas.stamp_scaled(self._offscreen.texture, canvas.Box(0, 0, _NAV_TEX_W, -_NAV_TEX_H),
canvas.Box(ax, ay, _NAV_TEX_W, _NAV_TEX_H), canvas.Pt(0, 0), 0, canvas.WHITE)
# ============================================================================
# Lead chevron labels
# ============================================================================
class ChevronOptions:
OFF = 0
DISTANCE_ONLY = 1
SPEED_ONLY = 2
TTC_ONLY = 3
ALL = 4
_CH_FONT = 40
_CH_LINE = 50
_CH_MARGIN = 20
_CH_FADE_DOWN = 0.05
_CH_FADE_UP = 0.1
_CH_DEDUP = 3.0
def _gap_label(d_rel: float, _v_abs: float) -> str:
val = max(0.0, d_rel)
return f"{val:.0f} m" if ui_state.is_metric else f"{val * 3.28084:.0f} ft"
def _pace_label(_d_rel: float, v_abs: float) -> str:
unit = "km/h" if ui_state.is_metric else "mph"
return f"{max(0.0, v_abs * _speed_scale()):.0f} {unit}"
def _ttc_label(d_rel: float, _v_abs: float, v_ego: float) -> str:
ttc = (d_rel / v_ego) if (d_rel > 0 and v_ego > 0) else 0.0
return f"{ttc:.1f} s" if 0 < ttc < 200 else "---"
_CH_METRICS = (
((ChevronOptions.DISTANCE_ONLY, ChevronOptions.ALL), lambda d, va, ve: _gap_label(d, va)),
((ChevronOptions.SPEED_ONLY, ChevronOptions.ALL), lambda d, va, ve: _pace_label(d, va)),
((ChevronOptions.TTC_ONLY, ChevronOptions.ALL), _ttc_label),
)
class ChevronMetrics:
def __init__(self):
self._alpha: float = 0.0
self._font = gui_app.font(FontWeight.SEMI_BOLD)
def update_alpha(self, has_lead: bool):
self._alpha = float(np.clip(self._alpha + (_CH_FADE_UP if has_lead else -_CH_FADE_DOWN), 0.0, 1.0))
def should_render(self) -> bool:
return ui_state.chevron_metrics != ChevronOptions.OFF and self._alpha > 0.0
@staticmethod
def _marker_size(d_rel: float) -> float:
return float(np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0)) * 2.35
def _labels(self, d_rel: float, v_rel: float, v_ego: float) -> list[str]:
mode = ui_state.chevron_metrics
return [fn(d_rel, v_rel + v_ego, v_ego) for modes, fn in _CH_METRICS if mode in modes]
def _top_y(self, anchor_y: float, size: float, n: int, rect) -> float:
y = anchor_y + size + 15
block = n * _CH_LINE
floor = rect.y + rect.height - _CH_MARGIN
if y + block > floor:
y = max(rect.y + _CH_MARGIN, min(anchor_y, floor) - 15 - block)
return y
def _stack(self, lines, cx: float, top: float, rect):
a = self._alpha
fg = canvas.shade(255, 255, 255, int(255 * a))
shadow = canvas.shade(0, 0, 0, int(200 * a))
floor = rect.y + rect.height - _CH_MARGIN
for i, line in enumerate(lines):
y = int(top + i * _CH_LINE)
if y + _CH_LINE > floor:
break
w = canvas.span(self._font, line, _CH_FONT).x
x = int(np.clip(cx - w / 2, rect.x + _CH_MARGIN, rect.x + rect.width - w - _CH_MARGIN))
canvas.glyphs(self._font, line, canvas.Pt(x + 2, y + 2), _CH_FONT, shadow)
canvas.glyphs(self._font, line, canvas.Pt(x, y), _CH_FONT, fg)
def _one_lead(self, lead, marker, v_ego: float, rect):
if not self.should_render() or marker.center is None:
return
lines = self._labels(lead.dRel, lead.vRel, v_ego)
if not lines:
return
self._stack(lines, marker.center[0], self._top_y(marker.center[1], self._marker_size(lead.dRel), len(lines), rect), rect)
@staticmethod
def _active_leads(radar_state, markers):
"""Yield (lead, marker) for tracked leads with a projected marker, dropping a
second lead that sits within the dedup band of the first."""
tracked = []
for lead, marker in zip((radar_state.leadOne, radar_state.leadTwo), markers, strict=False):
if lead and lead.status and marker.center is not None:
tracked.append((lead, marker))
if len(tracked) == 2 and abs(tracked[0][0].dRel - tracked[1][0].dRel) <= _CH_DEDUP:
tracked.pop()
return tracked
def draw_lead_status(self, sm, radar_state, rect, lead_vehicles):
present = [radar_state.leadOne, radar_state.leadTwo]
self.update_alpha(any(bool(x) and x.status for x in present))
if not self.should_render():
return
v_ego = sm['carState'].vEgo
for lead, marker in self._active_leads(radar_state, lead_vehicles):
self._one_lead(lead, marker, v_ego, rect)
# ============================================================================
# Developer telemetry bar
# ============================================================================
_TEAL = canvas.shade(0x0C, 0x94, 0x96, 0xFF)
_AMBER = canvas.shade(255, 188, 0, 255)
_GREEN = canvas.shade(0, 255, 0, 255)
_GREY = canvas.shade(145, 155, 149, 255)
_G = 9.81
_BAR_FONT = 38
_ANGLE_TYPES = (car.CarParams.SteerControlType.angle, car.CarParams.SteerControlType.curvatureDEPRECATED)
@dataclass
class Readout:
"""One bar cell: 'TAG value unit', with each part pre-measured for layout."""
tag: str
value: str
unit: str = ""
color: object = field(default_factory=lambda: canvas.WHITE)
tag_text: str = ""
value_text: str = ""
unit_text: str = ""
tag_w: float = 0.0
value_w: float = 0.0
unit_w: float = 0.0
span: float = 0.0
def size_up(self, font, px: int):
self.tag_text = f"{self.tag} "
self.value_text = self.value
self.unit_text = f" {self.unit}" if self.unit else ""
self.tag_w = canvas.span(font, self.tag_text, px, 0).x
self.value_w = canvas.span(font, self.value_text, px, 0).x
self.unit_w = canvas.span(font, self.unit_text, px, 0).x if self.unit else 0
self.span = self.tag_w + self.value_w + self.unit_w
# kept for external callers that used the old field/method names
@property
def total_width(self):
return self.span
def measure(self, font, px):
self.size_up(font, px)
UiElement = Readout
# --- grading -----------------------------------------------------------------
def _banded(magnitude, warn, crit, ok):
if magnitude > crit:
return canvas.RED
return _AMBER if magnitude > warn else ok
def _closing(v_rel):
return _banded(-v_rel if v_rel < 0 else 0.0, 0.0, 4.4704, canvas.WHITE)
def _following(d_rel):
if d_rel < 5:
return canvas.RED
return _AMBER if d_rel < 15 else canvas.WHITE
def _steer_tint(sm):
if not sm['carControl'].latActive:
return canvas.WHITE
return _GREY if sm['carState'].steeringPressed else _TEAL
def _angle_tint(sm, deg):
floor = _steer_tint(sm) if sm['carControl'].latActive else canvas.WHITE
return _banded(abs(deg), 90.0, 180.0, floor)
def _yaw_offset(sm):
return sm['liveParameters'].angleOffsetAverageDeg if sm.valid['liveParameters'] else 0.0
def _bank(sm):
return sm['liveParameters'].roll if sm.valid['liveParameters'] else 0.0
def _units(is_metric):
return (CV.MS_TO_KPH, "km/h") if is_metric else (CV.MS_TO_MPH, "mph")
def _fix(sm):
for svc in ('gpsLocationExternal', 'gpsLocation'):
if sm.valid[svc]:
return sm[svc], svc
return None, None
# --- probes (sm, is_metric) -> Readout ---------------------------------------
def steering_angle(sm, is_metric):
deg = sm['carState'].steeringAngleDeg - _yaw_offset(sm)
return Readout("R.S.", f"{deg:.1f}°", color=_angle_tint(sm, deg))
def desired_steering_angle(sm, is_metric):
live = sm['carControl'].latActive
off = _yaw_offset(sm)
lat = sm['controlsState'].lateralControlState
if lat.which() == 'angleState':
want = lat.angleState.steeringAngleDesiredDeg - off
else:
want = sm['carControl'].actuators.steeringAngleDeg - off
seen = sm['carState'].steeringAngleDeg - off
tint = _banded(abs(seen), 90.0, 180.0, _TEAL) if live else canvas.WHITE
return Readout("D.S.", f"{want:.1f}°" if live else "-", color=tint)
def desired_steering_pid(sm, is_metric):
live = sm['carControl'].latActive
off = _yaw_offset(sm)
want = sm['controlsState'].lateralControlState.pidState.steeringAngleDesiredDeg - off
seen = sm['carState'].steeringAngleDeg - off
tint = _banded(abs(seen), 90.0, 180.0, _TEAL) if live else canvas.WHITE
return Readout("D.S.", f"{want:.1f}°" if live else "-", color=tint)
def actual_lat_accel(sm, is_metric):
a = sm['controlsState'].curvature * sm['carState'].vEgo ** 2 - _bank(sm) * _G
return Readout("A.L.A.", f"{a:.2f}", "m/s^2", _steer_tint(sm))
def desired_lat_accel(sm, is_metric):
live = sm['carControl'].latActive
a = sm['controlsState'].desiredCurvature * sm['carState'].vEgo ** 2 - _bank(sm) * _G
return Readout("D.L.A.", f"{a:.2f}" if live else "-", "m/s^2", _steer_tint(sm))
def a_ego(sm, is_metric):
return Readout("L.ACC.", f"{sm['carState'].aEgo:.1f}", "m/s^2")
def lead_distance(sm, is_metric):
lead = sm['radarState'].leadOne
return Readout("REL DIST", "-", "m") if not lead.status else Readout("REL DIST", f"{lead.dRel:.0f}", "m", _following(lead.dRel))
def lead_rel_speed(sm, is_metric):
lead = sm['radarState'].leadOne
k, unit = _units(is_metric)
return Readout("REL SPEED", "-", unit) if not lead.status else Readout("REL SPEED", f"{lead.vRel * k:.0f}", unit, _closing(lead.vRel))
def lead_speed(sm, is_metric):
lead = sm['radarState'].leadOne
k, unit = _units(is_metric)
if not lead.status:
return Readout("L.S.", "-", unit)
return Readout("L.S.", f"{(lead.vRel + sm['carState'].vEgo) * k:.0f}", unit, _closing(lead.vRel))
def friction_coefficient(sm, is_metric):
ltp = sm['liveTorqueParameters']
return Readout("FRIC.", f"{ltp.frictionCoefficientFiltered:.3f}", color=_GREEN if ltp.liveValid else canvas.WHITE)
def lat_accel_factor(sm, is_metric):
ltp = sm['liveTorqueParameters']
return Readout("L.A.F.", f"{ltp.latAccelFactorFiltered:.3f}", color=_GREEN if ltp.liveValid else canvas.WHITE)
def eps_torque(sm, is_metric):
return Readout("E.T.", f"{abs(sm['carState'].steeringTorqueEps):.1f}", "N·dm")
_COMPASS = ("N", "NE", "E", "SE", "S", "SW", "W", "NW")
def bearing(sm, is_metric):
fix, _ = _fix(sm)
if fix is None or fix.bearingAccuracyDeg == 180.0:
return Readout("B.D.", "OFF | -")
heading = _COMPASS[int(((fix.bearingDeg + 22.5) % 360) // 45)]
return Readout("B.D.", f"{heading} | {fix.bearingDeg:.0f}°")
def altitude(sm, is_metric):
fix, svc = _fix(sm)
if fix is None:
return Readout("ALT.", "-", "m")
acc = fix.horizontalAccuracy if svc == 'gpsLocationExternal' else 1.0
return Readout("ALT.", f"{fix.altitude:.1f}" if acc != 0.0 else "-", "m")
def _desired_probe(sm):
"""The 'desired' cell tracks whichever lateral controller is live."""
if sm['controlsState'].lateralControlState.which() == 'angleState':
return desired_steering_angle
if ui_state.CP is not None and ui_state.CP.steerControlType in _ANGLE_TYPES:
return desired_steering_angle
if sm['controlsState'].lateralControlState.which() == 'pidState':
return desired_steering_pid
return desired_lat_accel
class DeveloperUiRenderer(Widget):
DEV_UI_OFF = 0
DEV_UI_RIGHT = 1
DEV_UI_BOTTOM = 2
DEV_UI_BOTH = 3
BOTTOM_BAR_HEIGHT = 61
def __init__(self):
super().__init__()
self._face = gui_app.font(FontWeight.BOLD)
self.dev_ui_mode = self.DEV_UI_OFF
@staticmethod
def get_bottom_dev_ui_offset():
return DeveloperUiRenderer.BOTTOM_BAR_HEIGHT if ui_state.developer_ui != DeveloperUiRenderer.DEV_UI_OFF else 0
def _update_state(self) -> None:
self.dev_ui_mode = ui_state.developer_ui
def _render(self, rect) -> None:
if self.dev_ui_mode == self.DEV_UI_OFF:
return
sm = ui_state.sm
if sm.recv_frame["carState"] < ui_state.started_frame:
return
self._paint_bar(rect)
def _gather(self, sm):
probes = (_desired_probe(sm), actual_lat_accel, steering_angle, a_ego, lead_speed)
cells = [probe(sm, ui_state.is_metric) for probe in probes]
for cell in cells:
cell.size_up(self._face, _BAR_FONT)
return cells
def _paint_bar(self, rect) -> None:
height = self.BOTTOM_BAR_HEIGHT
top = int(rect.y + rect.height - height)
canvas.slab(rect.x, top, rect.width, height, canvas.shade(0, 0, 0, 100))
cells = self._gather(ui_state.sm)
slack = (rect.width - sum(c.span for c in cells)) / (len(cells) + 1)
baseline = top + height // 2 - _BAR_FONT // 2
cursor = rect.x + slack
for cell in cells:
self._paint_cell(cursor, baseline, cell)
cursor += cell.span + slack
def _paint_cell(self, x, y, cell) -> None:
canvas.glyphs(self._face, cell.tag_text, canvas.Pt(x, y), _BAR_FONT, canvas.WHITE)
canvas.glyphs(self._face, cell.value_text, canvas.Pt(x + cell.tag_w, y), _BAR_FONT, cell.color)
if cell.unit:
canvas.glyphs(self._face, cell.unit_text, canvas.Pt(x + cell.tag_w + cell.value_w, y), _BAR_FONT, canvas.WHITE)
# ============================================================================
# Speed-limit sign (Vienna / MUTCD) + limit-ahead preview + assist arrows
# ============================================================================
_SL_M_TO_FT = 3.28084
_SL_M_TO_MI = 0.000621371
_SL_AHEAD_STEPS = 5
_SL_ASSIST = custom.IQPlan.SpeedLimit.AssistState
_SL_SOURCE = custom.IQPlan.SpeedLimit.Source
_SL_GREY = canvas.shade(145, 155, 149, 255)
_SL_DARK = canvas.shade(77, 77, 77, 255)
_SL_PANEL_BG = canvas.shade(0, 0, 0, 180)
_SL_PANEL_EDGE = canvas.shade(255, 255, 255, 100)
def _dim(color, alpha: float):
return canvas.with_opacity(color, 255 * alpha)
class SpeedLimitRenderer(Widget):
"""Regulatory sign, upcoming-limit preview and pre-active nudge arrows."""
def __init__(self):
super().__init__()
self.speed_limit = 0.0
self.speed_limit_last = 0.0
self.speed_limit_offset = 0.0
self.speed_limit_valid = False
self.speed_limit_last_valid = False
self.speed_limit_final_last = 0.0
self.speed_limit_source = _SL_SOURCE.none
self.assist_state = _SL_ASSIST.disabled
self.ahead_limit = 0.0
self.ahead_dist = 0.0
self._ahead_prev = 0.0
self.ahead_valid = False
self._ahead_streak = 0
self.assist_frame = 0
self.speed = 0.0
self.set_speed = 0.0
self._bold = gui_app.font(FontWeight.BOLD)
self._demi = gui_app.font(FontWeight.SEMI_BOLD)
self._norm = gui_app.font(FontWeight.NORMAL)
self._pulse_ema = FirstOrderFilter(1.0, 0.5, 1 / gui_app.target_fps)
px = 90
self._up = gui_app.texture("../../iqpilot/selfdrive/assets/img_plus_arrow_up.png", px, px)
self._down = gui_app.texture("../../iqpilot/selfdrive/assets/img_minus_arrow_down.png", px, px)
@property
def speed_limit_assist_state(self):
return self.assist_state
@property
def _scale(self):
return CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
def _take_plan(self, lp_iq):
k = self._scale
r = lp_iq.speedLimit.resolver
self.speed_limit = r.speedLimit * k
self.speed_limit_last = r.speedLimitLast * k
self.speed_limit_offset = r.speedLimitOffset * k
self.speed_limit_valid = r.speedLimitValid
self.speed_limit_last_valid = r.speedLimitLastValid
self.speed_limit_final_last = r.speedLimitFinalLast * k
self.speed_limit_source = r.source
self.assist_state = lp_iq.speedLimit.assist.state
def _take_ahead(self, lmd):
self.ahead_valid = lmd.speedLimitAheadValid
self.ahead_limit = lmd.speedLimitAhead * self._scale
self.ahead_dist = lmd.speedLimitAheadDistance
if self.ahead_dist < self._ahead_prev:
self._ahead_streak = min(_SL_AHEAD_STEPS, self._ahead_streak + 1)
elif self.ahead_dist > self._ahead_prev:
self._ahead_streak = max(0, self._ahead_streak - 1)
self._ahead_prev = self.ahead_dist
def update(self):
sm = _feed()
if sm.recv_frame["carState"] < ui_state.started_frame:
return
if sm.updated["iqPlan"]:
self._take_plan(sm["iqPlan"])
if sm.updated["iqLiveData"]:
self._take_ahead(sm["iqLiveData"])
cs = sm["carState"]
self.set_speed = cs.cruiseState.speed * self._scale
v_ego = cs.vEgoCluster if cs.vEgoCluster != 0.0 else cs.vEgo
self.speed = max(0.0, v_ego * self._scale)
def _spec(self):
has_limit = self.speed_limit_valid or self.speed_limit_last_valid
value = str(round(self.speed_limit_last)) if has_limit else "---"
badge = ""
if self.speed_limit_offset != 0:
badge = f"{'' if self.speed_limit_offset > 0 else '-'}{round(abs(self.speed_limit_offset))}"
warn = ui_state.speed_limit_mode >= 2 # SpeedLimitMode.warning
over = has_limit and round(self.speed_limit_final_last) < round(self.speed)
tint = canvas.RED if (warn and over) else (_SL_GREY if not self.speed_limit_valid else canvas.BLACK)
return value, badge, tint, has_limit
def _render(self, rect):
if ui_state.speed_limit_mode == 0: # SpeedLimitMode.off
return
w = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial
sign = canvas.Box(rect.x + 60 - 6, rect.y + 45 + UI_CONFIG.set_speed_height + 12, w + 12, 160)
if self.assist_state == _SL_ASSIST.preActive:
self.assist_frame += 1
pulse = 0.65 + 0.35 * math.sin(self.assist_frame * math.pi / gui_app.target_fps)
self._sign(sign, self._pulse_ema.update(pulse))
self._nudge_arrow(sign)
else:
self.assist_frame = 0
self._pulse_ema.update(1.0)
self._sign(sign)
self._ahead(sign)
def _sign(self, rect, alpha=1.0):
value, badge, tint, has_limit = self._spec()
(self._vienna if ui_state.is_metric else self._mutcd)(rect, value, badge, tint, has_limit, alpha)
def _nudge_arrow(self, sign):
delta = round(self.speed_limit_final_last) - round(self.set_speed)
if delta == 0:
return
arrow = self._up if delta > 0 else self._down
bounce = int(20 * math.sin(self.assist_frame * 2.0 * math.pi / (gui_app.target_fps * 2.5)))
x = sign.x + (sign.width - arrow.width) / 2
y = sign.y + (sign.height - arrow.height) / 2 + (bounce if delta > 0 else -bounce)
canvas.stamp(arrow, x, y, canvas.WHITE)
def _vienna(self, rect, value, badge, tint, has_limit, alpha=1.0):
hub = canvas.Pt(rect.x + rect.width / 2, rect.y + rect.height / 2)
radius = (rect.width + 18) / 2
canvas.disc_at(hub, radius, _dim(canvas.WHITE, alpha))
canvas.annulus(hub, radius * 0.80, radius, 0, 360, 36, _dim(canvas.RED, alpha))
canvas.glyphs_centered(self._bold, value, 70 if len(value) >= 3 else 85, hub, _dim(tint, alpha))
if badge and has_limit:
br = radius * 0.4
bc = canvas.Pt(rect.x + rect.width - br / 2, rect.y + br / 2)
canvas.disc_at(bc, br, _dim(canvas.BLACK, alpha))
canvas.annulus(bc, br - 3, br, 0, 360, 36, _dim(_SL_DARK, alpha))
canvas.glyphs_centered(self._bold, badge, int(br * 2 * (0.5 if len(badge) < 3 else 0.45)), bc, _dim(canvas.WHITE, alpha))
def _mutcd(self, rect, value, badge, tint, has_limit, alpha=1.0):
canvas.panel(rect, 0.35, 10, _dim(canvas.WHITE, alpha))
inner = canvas.Box(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20)
canvas.panel_outline(inner, 0.35, 10, 4, _dim(canvas.BLACK, alpha))
mid = rect.x + rect.width / 2
canvas.glyphs_centered(self._demi, "SPEED", 40, canvas.Pt(mid, rect.y + 40), _dim(canvas.BLACK, alpha))
canvas.glyphs_centered(self._demi, "LIMIT", 40, canvas.Pt(mid, rect.y + 80), _dim(canvas.BLACK, alpha))
canvas.glyphs_centered(self._bold, value, 90, canvas.Pt(mid, rect.y + 150), _dim(tint, alpha))
if badge and has_limit:
side = rect.width * 0.3
overlap = side * 0.2
chip = canvas.Box(rect.x + rect.width - side / 1.5 + overlap, rect.y - side / 1.25 + overlap, side, side)
canvas.panel(chip, 0.35, 10, _dim(canvas.BLACK, alpha))
canvas.panel_outline(chip, 0.35, 10, 6, _dim(_SL_DARK, alpha))
canvas.glyphs_centered(self._bold, badge, int(side * (0.6 if len(badge) < 3 else 0.475)),
canvas.Pt(chip.x + side / 2, chip.y + side / 2), _dim(canvas.WHITE, alpha))
def _ahead(self, sign):
if not (self.ahead_valid and self.ahead_limit > 0 and self.ahead_limit != self.speed_limit_last and self._ahead_streak > 0):
return
panel = canvas.Box(sign.x + (sign.width - 170) / 2, sign.y + sign.height + 10, 170, 160)
canvas.panel(panel, 0.35, 10, _SL_PANEL_BG)
canvas.panel_outline(panel, 0.35, 10, 3, _SL_PANEL_EDGE)
mid = panel.x + panel.width / 2
canvas.glyphs_centered(self._demi, "AHEAD", 40, canvas.Pt(mid, panel.y + 28), _SL_GREY)
canvas.glyphs_centered(self._bold, str(round(self.ahead_limit)), 70, canvas.Pt(mid, panel.y + 82), canvas.WHITE)
canvas.glyphs_centered(self._norm, self._dist(self.ahead_dist), 36, canvas.Pt(mid, panel.y + 134), _SL_GREY)
@staticmethod
def _dist(d):
if ui_state.is_metric:
if d < 50:
return tr("Near")
if d >= 1000:
return f"{d / 1000:.1f} km"
return f"{int(round(d, -1) if d < 200 else round(d, -2))} m"
ft = d * _SL_M_TO_FT
if ft < 100:
return tr("Near")
if ft >= 900:
return f"{d * _SL_M_TO_MI:.1f} mi"
step = 50 if ft < 500 else 100
return f"{int(round(ft / step) * step)} ft"

View File

@@ -0,0 +1,82 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.onroad.hud_renderer import HudRenderer
from openpilot.iqpilot.ui.onroad.hud_overlays import DeveloperUiRenderer
from openpilot.iqpilot.ui.onroad.nav_map_panel import NavMapPanel
from openpilot.iqpilot.ui.onroad.hud_overlays import RoadNameRenderer
from openpilot.iqpilot.ui.onroad.hud_overlays import RocketFuel
from openpilot.iqpilot.ui.onroad.hud_overlays import SpeedLimitRenderer
from openpilot.iqpilot.ui.onroad.hud_overlays import TurnSignalController
from openpilot.iqpilot.ui.onroad.hud_overlays import SpeedRenderer
from openpilot.iqpilot.ui.onroad.soft_warning import SoftWarningRenderer
ENABLE_FLOATING_NAV_MAP_PANEL = False
ENABLE_SPLIT_NAV_MAP_PANEL = True
class IQHudRenderer(HudRenderer):
def __init__(self):
super().__init__()
self.developer_ui = DeveloperUiRenderer()
self.nav_map_panel = NavMapPanel()
self.road_name_renderer = RoadNameRenderer()
self.rocket_fuel = RocketFuel()
self.speed_limit_renderer = SpeedLimitRenderer()
self.turn_signal_controller = TurnSignalController()
self.speed_renderer = SpeedRenderer()
self.soft_warning_renderer = SoftWarningRenderer()
self._torque_bar = TorqueBar(scale=3.0, always=True)
def _update_state(self) -> None:
super()._update_state()
if ENABLE_FLOATING_NAV_MAP_PANEL or ENABLE_SPLIT_NAV_MAP_PANEL:
self.nav_map_panel.update()
self.road_name_renderer.update()
self.speed_limit_renderer.update()
has_limit = self.speed_limit_renderer.speed_limit_valid or self.speed_limit_renderer.speed_limit_last_valid
self.limit_available = has_limit
self.limit_speed_text = str(round(self.speed_limit_renderer.speed_limit_last)) if has_limit else "---"
self.turn_signal_controller.update()
self.speed_renderer.update()
self.soft_warning_renderer.update()
def _draw_current_speed(self, rect: rl.Rectangle) -> None:
self.speed_renderer.render(rect)
def _render(self, rect: rl.Rectangle) -> None:
super()._render(rect)
if ui_state.torque_bar:
torque_rect = rect
if ui_state.developer_ui in (DeveloperUiRenderer.DEV_UI_BOTTOM, DeveloperUiRenderer.DEV_UI_BOTH):
torque_rect = rl.Rectangle(rect.x, rect.y, rect.width, rect.height - DeveloperUiRenderer.BOTTOM_BAR_HEIGHT)
self._torque_bar.render(torque_rect)
if not self.split_nav_enabled():
self.developer_ui.render(rect)
if ENABLE_FLOATING_NAV_MAP_PANEL:
self.nav_map_panel.render(rect)
self.road_name_renderer.render(rect)
self.turn_signal_controller.render(rect)
self.soft_warning_renderer.render(rect)
self.rocket_fuel.render(rect, ui_state.sm)
def split_nav_enabled(self) -> bool:
if not ENABLE_SPLIT_NAV_MAP_PANEL:
return False
if hasattr(self.nav_map_panel, "maps_enabled"):
return bool(self.nav_map_panel.maps_enabled())
return bool(getattr(self.nav_map_panel, "_maps_enabled", False))
def render_split_nav(self, rect: rl.Rectangle) -> None:
if self.split_nav_enabled():
self.nav_map_panel.render_split(rect)
def render_full_width_overlays(self, rect: rl.Rectangle) -> None:
if self.split_nav_enabled():
self.developer_ui.render(rect)

View File

@@ -0,0 +1,70 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.system.ui.lib.application import gui_app
ACTIVE_TOP = rl.Color(0x22, 0xB8, 0xB9, 255)
ACTIVE_BOTTOM = rl.Color(0x0C, 0x94, 0x96, 255)
MEDIUM_TOP = rl.Color(255, 200, 0, 255)
MEDIUM_BOTTOM = rl.Color(255, 115, 0, 255)
LOW_TOP = rl.Color(255, 0, 21, 255)
LOW_BOTTOM = rl.Color(255, 0, 89, 255)
OVERRIDE_TOP = rl.Color(255, 255, 255, 255)
OVERRIDE_BOTTOM = rl.Color(82, 82, 82, 255)
IDLE_TOP = rl.Color(120, 120, 120, 255)
IDLE_BOTTOM = rl.Color(60, 60, 60, 255)
def _zone_colors(confidence: float) -> tuple[rl.Color, rl.Color]:
if confidence > 0.5:
return ACTIVE_TOP, ACTIVE_BOTTOM
if confidence > 0.2:
return MEDIUM_TOP, MEDIUM_BOTTOM
return LOW_TOP, LOW_BOTTOM
class DrivingConfidence:
def __init__(self):
self._filter = FirstOrderFilter(-0.5, 0.5, 1 / gui_app.target_fps)
self._last_frame = -1
def update(self) -> None:
frame = ui_state.sm.frame
if frame == self._last_frame:
return
self._last_frame = frame
try:
predictions = ui_state.sm['modelV2'].meta.disengagePredictions
except Exception:
return
if ui_state.status == UIStatus.DISENGAGED:
value = -0.5
elif ui_state.status == UIStatus.LAT_ONLY:
value = 1 - max(predictions.steerOverrideProbs or [1])
elif ui_state.status == UIStatus.LONG_ONLY:
value = 1 - max(predictions.brakeDisengageProbs or [1])
else:
value = (1 - max(predictions.brakeDisengageProbs or [1])) * (1 - max(predictions.steerOverrideProbs or [1]))
self._filter.update(value)
@property
def value(self) -> float:
return self._filter.x
def colors(self, demo: bool = False) -> tuple[rl.Color, rl.Color]:
confidence = self._filter.x
if ui_state.status == UIStatus.ENGAGED or demo:
return _zone_colors(confidence)
if ui_state.status in (UIStatus.LAT_ONLY, UIStatus.LONG_ONLY):
return _zone_colors(confidence)
if ui_state.status == UIStatus.OVERRIDE:
return OVERRIDE_TOP, OVERRIDE_BOTTOM
return IDLE_TOP, IDLE_BOTTOM
driving_confidence = DrivingConfidence()

View File

@@ -0,0 +1,10 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
from openpilot.iqpilot.ui.onroad.hud_overlays import ChevronMetrics
from openpilot.iqpilot.ui.onroad.rainbow_path import RainbowPath
class IQModelRenderer:
def __init__(self):
self.rainbow_path = RainbowPath()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
import math
from urllib.parse import quote
EARTH_RADIUS_M = 6378137.0
TILE_SIZE = 256.0
# Shift the whole driving zoom window closer in. The route-ahead fit (fit_zoom_for_points)
# still zooms out for distant turns and in for straight roads; this just biases the baseline
# so the route line + ego marker are easier to read while driving.
NAV_DRIVE_ZOOM_BOOST = 1.0
def _mercator_normalized(latitude: float, longitude: float) -> tuple[float, float]:
x = (longitude + 180.0) / 360.0
siny = min(max(math.sin(math.radians(latitude)), -0.9999), 0.9999)
y = 0.5 - math.log((1.0 + siny) / (1.0 - siny)) / (4.0 * math.pi)
return x, y
def mercator_world_px(latitude: float, longitude: float, zoom: float) -> tuple[float, float]:
world_size = TILE_SIZE * (2.0 ** zoom)
nx, ny = _mercator_normalized(latitude, longitude)
x = nx * world_size
y = ny * world_size
return x, y
def destination_point(latitude: float, longitude: float, bearing_deg: float, distance_m: float) -> tuple[float, float]:
if abs(distance_m) < 1e-3:
return latitude, longitude
angular_distance = distance_m / EARTH_RADIUS_M
bearing = math.radians(bearing_deg)
lat1 = math.radians(latitude)
lon1 = math.radians(longitude)
sin_lat1 = math.sin(lat1)
cos_lat1 = math.cos(lat1)
sin_ad = math.sin(angular_distance)
cos_ad = math.cos(angular_distance)
lat2 = math.asin(sin_lat1 * cos_ad + cos_lat1 * sin_ad * math.cos(bearing))
lon2 = lon1 + math.atan2(
math.sin(bearing) * sin_ad * cos_lat1,
cos_ad - sin_lat1 * math.sin(lat2),
)
return math.degrees(lat2), math.degrees(lon2)
def fit_zoom_for_points(points, width: float, height: float, max_zoom: float = 17.6,
min_zoom: float = 12.8, padding: float = 56.0) -> float:
coords = [(float(point.latitude), float(point.longitude)) for point in points if point is not None]
if len(coords) < 2:
return max_zoom
xs, ys = zip(*[_mercator_normalized(lat, lon) for lat, lon in coords])
span_x = max(max(xs) - min(xs), 1e-6)
span_y = max(max(ys) - min(ys), 1e-6)
usable_width = max(width - 2.0 * padding, 32.0)
usable_height = max(height - 2.0 * padding, 32.0)
zoom_x = math.log2(usable_width / (TILE_SIZE * span_x))
zoom_y = math.log2(usable_height / (TILE_SIZE * span_y))
return max(min(min(zoom_x, zoom_y), max_zoom), min_zoom)
def choose_nav_camera(current_latitude: float, current_longitude: float, bearing_deg: float, points,
width: float, height: float, preferred_zoom: float) -> tuple[float, float, float]:
preferred_zoom += NAV_DRIVE_ZOOM_BOOST
zoom = preferred_zoom
if points:
zoom = fit_zoom_for_points(points, width, height * 0.78, max_zoom=preferred_zoom + 0.6)
zoom = min(max(zoom, preferred_zoom - 1.2), preferred_zoom + 0.6)
meters_per_pixel = 156543.03392 * math.cos(math.radians(current_latitude)) / (2.0 ** zoom)
lookahead_pixels = height * 0.16
lookahead_m = max(lookahead_pixels * meters_per_pixel, 12.0)
center_latitude, center_longitude = destination_point(current_latitude, current_longitude, bearing_deg, lookahead_m)
return center_latitude, center_longitude, zoom
def build_mapbox_static_url(latitude: float, longitude: float, zoom: float, bearing: float,
width: int, height: int, points=None) -> str:
overlay = ""
if points:
overlay = f"path-7+34d17a-0.85({encode_polyline(points)})/"
return (
f"https://api.mapbox.com/styles/v1/mapbox/navigation-night-v1/static/"
f"{overlay}{longitude:.6f},{latitude:.6f},{zoom:.2f},{bearing:.1f},0/{width}x{height}@2x"
)
def build_mapbox_tile_url(z: int, x: int, y: int, tile_size: int = 256, scale: int = 2,
style: str = "navigation-night-v1") -> str:
suffix = f"@{scale}x" if scale > 1 else ""
return (
f"https://api.mapbox.com/styles/v1/mapbox/{style}/tiles/"
f"{tile_size}/{z}/{x}/{y}{suffix}"
)
def tile_world_size(z: int, tile_size: int = 256) -> int:
return tile_size * (2 ** z)
def mercator_world_px_at_zoom(latitude: float, longitude: float, z: int, tile_size: int = 256) -> tuple[float, float]:
world_size = tile_world_size(z, tile_size)
nx, ny = _mercator_normalized(latitude, longitude)
return nx * world_size, ny * world_size
def encode_polyline(points) -> str:
result = []
last_lat = 0
last_lon = 0
for point in points:
lat = int(round(float(point.latitude if hasattr(point, "latitude") else point[0]) * 1e5))
lon = int(round(float(point.longitude if hasattr(point, "longitude") else point[1]) * 1e5))
for value in (lat - last_lat, lon - last_lon):
shifted = ~(value << 1) if value < 0 else (value << 1)
while shifted >= 0x20:
result.append(chr((0x20 | (shifted & 0x1f)) + 63))
shifted >>= 5
result.append(chr(shifted + 63))
last_lat = lat
last_lon = lon
return quote("".join(result), safe="")
def project_nav_point(latitude: float, longitude: float, center_latitude: float, center_longitude: float,
zoom: float, bearing_deg: float, width: float, height: float,
anchor_x: float = 0.5, anchor_y: float = 0.5) -> tuple[float, float]:
px, py = mercator_world_px(latitude, longitude, zoom)
cx, cy = mercator_world_px(center_latitude, center_longitude, zoom)
dx = px - cx
dy = py - cy
theta = math.radians(bearing_deg)
cos_theta = math.cos(theta)
sin_theta = math.sin(theta)
rx = dx * cos_theta + dy * sin_theta
ry = -dx * sin_theta + dy * cos_theta
return width * anchor_x + rx, height * anchor_y + ry
def project_nav_polyline(points, center_latitude: float, center_longitude: float, zoom: float, bearing_deg: float,
width: float, height: float, anchor_x: float = 0.5, anchor_y: float = 0.5) -> list[tuple[float, float]]:
projected = []
for point in points:
projected.append(
project_nav_point(
float(point.latitude),
float(point.longitude),
center_latitude,
center_longitude,
zoom,
bearing_deg,
width,
height,
anchor_x=anchor_x,
anchor_y=anchor_y,
)
)
return projected
def solar_elevation_deg(latitude: float, longitude: float, unix_time: float) -> float:
"""Approximate solar elevation (NOAA-style, good to ~0.5 deg) for day/night map styling."""
days = unix_time / 86400.0 - 10957.5 # days since J2000 epoch
mean_longitude = math.radians((280.460 + 0.9856474 * days) % 360.0)
mean_anomaly = math.radians((357.528 + 0.9856003 * days) % 360.0)
ecliptic_longitude = mean_longitude + math.radians(1.915) * math.sin(mean_anomaly) \
+ math.radians(0.020) * math.sin(2.0 * mean_anomaly)
obliquity = math.radians(23.439 - 0.0000004 * days)
declination = math.asin(math.sin(obliquity) * math.sin(ecliptic_longitude))
right_ascension = math.atan2(math.cos(obliquity) * math.sin(ecliptic_longitude), math.cos(ecliptic_longitude))
gmst_deg = (280.46061837 + 360.98564736629 * days) % 360.0
hour_angle = math.radians(gmst_deg) + math.radians(longitude) - right_ascension
lat_rad = math.radians(latitude)
elevation = math.asin(
math.sin(lat_rad) * math.sin(declination)
+ math.cos(lat_rad) * math.cos(declination) * math.cos(hour_angle)
)
return math.degrees(elevation)

View File

@@ -0,0 +1,288 @@
import os
import json
import time
from typing import Any
from pathlib import Path
try:
import sqlite3
except Exception:
sqlite3 = None # type: ignore[assignment]
OFFLINE_MBTILES_ENV = "IQPILOT_OFFLINE_MBTILES"
OFFLINE_TILE_ROOT_ENV = "IQPILOT_OFFLINE_TILE_ROOT"
DEFAULT_OFFLINE_TILE_ROOT = Path("/data/offline_maps/tiles" if Path("/data").exists() else "/tmp/offline_maps/tiles")
DEFAULT_OFFLINE_MAP_ROOT = Path("/data/offline_maps" if Path("/data").exists() else "/tmp/offline_maps")
SQLITE_ERRORS = (sqlite3.Error,) if sqlite3 is not None else (Exception,)
SQLiteConnection = Any
def offline_tile_root() -> Path:
override = os.getenv(OFFLINE_TILE_ROOT_ENV)
return Path(override) if override else DEFAULT_OFFLINE_TILE_ROOT
def offline_map_root() -> Path:
root = offline_tile_root()
if root.name == "tiles":
return root.parent
if root.name == "xyz":
return root.parent.parent if root.parent.name == "tiles" else root.parent
return DEFAULT_OFFLINE_MAP_ROOT
def _parse_bounds(bounds: str) -> tuple[float, float, float, float] | None:
try:
min_lon, min_lat, max_lon, max_lat = [float(part) for part in bounds.split(",")]
except (ValueError, AttributeError):
return None
return min_lat, min_lon, max_lat, max_lon
def _bounds_contains(bounds: tuple[float, float, float, float], latitude: float, longitude: float) -> bool:
min_lat, min_lon, max_lat, max_lon = bounds
return min_lat <= latitude <= max_lat and min_lon <= longitude <= max_lon
def _bounds_area(bounds: tuple[float, float, float, float]) -> float:
min_lat, min_lon, max_lat, max_lon = bounds
return max(max_lat - min_lat, 0.0) * max(max_lon - min_lon, 0.0)
# Manual cache that only stores hits: caching a None (manifest not written yet — e.g. a
# bundle download in flight) would otherwise pin the miss for the life of the process.
_region_bounds_cache: dict[Path, tuple[float, float, float, float]] = {}
def _load_region_bounds(region_root: Path) -> tuple[float, float, float, float] | None:
cached = _region_bounds_cache.get(region_root)
if cached is not None:
return cached
bounds = _load_region_bounds_uncached(region_root)
if bounds is not None:
if len(_region_bounds_cache) > 64:
_region_bounds_cache.clear()
_region_bounds_cache[region_root] = bounds
return bounds
def _load_region_bounds_uncached(region_root: Path) -> tuple[float, float, float, float] | None:
manifest_path = region_root / "manifest.json"
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text())
bounds = manifest.get("mbtiles", {}).get("bounds")
parsed = _parse_bounds(bounds) if bounds else None
if parsed is not None:
return parsed
except (OSError, json.JSONDecodeError):
pass
mbtiles_path = region_root / "tiles" / "offline.mbtiles"
if not mbtiles_path.exists():
return None
try:
conn = open_mbtiles(mbtiles_path)
row = conn.execute("SELECT value FROM metadata WHERE name = 'bounds'").fetchone()
conn.close()
except SQLITE_ERRORS:
return None
return _parse_bounds(row["value"]) if row is not None else None
# Short-TTL cache instead of lru_cache: region bundles can be downloaded while the UI is
# running, and a forever-cached candidate list would hide them until the process restarts.
_REGION_ROOTS_TTL_S = 15.0
_region_roots_cache: tuple[float, Path, tuple[Path, ...]] | None = None
def _candidate_region_roots() -> tuple[Path, ...]:
global _region_roots_cache
root = offline_map_root()
now = time.monotonic()
if _region_roots_cache is not None:
cached_at, cached_root, cached = _region_roots_cache
if cached_root == root and now - cached_at < _REGION_ROOTS_TTL_S:
return cached
candidates: list[Path] = []
if (root / "tiles").exists():
candidates.append(root)
regions_root = root / "regions"
if regions_root.exists():
for child in sorted(regions_root.iterdir()):
if child.is_dir() and (child / "tiles").exists():
candidates.append(child)
result = tuple(candidates)
_region_roots_cache = (now, root, result)
return result
def _region_covers_point(region_root: Path, latitude: float, longitude: float) -> bool:
mb = region_root / "tiles" / "offline.mbtiles"
if not mb.exists():
return True # xyz-only / unknown layout: don't second-guess the bbox match
try:
conn = open_mbtiles(mb)
try:
_, max_zoom = mbtiles_zoom_bounds(conn)
z = max_zoom if max_zoom is not None else 14
import math
n = 2 ** z
lat_r = math.radians(max(min(latitude, 85.05112878), -85.05112878))
x = int((longitude + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n)
# 3x3 cluster: tolerate an empty sub-tile at the exact point (a z15 child with no road)
# while still rejecting a neighbor whose coverage doesn't reach this area at all.
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if load_raster_tile_blob(conn, z, x + dx, y + dy) is not None:
return True
return False
finally:
conn.close()
except SQLITE_ERRORS:
return True
def find_offline_region_root(latitude: float | None = None, longitude: float | None = None) -> Path | None:
candidates = _candidate_region_roots()
if not candidates:
return None
if latitude is None or longitude is None:
return candidates[0]
bounded: list[tuple[float, Path]] = []
for candidate in candidates:
bounds = _load_region_bounds(candidate)
if bounds is None:
continue
if _bounds_contains(bounds, latitude, longitude):
bounded.append((_bounds_area(bounds), candidate))
if bounded:
if len(bounded) == 1:
return bounded[0][1]
# multiple bboxes overlap this point (border zone): prefer the smallest-area region that
# ACTUALLY has tiles here, so we don't pick a neighbor whose bundle is empty at the border.
bounded.sort(key=lambda item: item[0])
for _, candidate in bounded:
if _region_covers_point(candidate, latitude, longitude):
return candidate
return bounded[0][1]
return candidates[0]
def find_offline_mbtiles_path(latitude: float | None = None, longitude: float | None = None,
day: bool = False) -> Path | None:
explicit = os.getenv(OFFLINE_MBTILES_ENV)
if explicit:
path = Path(explicit)
if day:
day_path = path.with_name("offline_day.mbtiles")
if day_path.exists():
return day_path
return path if path.exists() else None
region_root = find_offline_region_root(latitude, longitude)
if region_root is None:
return None
# day variant is optional: regions built before the day palette fall back to the night set
if day:
day_preferred = region_root / "tiles" / "offline_day.mbtiles"
if day_preferred.exists():
return day_preferred
preferred = region_root / "tiles" / "offline.mbtiles"
if preferred.exists():
return preferred
matches = sorted(p for p in (region_root / "tiles").glob("*.mbtiles") if "_day" not in p.name or day)
return matches[0] if matches else None
def find_offline_xyz_root(latitude: float | None = None, longitude: float | None = None) -> Path | None:
root = offline_tile_root()
if not root.exists():
region_root = find_offline_region_root(latitude, longitude)
if region_root is None:
return None
root = region_root / "tiles"
if any(child.is_dir() and child.name.isdigit() for child in root.iterdir()):
return root
xyz_dir = root / "xyz"
if xyz_dir.exists() and any(child.is_dir() and child.name.isdigit() for child in xyz_dir.iterdir()):
return xyz_dir
return None
def xyz_to_tms_y(z: int, y: int) -> int:
return (2 ** z - 1) - y
def open_mbtiles(path: Path) -> SQLiteConnection:
if sqlite3 is None:
raise RuntimeError("sqlite3 unavailable")
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def mbtiles_is_raster(conn: SQLiteConnection) -> bool:
row = conn.execute("SELECT value FROM metadata WHERE name = 'format'").fetchone()
if row is None:
return False
return row["value"] in {"png", "jpg", "jpeg", "webp"}
def mbtiles_zoom_bounds(conn: SQLiteConnection) -> tuple[int | None, int | None]:
rows = {
row["name"]: row["value"]
for row in conn.execute("SELECT name, value FROM metadata WHERE name IN ('minzoom', 'maxzoom')")
}
min_zoom = int(rows["minzoom"]) if "minzoom" in rows else None
max_zoom = int(rows["maxzoom"]) if "maxzoom" in rows else None
return min_zoom, max_zoom
def xyz_zoom_bounds(root: Path) -> tuple[int | None, int | None]:
zoom_dirs = sorted(
int(child.name)
for child in root.iterdir()
if child.is_dir() and child.name.isdigit()
)
if not zoom_dirs:
return None, None
return zoom_dirs[0], zoom_dirs[-1]
def load_raster_tile_blob(conn: SQLiteConnection, z: int, x: int, y: int) -> bytes | None:
row = conn.execute(
"""
SELECT tile_data
FROM tiles
WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?
""",
(z, x, xyz_to_tms_y(z, y)),
).fetchone()
return bytes(row["tile_data"]) if row is not None else None
def load_raster_xyz_tile_blob(root: Path, z: int, x: int, y: int) -> bytes | None:
for suffix in ("png", "webp", "jpg", "jpeg"):
for filename in (f"{y}.{suffix}", f"{y}@2x.{suffix}"):
tile_path = root / str(z) / str(x) / filename
if tile_path.exists():
return tile_path.read_bytes()
return None

View File

@@ -0,0 +1,41 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import colorsys
import time
import pyray as rl
from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
# Scrolling spectrum along the driving path: a fixed set of stops from the
# bottom (1.0) to the top (0.0) of the path, each a full-saturation swatch whose
# hue advances with time and whose opacity thins toward the horizon.
_SEGMENTS = 8
_SCROLL_DEG_PER_S = 50.0
_SATURATION = 0.9
_LIGHTNESS = 0.6
_ALPHA_NEAR = 0.8 # opacity at the bottom of the path
_ALPHA_HORIZON_FRACTION = 0.3 # how much of that opacity is shed by the top
# Stop offsets are constant, so resolve them once.
_STOP_OFFSETS = tuple(i / (_SEGMENTS - 1) for i in range(_SEGMENTS))
def _swatch(hue_turns: float, alpha: float) -> rl.Color:
r, g, b = colorsys.hls_to_rgb(hue_turns, _LIGHTNESS, _SATURATION)
return rl.Color(int(r * 255), int(g * 255), int(b * 255), int(alpha * 255))
def _spectrum_gradient() -> Gradient:
scroll_deg = (time.monotonic() * _SCROLL_DEG_PER_S) % 360.0
colors = []
for offset in _STOP_OFFSETS:
hue_deg = (scroll_deg + offset * 360.0) % 360.0
alpha = _ALPHA_NEAR * (1.0 - offset * _ALPHA_HORIZON_FRACTION)
colors.append(_swatch(hue_deg / 360.0, alpha))
return Gradient(start=(0.0, 1.0), end=(0.0, 0.0), colors=colors, stops=list(_STOP_OFFSETS))
class RainbowPath:
def draw_rainbow_path(self, rect, path):
draw_polygon(rect, path.projected_points, gradient=_spectrum_gradient())

View File

@@ -0,0 +1,60 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
from cereal import log
from openpilot.selfdrive.ui import UI_BORDER_SIZE
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.onroad.driver_state import BTN_SIZE
from openpilot.system.ui.lib.application import gui_app
EventName = log.OnroadEvent.EventName
# Events that trigger the soft warning triangle instead of a disruptive alert
SOFT_WARNING_EVENTS = {
EventName.commIssue,
EventName.commIssueAvgFreq,
EventName.selfdrivedLagging,
}
ICON_SIZE = 96
# Speed box geometry (mirrors hud_renderer._draw_set_speed)
_SPEED_BOX_X_OFFSET = 60
_SPEED_BOX_Y_OFFSET = 45
_SPEED_BOX_WIDTH = 180 # midpoint between metric (186) and imperial (174)
_SPEED_BOX_HEIGHT = 228
_DM_OFFSET = UI_BORDER_SIZE + BTN_SIZE // 2 # = 126
class SoftWarningRenderer:
def __init__(self):
self._icon = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", ICON_SIZE, ICON_SIZE)
self._active = False
def update(self) -> None:
sm = ui_state.sm
self._active = any(e.name in SOFT_WARNING_EVENTS for e in sm['onroadEvents'])
def render(self, rect: rl.Rectangle) -> None:
if not self._active:
return
# Centre of speed box (top-left of screen)
speed_cx = rect.x + _SPEED_BOX_X_OFFSET + _SPEED_BOX_WIDTH / 2
speed_cy = rect.y + _SPEED_BOX_Y_OFFSET + _SPEED_BOX_HEIGHT / 2
# Centre of driver monitoring icon (bottom-left of screen, LHD)
dm_cx = rect.x + _DM_OFFSET
dm_cy = rect.y + rect.height - _DM_OFFSET
# Midpoint between the two
mid_x = (speed_cx + dm_cx) / 2
mid_y = (speed_cy + dm_cy) / 2
draw_x = int(mid_x - ICON_SIZE / 2)
draw_y = int(mid_y - ICON_SIZE / 2)
rl.draw_texture(self._icon, draw_x, draw_y, rl.WHITE)

161
iqpilot/ui/theme.py Normal file
View File

@@ -0,0 +1,161 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
IQ.Pilot UI Theme — NeonTheme singleton
========================================
Stores the active accent/neon color as a hex string in Params["UIAccentColor"].
This allows:
- hephaestusd to write the color via BLE/RPC from the Konn3kt companion app
- Konn3kt-set themes to be reflected on-device in real time
- Future per-driver theme profiles via Konn3kt
Default accent: #00FFF5 (cyan neon — the IQ.Pilot signature color)
Any UI component that wants the accent color calls:
NeonTheme.glow() -> rl.Color (full brightness, inner ring)
NeonTheme.glow_mid() -> rl.Color (60% alpha, mid ring)
NeonTheme.glow_outer() -> rl.Color (25% alpha, soft halo)
NeonTheme.bg() -> rl.Color (darkened tint of accent for card bg)
Konn3kt / hephaestusd write interface:
Params().put("UIAccentColor", "#00FFF5") # any CSS hex string
The theme refreshes from Params every REFRESH_INTERVAL seconds so changes
propagate without a restart.
"""
import time
import pyray as rl
try:
from openpilot.common.params import Params
except ImportError:
Params = None
# How often (seconds) to re-read the accent color from Params
REFRESH_INTERVAL = 2.0
# IQ.Pilot default signature neon cyan
DEFAULT_ACCENT_HEX = "#00FFF5"
def _hex_to_rgb(hex_str: str) -> tuple[int, int, int]:
"""Parse a CSS hex color string like '#00FFF5' or '00FFF5' to (r, g, b)."""
h = hex_str.strip().lstrip("#")
if len(h) == 3:
h = h[0] * 2 + h[1] * 2 + h[2] * 2
if len(h) != 6:
return (0x00, 0xFF, 0xF5) # fallback to default
try:
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
except ValueError:
return (0x00, 0xFF, 0xF5)
def _darken(r: int, g: int, b: int, factor: float = 0.08) -> tuple[int, int, int]:
"""Mix the accent toward black to create a card bg tint."""
return (int(r * factor), int(g * factor), int(b * factor))
class _NeonTheme:
"""
Singleton that holds and lazily refreshes the active neon accent color.
All color properties are rl.Color objects ready for use in Raylib draw calls.
"""
def __init__(self):
self._params = Params() if Params else None
self._last_refresh: float = 0.0
self._hex: str = DEFAULT_ACCENT_HEX
self._r, self._g, self._b = _hex_to_rgb(DEFAULT_ACCENT_HEX)
self._refresh()
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _refresh(self):
self._last_refresh = time.monotonic()
if self._params is None:
return
try:
stored = self._params.get("UIAccentColor")
if stored and isinstance(stored, str) and stored.strip():
self._hex = stored.strip()
self._r, self._g, self._b = _hex_to_rgb(self._hex)
except Exception:
pass # keep previous value on any error
def _maybe_refresh(self):
if time.monotonic() - self._last_refresh >= REFRESH_INTERVAL:
self._refresh()
# ------------------------------------------------------------------
# Public color accessors
# ------------------------------------------------------------------
def glow(self, alpha: int = 255) -> rl.Color:
"""Full-brightness inner glow / border color."""
self._maybe_refresh()
return rl.Color(self._r, self._g, self._b, alpha)
def glow_mid(self, alpha: int = 130) -> rl.Color:
"""Mid-brightness second ring."""
self._maybe_refresh()
return rl.Color(self._r, self._g, self._b, alpha)
def glow_outer(self, alpha: int = 45) -> rl.Color:
"""Soft outer halo."""
self._maybe_refresh()
return rl.Color(self._r, self._g, self._b, alpha)
def bg(self) -> rl.Color:
"""Dark card background tinted with the accent color."""
self._maybe_refresh()
dr, dg, db = _darken(self._r, self._g, self._b, 0.08)
# minimum darkness so the card is always readable
dr = max(dr, 0x0A)
dg = max(dg, 0x0A)
db = max(db, 0x0A)
return rl.Color(dr, dg, db, 255)
def bg_pressed(self) -> rl.Color:
"""Slightly lighter card background for press state."""
self._maybe_refresh()
dr, dg, db = _darken(self._r, self._g, self._b, 0.13)
dr = max(dr, 0x0D)
dg = max(dg, 0x0D)
db = max(db, 0x0D)
return rl.Color(dr, dg, db, 255)
@property
def hex(self) -> str:
"""Current accent color as hex string, e.g. '#00FFF5'."""
self._maybe_refresh()
return self._hex
# ------------------------------------------------------------------
# Write interface (called by hephaestusd / Konn3kt theme handler)
# ------------------------------------------------------------------
def set_accent(self, hex_color: str):
"""
Set a new accent color immediately and persist it to Params.
hephaestusd / Konn3kt companion app should call this.
Args:
hex_color: CSS hex string, e.g. '#FF6B00' or '8B5CF6'
"""
r, g, b = _hex_to_rgb(hex_color)
self._r, self._g, self._b = r, g, b
self._hex = "#" + hex_color.strip().lstrip("#").upper()
self._last_refresh = time.monotonic()
if self._params:
try:
self._params.put_nonblocking("UIAccentColor", self._hex)
except Exception:
pass
# Global singleton — import this everywhere
NeonTheme = _NeonTheme()