"""Explicit, immutable observations for the offline onroad presentation. The producer owns freshness and units. A missing observation is never inferred from a zero-valued speed or a default-initialized message. """ from openpilot.starpilot.ui.onroad_customization import PROFILES, default_document, offset, placement, widget_size from collections.abc import Callable from dataclasses import dataclass, field, replace from enum import StrEnum import math from typing import Protocol from openpilot.starpilot.ui.presentation import Profile from openpilot.starpilot.ui.large_speed_geometry import ACTION_BOUNDS, LIMIT_RECTS, confirmation_bounds, confirmation_buttons from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice, OnroadAppearance from openpilot.starpilot.curve_speed.status import CurveObservation from openpilot.starpilot.conditional_mode.policy import ModeChoice from openpilot.starpilot.ui.conditional_status import ConditionalDisplay from openpilot.starpilot.ui.traffic_status import TrafficDisplay from openpilot.starpilot.ui.developer_preview import OnroadVisualPreview from openpilot.starpilot.ui.navigation_state import NavigationDisplay from openpilot.starpilot.ui.live_developer_sidebar import DeveloperMetric TOYOTA_NOTICE_STATUSES = frozenset(('raise_dash', 'release_plus', 'off_hold', 'off_resume')) class ObservationKind(StrEnum): VALID = "valid" ABSENT = "absent" UNKNOWN = "unknown" STALE = "stale" class AlertSize(StrEnum): NONE = "none" SMALL = "small" MID = "mid" FULL = "full" class SlcActionKind(StrEnum): ACCEPT = "accept" REJECT = "reject" ADOPT = "adopt" @dataclass(frozen=True) class SlcUiRequest: kind: SlcActionKind session_id: str decision_id: int presentation_id: int candidate_speed_mps: float @dataclass(frozen=True) class SlcControl: request: SlcUiRequest label: str bounds: tuple[float, float, float, float] @dataclass(frozen=True) class SourceReading: source: str enabled: bool kind: str speed_mps: float | None = None @dataclass(frozen=True) class SpeedLimitObservation: kind: ObservationKind = ObservationKind.UNKNOWN source: str = "none" speed_limit_mps: float | None = None offset_mps: float | None = None pending_speed_limit_mps: float | None = None effective_cap_mps: float | None = None accepted_speed_limit_mps: float | None = None accepted_source: str = "none" pending_source: str = "none" effective_cluster_target_mps: float | None = None limiting_max_set: bool = False driver_override_active: bool = False session_id: str | None = None decision_id: int | None = None presentation_id: int | None = None status: str = "" action_enabled: bool = False action_sequence_id: int = 0 action_status: str = "" source_readings: tuple[SourceReading, ...] = () accepted_adjusted_limit_mps: float | None = None pending_adjusted_limit_mps: float | None = None presentation_adjusted_limit_mps: float | None = None override_basis: str = 'none' retained_override: bool = False command_action_sequence_id: int = 0 command_status: str = '' action_feedback: str = '' automatic_adoption_pending: bool = False pending_since_ns: int = 0 last_received_speed_limit_mps: float | None = None last_received_source: str = 'none' last_received_adjusted_limit_mps: float | None = None def __post_init__(self) -> None: for value in (self.speed_limit_mps, self.offset_mps, self.pending_speed_limit_mps, self.effective_cap_mps, self.accepted_speed_limit_mps, self.effective_cluster_target_mps, self.accepted_adjusted_limit_mps, self.pending_adjusted_limit_mps, self.presentation_adjusted_limit_mps, self.last_received_speed_limit_mps, self.last_received_adjusted_limit_mps): if value is not None and not math.isfinite(value): raise ValueError("Speed-limit values must be finite") for value in (self.accepted_adjusted_limit_mps, self.pending_adjusted_limit_mps, self.presentation_adjusted_limit_mps): if value is not None and value <= 0: raise ValueError('Adjusted limits must be positive') if self.kind != ObservationKind.VALID and self.speed_limit_mps is not None: raise ValueError("Only a valid observation can carry a speed limit") if self.kind == ObservationKind.VALID and (self.speed_limit_mps is None or self.speed_limit_mps <= 0): raise ValueError("A valid observation needs a positive speed limit") @dataclass(frozen=True) class OnroadAlert: size: AlertSize = AlertSize.NONE text1: str = "" text2: str = "" critical: bool = False alert_type: str = "" user_prompt: bool = False visual_alert: int = 0 def is_lane_change_notice(alert: OnroadAlert) -> bool: return (alert.alert_type in ("preLaneChangeLeft/warning", "preLaneChangeRight/warning", "laneChange/warning") and alert.size in (AlertSize.SMALL, AlertSize.MID) and not alert.critical and not alert.user_prompt and alert.visual_alert == 0) def is_personality_notice(alert: OnroadAlert) -> bool: return (alert.alert_type == "personalityChanged/warning" and alert.size in (AlertSize.SMALL, AlertSize.MID) and not alert.critical and not alert.user_prompt and alert.visual_alert == 0) @dataclass(frozen=True) class BorderSignals: left_blinker: bool right_blinker: bool left_blindspot: bool right_blindspot: bool @dataclass(frozen=True) class ConditionalLead: distance_m: float speed_mps: float def __post_init__(self) -> None: if (not math.isfinite(self.distance_m) or not 0 < self.distance_m <= 500 or not math.isfinite(self.speed_mps) or not 0 <= self.speed_mps <= 100): raise ValueError('Conditional lead metrics must be finite and physically bounded') def conditional_lead_from_message(radar) -> ConditionalLead | None: """The caller supplies only a current display radar observation.""" try: lead = radar.leadOne if lead.status is not True: return None return ConditionalLead(float(lead.dRel), float(lead.vLead)) except (AttributeError, TypeError, ValueError, OverflowError): return None @dataclass(frozen=True) class OnroadState: engaged: bool camera_available: bool speed_mps: float | None cruise_kph: float | None speed_limit: SpeedLimitObservation appearance: OnroadAppearance = field(default_factory=OnroadAppearance) alert: OnroadAlert = OnroadAlert() metric: bool = False show_slc_offset: bool = True experimental_available: bool = False experimental_enabled: bool = False torque_utilization: float = 0.0 personality: int = 1 switchback_mode: bool = False traffic_mode: bool = False traffic_display: TrafficDisplay | None = None lateral_active: bool = False longitudinal_active: bool = False longitudinal_overridden: bool = False driver_override_active: bool = False stock_cruise_active: bool = False slc_system_long_available: bool = False curve: CurveObservation | None = None show_curve_status: bool = False conditional_configured: ModeChoice | None = None navigation: NavigationDisplay | None = None conditional_perception: ConditionalDisplay | None = None conditional_effective: ConditionalDisplay | None = None conditional_lead: ConditionalLead | None = None visual_preview: OnroadVisualPreview | None = None border_signals: BorderSignals | None = None stopped_duration_s: int | None = None reverse_driver_camera: bool = False reversing: bool = False stock_confidence_source_fresh: bool = False stock_confidence_source_stamp_ns: int | None = None stock_confidence_drive_frame: int | None = None model_confidence: float | None = None lead_indicator_source_fresh: bool = False customization: dict = field(default_factory=default_document) wheel_acceleration: float | None = None torque_source_available: bool = True torque_drive_frame: int | None = None experimental_action_token: str = "" drive_frame: int | None = None observed_ns: int = 0 viewport_width: float = 2160.0 developer_metrics: tuple[DeveloperMetric, ...] = () slc_prompt_open: bool = False slc_wheel_confirmation_available: bool = False toyota_cruise_status: str = '' toyota_cruise_target_kph: float | None = None @property def cruise_active(self) -> bool: return self.longitudinal_active or self.longitudinal_overridden or self.stock_cruise_active def __post_init__(self) -> None: if self.speed_mps is not None and (not math.isfinite(self.speed_mps) or self.speed_mps < 0): raise ValueError("Speed must be finite and nonnegative") if self.cruise_kph is not None and (not math.isfinite(self.cruise_kph) or self.cruise_kph < 0): raise ValueError("Cruise speed must be finite and nonnegative") if not math.isfinite(self.torque_utilization) or not -1.0 <= self.torque_utilization <= 1.0: raise ValueError("Torque utilization must be normalized") if not 0 <= self.personality <= 2: raise ValueError("Personality must be aggressive, standard, or relaxed") if self.stopped_duration_s is not None and (type(self.stopped_duration_s) is not int or self.stopped_duration_s <= 0): raise ValueError("Stopped duration must be a positive whole second") if self.model_confidence is not None and (not math.isfinite(self.model_confidence) or not 0 <= self.model_confidence <= 1): raise ValueError("Model confidence must be normalized") @dataclass(frozen=True) class OnroadRequest: kind: str value: bool | None = None action_token: str = "" def manual_slc_confirmation(profile: Profile, state: OnroadState) -> bool: obs = state.speed_limit widget = 'speed_limit' if profile == Profile.COMPACT else 'cruise_limits' return bool(state.alert.size == AlertSize.NONE and state.toyota_cruise_status not in TOYOTA_NOTICE_STATUSES and (profile != Profile.COMPACT or not state.reverse_driver_camera and state.appearance.camera_view != CameraViewChoice.DRIVER) and obs.kind == ObservationKind.VALID and obs.action_enabled and not obs.automatic_adoption_pending and obs.pending_speed_limit_mps is not None and obs.pending_speed_limit_mps > 0 and obs.session_id and obs.decision_id and obs.decision_id > 0 and obs.presentation_id and obs.presentation_id > 0 and placement(state.customization, profile, widget)['enabled'] and placement(state.customization, profile, 'speed_limit_actions')['enabled']) def _default_slc_controls(profile: Profile, state: OnroadState) -> tuple[SlcControl, ...]: """Only render/accept actions backed by a displayed live SLC decision.""" observation = state.speed_limit if (state.toyota_cruise_status in TOYOTA_NOTICE_STATUSES or state.alert.size != AlertSize.NONE or not observation.action_enabled or observation.kind != ObservationKind.VALID or not observation.session_id): return () pending = observation.pending_speed_limit_mps if (pending is None and observation.speed_limit_mps is not None and observation.presentation_id is not None and observation.presentation_id > 0): # Once this limit is the one in use, adopting it again only helps to clear an override # or to raise a set speed that sits below it. accepted = observation.accepted_speed_limit_mps adjusted = observation.presentation_adjusted_limit_mps or observation.speed_limit_mps # Card selects whole display units. Compare those same units so its rounded # set speed does not keep offering an already applied limit (e.g. 70 mph). display_units = 3.6 if state.metric else 2.2369362921 raises_set = (state.cruise_active and state.cruise_kph is not None and round(state.cruise_kph / 3.6 * display_units) < round(adjusted * display_units)) if (accepted is not None and abs(accepted - observation.speed_limit_mps) <= 0.02 and not observation.driver_override_active and not raises_set): return () request = SlcUiRequest(SlcActionKind.ADOPT, observation.session_id, observation.decision_id or 0, observation.presentation_id, observation.speed_limit_mps) first, last = ACTION_BOUNDS[profile] bounds = first[0], first[1], last[2], last[3] target = observation.presentation_adjusted_limit_mps label = f'Use {round((target or observation.speed_limit_mps) * display_units)}' return (SlcControl(request, label, bounds),) return () def slc_action_bounds(profile: Profile, state: OnroadState): dx, dy = offset(state.customization, profile, 'speed_limit_actions') return tuple((left + dx, top + dy, right + dx, bottom + dy) for left, top, right, bottom in ACTION_BOUNDS[profile]) def slc_controls(profile: Profile, state: OnroadState) -> tuple[SlcControl, ...]: if manual_slc_confirmation(profile, state) and state.slc_prompt_open: obs = state.speed_limit bounds = speed_limit_bounds(profile, state) if bounds is None: return () return tuple(SlcControl(SlcUiRequest(kind, obs.session_id or '', obs.decision_id or 0, obs.presentation_id or 0, obs.pending_speed_limit_mps or 0), label, area) for kind, label, area in zip((SlcActionKind.ACCEPT, SlcActionKind.REJECT), ('Accept', 'Reject'), confirmation_buttons(profile, bounds), strict=True)) if not placement(state.customization, profile, "speed_limit_actions")["enabled"]: return () dx, dy = offset(state.customization, profile, 'speed_limit_actions') controls = tuple(SlcControl(item.request, item.label, (item.bounds[0] + dx, item.bounds[1] + dy, item.bounds[2] + dx, item.bounds[3] + dy)) for item in _default_slc_controls(profile, state)) if profile == Profile.COMPACT: controls = tuple(replace(item, bounds=(max(0, item.bounds[0]), max(0, item.bounds[1]), min(476, item.bounds[2]), min(240, item.bounds[3]))) for item in controls) return controls def slc_action_occludes(profile: Profile, state: OnroadState, bounds) -> bool: if manual_slc_confirmation(profile, state): area = speed_limit_bounds(profile, state) return area is not None and area[0] < bounds[2] and bounds[0] < area[2] and area[1] < bounds[3] and bounds[1] < area[3] notice = state.toyota_cruise_status in TOYOTA_NOTICE_STATUSES if state.alert.size != AlertSize.NONE or not (notice or slc_controls(profile, state)): return False first, last = slc_action_bounds(profile, state) left, top, right, bottom = bounds return first[0] < right and left < last[2] and first[1] < bottom and top < last[3] def _inside_widget(x, y, state, widget, bounds): if not placement(state.customization, Profile.LARGE, widget)["enabled"]: return False dx, dy = offset(state.customization, Profile.LARGE, widget) left, top, right, bottom = bounds if widget == "steering_wheel": left += state.viewport_width - 1860 size, _ = widget_size(state.customization, Profile.LARGE, widget) right, bottom = left + size, top + size return left + dx <= x <= right + dx and top + dy <= y <= bottom + dy def compact_sign_obscured_by_actions(state: OnroadState) -> bool: """Hide a moved limit only where the visible confirmation words overlap it.""" if manual_slc_confirmation(Profile.COMPACT, state) or not slc_controls(Profile.COMPACT, state): return False sign = placement(state.customization, "compact", "speed_limit") actions = placement(state.customization, "compact", "speed_limit_actions") widgets = PROFILES["compact"]["widgets"] area, sign_area = widgets["speed_limit_actions"], widgets["speed_limit"] top = actions["y"] bottom = top + area["height"] return (max(0, sign["x"] - sign_area.get('visualInsetLeft', 0)) < actions["x"] + area["width"] and sign["x"] + sign_area["width"] > actions["x"] and max(0, sign["y"] - sign_area.get('visualInsetTop', 0)) < bottom and sign["y"] + sign_area["height"] > top) def speed_limit_bounds(profile: Profile, state: OnroadState) -> tuple[float, float, float, float] | None: from openpilot.starpilot.ui.unified_speed_presentation import displayed_limit_mps if (displayed_limit_mps(state.speed_limit) is None or not state.appearance.show_speed_limit_sign and not state.speed_limit.action_enabled): return None widget = 'speed_limit' if profile == Profile.COMPACT else 'cruise_limits' saved = placement(state.customization, profile, widget) if not saved['enabled'] or profile == Profile.COMPACT and compact_sign_obscured_by_actions(state): return None if manual_slc_confirmation(profile, state): return confirmation_bounds(profile, saved['x'], saved['y'], opened=state.slc_prompt_open) x, y, width, height = LIMIT_RECTS[profile] dx, dy = offset(state.customization, profile, widget) return x + dx - 12, y + dy - 12, x + dx + width + 12, y + dy + height + 8 class OnroadInput: """The onroad wheel emits intent only; the supplied state must be refreshed.""" def __init__(self, emit: Callable[[OnroadRequest | SlcUiRequest], None], profile: Profile = Profile.LARGE, slop: float = 5): self.emit = emit self.profile = profile self.slop = slop self._press: tuple[float, float, bool, str] | None = None self._slc_press: SlcControl | None = None self._sign_press: tuple[tuple, tuple] | None = None self._prompt: tuple | None = None def _pending_context(self, state: OnroadState) -> tuple | None: obs = state.speed_limit if manual_slc_confirmation(self.profile, state): return obs.session_id, obs.decision_id, obs.presentation_id, obs.pending_speed_limit_mps return None def present(self, state: OnroadState) -> OnroadState: if self._prompt != self._pending_context(state): self._prompt = None return replace(state, slc_prompt_open=self._prompt is not None) @property def pressed_action(self): return self._slc_press.request if self._slc_press else None @property def claimed(self) -> bool: return self._press is not None or self._slc_press is not None or self._sign_press is not None def press(self, x: float, y: float, state: OnroadState) -> None: self.cancel() state = self.present(state) controls = slc_controls(self.profile, state) for control in controls: left, top, right, bottom = control.bounds if left <= x <= right and top <= y <= bottom: self._slc_press = control return bounds, context = speed_limit_bounds(self.profile, state), self._pending_context(state) if bounds is not None and context is not None and bounds[0] <= x <= bounds[2] and bounds[1] <= y <= bounds[3]: self._sign_press = context, bounds return # Only the large-profile wheel is an experimental-mode touch target. if self.profile != Profile.LARGE: self.cancel() return inside = _inside_widget(x, y, state, "steering_wheel", (1588, 75, 1780, 267)) self._press = (x, y, state.experimental_enabled, state.experimental_action_token) if inside and state.experimental_available else None def move(self, x: float, y: float, state: OnroadState) -> None: state = self.present(state) if self._sign_press: context, original_bounds = self._sign_press bounds = speed_limit_bounds(self.profile, state) if (context != self._pending_context(state) or bounds is None or bounds != original_bounds or not (bounds[0] <= x <= bounds[2] and bounds[1] <= y <= bounds[3])): self.cancel() return if self._slc_press: current = next((item for item in slc_controls(self.profile, state) if item == self._slc_press), None) in_control = current is not None and current.bounds[0] <= x <= current.bounds[2] and current.bounds[1] <= y <= current.bounds[3] if not in_control: self.cancel() return if self._press: px, py, previous, token = self._press if (abs(x - px) > self.slop or abs(y - py) > self.slop or not (_inside_widget(x, y, state, "steering_wheel", (1588, 75, 1780, 267))) or previous != state.experimental_enabled or token != state.experimental_action_token or not state.experimental_available): self.cancel() def release(self, x: float, y: float, state: OnroadState) -> None: self.move(x, y, state) if self._slc_press: self.emit(self._slc_press.request) self._prompt = None if self._sign_press: self._prompt = self._sign_press[0] if self._press: self.emit(OnroadRequest("set_experimental", not self._press[2], self._press[3])) self.cancel() def cancel(self, *, close_prompt: bool = False) -> None: self._press = None self._slc_press = None self._sign_press = None if close_prompt: self._prompt = None class SlcStateMessage(Protocol): """The small UI-facing subset of the planned SLC schema message.""" observationKind: str source: str speedLimit: float offset: float pendingSpeedLimit: float effectiveCap: float acceptedSpeedLimit: float hasPending: bool hasCeiling: bool hasAccepted: bool sessionId: str decisionId: int presentationId: int status: str enabled: bool actionSequenceId: int actionStatus: str def source_readings_from_message(message): rows = [] for row in getattr(message, 'sourceReadings', ()): source, kind = str(row.source), str(row.observationKind) if source not in ('dashboard', 'map', 'vision', 'online') or source in {item.source for item in rows}: continue value = float(row.speedLimit) if kind == 'valid' else None if value is not None and (not math.isfinite(value) or value <= 0): kind, value = 'unknown', None rows.append(SourceReading(source, bool(row.enabled), kind, value)) return tuple(rows) def speed_limit_from_message(message: SlcStateMessage | None) -> SpeedLimitObservation: """Map explicit SLC observation semantics without assuming a live service. The caller checks message freshness and passes ``None`` for missing or stale transport. Native numeric defaults cannot become a valid speed limit here. """ if message is None: return SpeedLimitObservation(kind=ObservationKind.UNKNOWN) try: kind = ObservationKind(message.observationKind) except (ValueError, AttributeError): return SpeedLimitObservation(kind=ObservationKind.UNKNOWN, status="Invalid SLC observation kind") if kind != ObservationKind.VALID and not (getattr(message, 'lastReceivedSpeedLimit', 0) > 0 or getattr(message, 'hasAccepted', False) and getattr(message, 'hasEffectiveClusterTarget', False)): return SpeedLimitObservation(kind=kind, source=message.source, status=message.status, source_readings=source_readings_from_message(message)) try: live = kind == ObservationKind.VALID return SpeedLimitObservation(kind=kind, source=message.source, speed_limit_mps=message.speedLimit if live else None, source_readings=source_readings_from_message(message), offset_mps=message.offset, pending_speed_limit_mps=message.pendingSpeedLimit if live and message.hasPending else None, effective_cap_mps=message.effectiveCap if message.hasCeiling else None, accepted_speed_limit_mps=message.acceptedSpeedLimit if message.hasAccepted else None, accepted_source=str(getattr(message, "acceptedSource", "none")) or "none", pending_source=(str(getattr(message, "pendingSource", "none")) or "none") if live else "none", effective_cluster_target_mps=(getattr(message, "effectiveClusterTarget", None) if getattr(message, "hasEffectiveClusterTarget", False) else None), limiting_max_set=bool(getattr(message, "isLimitingMaxSet", False)), driver_override_active=bool(getattr(message, "driverOverrideActive", False)), session_id=message.sessionId, decision_id=message.decisionId if live else None, presentation_id=message.presentationId if live else None, status=message.status, action_enabled=bool(getattr(message, "enabled", False)), action_sequence_id=int(getattr(message, "actionSequenceId", 0)), action_status=str(getattr(message, "actionStatus", "")), automatic_adoption_pending=bool(getattr(message, 'automaticAdoptionPending', False)), pending_since_ns=int(getattr(message, 'pendingSinceMonoTime', 0)), last_received_speed_limit_mps=getattr(message, 'lastReceivedSpeedLimit', 0) or None, last_received_source=str(getattr(message, 'lastReceivedSource', 'none')) or 'none', last_received_adjusted_limit_mps=getattr(message, 'lastReceivedAdjustedLimit', 0) or None, accepted_adjusted_limit_mps=(getattr(message, 'acceptedAdjustedLimit', None) if getattr(message, 'hasAcceptedAdjustedLimit', False) else None), pending_adjusted_limit_mps=(getattr(message, 'pendingAdjustedLimit', None) if getattr(message, 'hasPendingAdjustedLimit', False) else None), presentation_adjusted_limit_mps=(getattr(message, 'presentationAdjustedLimit', None) if getattr(message, 'hasPresentationAdjustedLimit', False) else None), override_basis=str(getattr(message, 'overrideBasis', 'none')), retained_override=bool(getattr(message, 'retainedOverride', False)), command_action_sequence_id=int(getattr(message, 'commandActionSequenceId', 0)), command_status=str(getattr(message, 'commandStatus', ''))) except (ValueError, TypeError, AttributeError, OverflowError): return SpeedLimitObservation(kind=ObservationKind.UNKNOWN, source=message.source, status="Invalid SLC numeric observation")