Files
IQ.Pilot/selfdrive/ui/ui_state.py
T
2026-07-19 18:18:20 -05:00

561 lines
19 KiB
Python

import pyray as rl
import numpy as np
import time
import threading
from collections.abc import Callable
from enum import Enum, IntEnum
from cereal import messaging, car, log, custom
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.lib.prime_state import PrimeState
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE, PC
BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50
OpenpilotState = log.SelfdriveState.OpenpilotState
GuidanceState = custom.AlwaysOnLateral.AlwaysOnLateralState
ONROAD_BRIGHTNESS_TIMER_PAUSED = -1
class OnroadTimerStatus(Enum):
NONE = 0
PAUSE = 1
RESUME = 2
class OnroadBrightness(IntEnum):
AUTO = 0
AUTO_DARK = 1
class IQUIState:
def __init__(self):
self.params = Params()
self.sm_services_ext = [
"iqModelManager", "iqState", "iqPlan", "iqNavState",
"gpsLocation", "liveTorqueParameters",
"iqLiveData", "iqNavRenderState", "liveDelay"
]
self.update_params()
self.onroad_brightness_timer: int = 0
self.custom_interactive_timeout: int = self.params.get("InteractivityTimeout", return_default=True)
self.reset_onroad_sleep_timer()
def update(self) -> None:
pass
def onroad_brightness_handle_alerts(self, started: bool, alert):
has_alert = started and self.onroad_brightness != OnroadBrightness.AUTO and alert is not None
self.update_onroad_brightness(has_alert)
if has_alert:
self.reset_onroad_sleep_timer()
def update_onroad_brightness(self, has_alert: bool) -> None:
if has_alert:
return
if self.onroad_brightness_timer > 0:
self.onroad_brightness_timer -= 1
def reset_onroad_sleep_timer(self, timer_status: OnroadTimerStatus = OnroadTimerStatus.NONE) -> None:
if timer_status == OnroadTimerStatus.PAUSE and self.onroad_brightness_timer != ONROAD_BRIGHTNESS_TIMER_PAUSED:
self.onroad_brightness_timer = ONROAD_BRIGHTNESS_TIMER_PAUSED
elif (self.onroad_brightness_timer_param >= 0 and self.onroad_brightness != OnroadBrightness.AUTO and
self.onroad_brightness_timer != ONROAD_BRIGHTNESS_TIMER_PAUSED) or timer_status == OnroadTimerStatus.RESUME:
if self.onroad_brightness == OnroadBrightness.AUTO_DARK:
self.onroad_brightness_timer = 15 * gui_app.target_fps
else:
self.onroad_brightness_timer = self.onroad_brightness_timer_param * gui_app.target_fps
@property
def onroad_brightness_timer_expired(self) -> bool:
return self.onroad_brightness != OnroadBrightness.AUTO and self.onroad_brightness_timer == 0
@property
def auto_onroad_brightness(self) -> bool:
return self.onroad_brightness in (OnroadBrightness.AUTO, OnroadBrightness.AUTO_DARK)
@staticmethod
def guidance_phase(ss, ss_iq, onroad_evt) -> str:
state = ss.state
guidance = ss_iq.aol
guidance_state = guidance.state
if state == OpenpilotState.preEnabled:
return "standby"
if state == OpenpilotState.overriding and (not guidance.available or any(e.overrideLongitudinal for e in onroad_evt)):
return "standby"
if guidance_state in (GuidanceState.paused, GuidanceState.overriding):
return "standby"
if guidance.enabled or ss.enabled:
return "active"
return "standby"
@staticmethod
def update_status(ss, ss_iq, onroad_evt) -> str:
state = ss.state
guidance = ss_iq.aol
guidance_state = guidance.state
if state == OpenpilotState.preEnabled:
return "override"
if state == OpenpilotState.overriding:
if not guidance.available:
return "override"
if any(e.overrideLongitudinal for e in onroad_evt):
return "override"
if guidance_state in (GuidanceState.paused, GuidanceState.overriding):
return "override"
if not guidance.available:
return "engaged" if ss.enabled else "disengaged"
if not guidance.enabled and not ss.enabled:
return "disengaged"
if guidance.enabled and ss.enabled:
return "engaged"
if guidance.enabled:
return "lat_only"
if ss.enabled:
return "long_only"
return "disengaged"
_PARAM_MIRROR = {
"active_bundle": ("ModelManager_ActiveBundle", "raw"),
"blindspot": ("BlindSpot", "bool"),
"chevron_metrics": ("ChevronInfo", "raw"),
"developer_ui": ("IQDevUIInfo", "raw"),
"night_mode": ("NightMode", "bool"),
"rainbow_path": ("RainbowMode", "bool"),
"road_name_toggle": ("RoadNameToggle", "bool"),
"rocket_fuel": ("RocketFuel", "bool"),
"torque_bar": ("TorqueBar", "bool"),
"turn_signals": ("ShowTurnSignals", "bool"),
"custom_interactive_timeout": ("InteractivityTimeout", "default"),
"onroad_brightness_timer_param": ("OnroadScreenOffTimer", "default"),
"speed_limit_mode": ("IQSpeedAssistMode", "default"),
}
def update_params(self) -> None:
CP_IQ_bytes = self.params.get("IQCarParamsPersistentV2")
if CP_IQ_bytes is not None:
self.CP_IQ = messaging.log_from_bytes(CP_IQ_bytes, custom.IQCarParams)
for attr, (key, kind) in self._PARAM_MIRROR.items():
if kind == "bool":
value = self.params.get_bool(key)
elif kind == "default":
value = self.params.get(key, return_default=True)
else:
value = self.params.get(key)
setattr(self, attr, value)
self.is_night = self._compute_is_night() if self.night_mode else False
self.onroad_brightness = int(float(self.params.get("OnroadScreenOffBrightness", return_default=True)))
def _compute_is_night(self) -> bool:
try:
from openpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position
from openpilot.selfdrive.ui.lib.solar import is_after_sunset
lat, lon, _, valid = current_or_last_gps_position(self.params)
return valid and is_after_sunset(lat, lon)
except Exception:
return False
class IQDevice:
def __init__(self):
self._params = Params()
def _set_awake(self, on: bool):
if on and self._params.get("DeviceBootMode", return_default=True) == 1:
self._params.put_bool("OffroadMode", True)
@staticmethod
def set_onroad_brightness(_ui_state, awake: bool, cur_brightness: float) -> float:
if not awake or not _ui_state.started:
return cur_brightness
if _ui_state.onroad_brightness_timer != 0:
if _ui_state.onroad_brightness == OnroadBrightness.AUTO_DARK:
return max(30.0, cur_brightness)
return cur_brightness
if _ui_state.onroad_brightness == OnroadBrightness.AUTO:
return cur_brightness
elif _ui_state.onroad_brightness == OnroadBrightness.AUTO_DARK:
return cur_brightness
return float((_ui_state.onroad_brightness - 1) * 5)
@staticmethod
def set_min_onroad_brightness(_ui_state, min_brightness: int) -> int:
if _ui_state.onroad_brightness == OnroadBrightness.AUTO_DARK:
min_brightness = 10
return min_brightness
@staticmethod
def wake_from_dimmed_onroad_brightness(_ui_state, evs) -> None:
if _ui_state.started and (_ui_state.onroad_brightness_timer_expired or _ui_state.onroad_brightness == OnroadBrightness.AUTO_DARK):
if any(ev.left_down for ev in evs):
if _ui_state.onroad_brightness_timer_expired:
gui_app.mouse_events.clear()
_ui_state.reset_onroad_sleep_timer()
class UIStatus(Enum):
DISENGAGED = "disengaged"
ENGAGED = "engaged"
OVERRIDE = "override"
LAT_ONLY = "lat_only"
LONG_ONLY = "long_only"
class UIState(IQUIState):
_instance: 'UIState | None' = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialize()
return cls._instance
def _initialize(self):
IQUIState.__init__(self)
self.params = Params()
self.sm = messaging.SubMaster(
[
"modelV2",
"controlsState",
"onroadEvents",
"liveCalibration",
"radarState",
"liveTracks",
"iqVehicleTracks",
"deviceState",
"pandaStates",
"carParams",
"driverMonitoringState",
"carState",
"driverStateV2",
"roadCameraState",
"wideRoadCameraState",
"managerState",
"selfdriveState",
"longitudinalPlan",
"gpsLocationExternal",
"carOutput",
"carControl",
"liveParameters",
] + self.sm_services_ext
)
self.prime_state = PrimeState()
# UI Status tracking
self.status: UIStatus = UIStatus.DISENGAGED
self.started_frame: int = 0
self.started_time: float = 0.0
self._engaged_prev: bool = False
self._started_prev: bool = False
self._last_non_disengaged_status: UIStatus = UIStatus.DISENGAGED
self._last_non_disengaged_time: float = 0.0
# Core state variables
self.is_metric: bool = self.params.get_bool("IsMetric")
self.is_release = self.params.get_bool("IsReleaseBranch")
self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM")
self.started: bool = False
self.ignition: bool = False
self.recording_audio: bool = False
self.panda_type: log.PandaState.PandaType = log.PandaState.PandaType.unknown
self.personality: log.LongitudinalPersonality = log.LongitudinalPersonality.standard
self.has_longitudinal_control: bool = False
self.CP: car.CarParams | None = None
self.light_sensor: float = -1.0
self._param_update_time: float = 0.0
# Callbacks
self._offroad_transition_callbacks: list[Callable[[], None]] = []
self._engaged_transition_callbacks: list[Callable[[], None]] = []
self.update_params()
def add_offroad_transition_callback(self, callback: Callable[[], None]):
self._offroad_transition_callbacks.append(callback)
def add_engaged_transition_callback(self, callback: Callable[[], None]):
self._engaged_transition_callbacks.append(callback)
@property
def engaged(self) -> bool:
ss_ext = self.sm["iqState"]
return self.started and (self.sm["selfdriveState"].enabled or ss_ext.aol.enabled)
def is_onroad(self) -> bool:
return self.started
def is_offroad(self) -> bool:
return not self.started
def update(self) -> None:
self.prime_state.start() # start thread after manager forks ui
self.sm.update(0)
self._update_state()
self._update_status()
if time.monotonic() - self._param_update_time > 5.0:
self.update_params()
device.update()
IQUIState.update(self)
def _update_state(self) -> None:
# Handle panda states updates
if self.sm.updated["pandaStates"]:
panda_states = self.sm["pandaStates"]
if len(panda_states) > 0:
# Get panda type from first panda
self.panda_type = panda_states[0].pandaType
# Check ignition status across all pandas
if self.panda_type != log.PandaState.PandaType.unknown:
self.ignition = any(state.ignitionLine or state.ignitionCan for state in panda_states)
elif not self.sm.alive["pandaStates"]:
# Time-based staleness (SubMaster tracks it against the service rate). The old
# `5 * rl.get_fps()` frame threshold went near-zero right after the screen woke — rendering
# had just resumed so raylib's FPS reading was still ~0 — which briefly flagged a perfectly
# fresh pandaStates as stale, flashing panda_type to unknown ("UNAVAILABLE") for a few seconds.
self.panda_type = log.PandaState.PandaType.unknown
# Handle wide road camera state updates
if self.sm.updated["wideRoadCameraState"]:
cam_state = self.sm["wideRoadCameraState"]
self.light_sensor = max(100.0 - cam_state.exposureValPercent, 0.0)
elif not self.sm.alive["wideRoadCameraState"] or not self.sm.valid["wideRoadCameraState"]:
self.light_sensor = -1
# Update started state
self.started = self.sm["deviceState"].started and self.ignition
# Update recording audio state
self.recording_audio = self.params.get_bool("RecordAudio") and self.started
self.is_metric = self.params.get_bool("IsMetric")
self.always_on_dm = self.params.get_bool("AlwaysOnDM")
def _update_status(self) -> None:
if self.started and self.sm.updated["selfdriveState"]:
ss = self.sm["selfdriveState"]
state = ss.state
iq_state = self.sm["iqState"]
if state in (log.SelfdriveState.OpenpilotState.preEnabled, log.SelfdriveState.OpenpilotState.overriding):
self.status = UIStatus.OVERRIDE
else:
self.status = UIStatus.ENGAGED if ss.enabled else UIStatus.DISENGAGED
computed_status = UIStatus(IQUIState.update_status(ss, iq_state, self.sm["onroadEvents"]))
now = time.monotonic()
engaged_like_now = bool(ss.enabled or iq_state.aol.enabled)
if computed_status != UIStatus.DISENGAGED:
self._last_non_disengaged_status = computed_status
self._last_non_disengaged_time = now
self.status = computed_status
elif engaged_like_now:
if self._last_non_disengaged_status != UIStatus.DISENGAGED:
self.status = self._last_non_disengaged_status
else:
self.status = UIStatus.ENGAGED if ss.enabled else UIStatus.DISENGAGED
else:
self.status = UIStatus.DISENGAGED
if self.engaged != self._engaged_prev:
for callback in self._engaged_transition_callbacks:
callback()
self._engaged_prev = self.engaged
if self.started != self._started_prev or self.sm.frame == 1:
if self.started:
self.status = UIStatus.DISENGAGED
self.started_frame = self.sm.frame
self.started_time = time.monotonic()
self._last_non_disengaged_status = UIStatus.DISENGAGED
self._last_non_disengaged_time = 0.0
for callback in self._offroad_transition_callbacks:
callback()
self._started_prev = self.started
def update_params(self) -> None:
CP_bytes = self.params.get("CarParamsPersistent")
if CP_bytes is not None:
self.CP = messaging.log_from_bytes(CP_bytes, car.CarParams)
if self.CP.alphaLongitudinalAvailable:
self.has_longitudinal_control = self.params.get_bool("AlphaLongitudinalEnabled")
else:
self.has_longitudinal_control = self.CP.openpilotLongitudinalControl
IQUIState.update_params(self)
self._param_update_time = time.monotonic()
class Device(IQDevice):
def __init__(self):
IQDevice.__init__(self)
self._ignition = False
self._interaction_time: float = -1
self._override_interactive_timeout: int | None = None
self._interactive_timeout_callbacks: list[Callable] = []
self._prev_timed_out = False
self._awake: bool = True
self._offroad_brightness: int = BACKLIGHT_OFFROAD
self._display_brightness_param: int = 0
self._last_brightness_param_update: float = 0.0
self._last_brightness: int = 0
self._brightness_filter = FirstOrderFilter(BACKLIGHT_OFFROAD, 10.00, 1 / gui_app.target_fps)
self._brightness_thread: threading.Thread | None = None
@property
def awake(self) -> bool:
return self._awake
def set_override_interactive_timeout(self, timeout: int | None) -> None:
self._override_interactive_timeout = timeout
self._reset_interactive_timeout()
@property
def interactive_timeout(self) -> int:
if self._override_interactive_timeout is not None:
return self._override_interactive_timeout
if gui_app.iqpilot_ui() and ui_state.custom_interactive_timeout != 0:
return ui_state.custom_interactive_timeout
ignition_timeout = 10 if gui_app.big_ui() else 5
if ui_state.night_mode and ui_state.is_night:
ignition_timeout = 15
return ignition_timeout if ui_state.ignition else 30
def _reset_interactive_timeout(self) -> None:
self._interaction_time = time.monotonic() + self.interactive_timeout
def add_interactive_timeout_callback(self, callback: Callable):
self._interactive_timeout_callbacks.append(callback)
def update(self):
if self._interaction_time <= 0:
self._reset_interactive_timeout()
self._update_brightness()
self._update_wakefulness()
def set_offroad_brightness(self, brightness: int | None):
if brightness is None:
brightness = BACKLIGHT_OFFROAD
self._offroad_brightness = min(max(brightness, 0), 100)
def _update_display_brightness_param(self):
now = time.monotonic()
if now - self._last_brightness_param_update < 1.0:
return
try:
brightness = int(self._params.get("Brightness", return_default=True))
except (TypeError, ValueError):
brightness = 0
self._display_brightness_param = min(max(brightness, 0), 100)
self._last_brightness_param_update = now
def _update_brightness(self):
self._update_display_brightness_param()
manual_display_brightness = self._display_brightness_param
clipped_brightness = manual_display_brightness if manual_display_brightness > 0 else self._offroad_brightness
if ui_state.started and ui_state.light_sensor >= 0:
clipped_brightness = ui_state.light_sensor
# CIE 1931 - https://www.photonstophotos.net/GeneralTopics/Exposure/Psychometric_Lightness_and_Gamma.htm
if clipped_brightness <= 8:
clipped_brightness = clipped_brightness / 903.3
else:
clipped_brightness = ((clipped_brightness + 16.0) / 116.0) ** 3.0
min_brightness = 30
if gui_app.iqpilot_ui():
min_brightness = IQDevice.set_min_onroad_brightness(ui_state, min_brightness)
clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [min_brightness, 100]))
brightness = round(self._brightness_filter.update(clipped_brightness))
if gui_app.iqpilot_ui():
brightness = IQDevice.set_onroad_brightness(ui_state, self._awake, brightness)
if manual_display_brightness > 0:
brightness = min(brightness, manual_display_brightness)
if not self._awake:
brightness = 0
if brightness != self._last_brightness:
if self._brightness_thread is None or not self._brightness_thread.is_alive():
self._brightness_thread = threading.Thread(target=HARDWARE.set_screen_brightness, args=(brightness,))
self._brightness_thread.start()
self._last_brightness = brightness
def _update_wakefulness(self):
# Handle interactive timeout
ignition_just_turned_off = not ui_state.ignition and self._ignition
self._ignition = ui_state.ignition
if ignition_just_turned_off or any(ev.left_down for ev in gui_app.mouse_events):
if gui_app.iqpilot_ui():
IQDevice.wake_from_dimmed_onroad_brightness(ui_state, gui_app.mouse_events)
self._reset_interactive_timeout()
interaction_timeout = time.monotonic() > self._interaction_time
if interaction_timeout and not self._prev_timed_out:
for callback in self._interactive_timeout_callbacks:
callback()
self._prev_timed_out = interaction_timeout
night_active = ui_state.night_mode and ui_state.is_night
# active screen recording keeps the display on so remote-triggered recordings capture frames
self._set_awake((ui_state.ignition and not night_active) or not interaction_timeout or PC
or gui_app.screen_recorder_active)
def _set_awake(self, on: bool):
if on != self._awake:
IQDevice._set_awake(self, on)
self._awake = on
cloudlog.debug(f"setting display power {int(on)}")
HARDWARE.set_display_power(on)
gui_app.set_should_render(on)
# Global instance
ui_state = UIState()
device = Device()