IQ.Pilot Release Commit @ 0798119
This commit is contained in:
3
iqpilot/ui/__init__.py
Normal file
3
iqpilot/ui/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
3
iqpilot/ui/layouts/__init__.py
Normal file
3
iqpilot/ui/layouts/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
0
iqpilot/ui/layouts/settings/__init__.py
Normal file
0
iqpilot/ui/layouts/settings/__init__.py
Normal file
158
iqpilot/ui/layouts/settings/drive_history.py
Normal file
158
iqpilot/ui/layouts/settings/drive_history.py
Normal 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
|
||||
202
iqpilot/ui/layouts/settings/iq_dynamic.py
Normal file
202
iqpilot/ui/layouts/settings/iq_dynamic.py
Normal 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.iqwidgets.widgets.list_view import IQListItem, IQToggleAction, SafeIQToggleAction
|
||||
from openpilot.system.ui.iqwidgets.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 iqdbc.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()
|
||||
2703
iqpilot/ui/layouts/settings/iq_panels.py
Normal file
2703
iqpilot/ui/layouts/settings/iq_panels.py
Normal file
File diff suppressed because it is too large
Load Diff
3
iqpilot/ui/mici/__init__.py
Normal file
3
iqpilot/ui/mici/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
3
iqpilot/ui/mici/onroad/__init__.py
Normal file
3
iqpilot/ui/mici/onroad/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
31
iqpilot/ui/mici/onroad/confidence_ball.py
Normal file
31
iqpilot/ui/mici/onroad/confidence_ball.py
Normal 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
|
||||
32
iqpilot/ui/mici/onroad/hud_renderer.py
Normal file
32
iqpilot/ui/mici/onroad/hud_renderer.py
Normal 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 IQBlindSpotOverlay
|
||||
|
||||
|
||||
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 = [IQBlindSpotOverlay()]
|
||||
|
||||
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)
|
||||
10
iqpilot/ui/mici/onroad/model_renderer.py
Normal file
10
iqpilot/ui/mici/onroad/model_renderer.py
Normal 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),
|
||||
}
|
||||
20
iqpilot/ui/mici/onroad/road_label.py
Normal file
20
iqpilot/ui/mici/onroad/road_label.py
Normal 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
|
||||
0
iqpilot/ui/onroad/__init__.py
Normal file
0
iqpilot/ui/onroad/__init__.py
Normal file
18
iqpilot/ui/onroad/augmented_road_view.py
Normal file
18
iqpilot/ui/onroad/augmented_road_view.py
Normal 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
|
||||
162
iqpilot/ui/onroad/driver_state.py
Normal file
162
iqpilot/ui/onroad/driver_state.py
Normal 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 IQDevMetricsOverlay
|
||||
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 = IQDevMetricsOverlay.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
|
||||
)
|
||||
1024
iqpilot/ui/onroad/hud_overlays.py
Normal file
1024
iqpilot/ui/onroad/hud_overlays.py
Normal file
File diff suppressed because it is too large
Load Diff
90
iqpilot/ui/onroad/hud_renderer.py
Normal file
90
iqpilot/ui/onroad/hud_renderer.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
IQ.Pilot road-view HUD: extends the stock renderer and layers on the IQ overlays
|
||||
(developer bar, nav map, road name, speed + speed-limit, turn signals, rocket-fuel
|
||||
accel bar, soft warnings, steering arc).
|
||||
"""
|
||||
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 (
|
||||
IQDevMetricsOverlay,
|
||||
RoadNameRenderer,
|
||||
IQAccelBar,
|
||||
IQSpeedLimitOverlay,
|
||||
IQTurnSignalOverlay,
|
||||
IQSpeedOverlay,
|
||||
)
|
||||
from openpilot.iqpilot.ui.onroad.nav_map_panel import NavMapPanel
|
||||
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 = IQDevMetricsOverlay()
|
||||
self.nav_map_panel = NavMapPanel()
|
||||
self.road_name_renderer = RoadNameRenderer()
|
||||
self.rocket_fuel = IQAccelBar()
|
||||
self.speed_limit_renderer = IQSpeedLimitOverlay()
|
||||
self.turn_signal_controller = IQTurnSignalOverlay()
|
||||
self.speed_renderer = IQSpeedOverlay()
|
||||
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 "---"
|
||||
offset = round(self.speed_limit_renderer.speed_limit_offset)
|
||||
self.limit_offset_text = f"{offset:+d}" if has_limit and offset != 0 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 (IQDevMetricsOverlay.DEV_UI_BOTTOM, IQDevMetricsOverlay.DEV_UI_BOTH):
|
||||
torque_rect = rl.Rectangle(rect.x, rect.y, rect.width, rect.height - IQDevMetricsOverlay.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)
|
||||
70
iqpilot/ui/onroad/lead_confidence.py
Normal file
70
iqpilot/ui/onroad/lead_confidence.py
Normal 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()
|
||||
1800
iqpilot/ui/onroad/nav_map_panel.py
Normal file
1800
iqpilot/ui/onroad/nav_map_panel.py
Normal file
File diff suppressed because it is too large
Load Diff
189
iqpilot/ui/onroad/nav_map_utils.py
Normal file
189
iqpilot/ui/onroad/nav_map_utils.py
Normal 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)
|
||||
288
iqpilot/ui/onroad/offline_tiles.py
Normal file
288
iqpilot/ui/onroad/offline_tiles.py
Normal 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
|
||||
60
iqpilot/ui/onroad/soft_warning.py
Normal file
60
iqpilot/ui/onroad/soft_warning.py
Normal 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
161
iqpilot/ui/theme.py
Normal 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()
|
||||
Reference in New Issue
Block a user