Files
StarPilot/openpilot/starpilot/ui/slc_action_dispatch.py
T
2026-10-21 19:22:59 -04:00

130 lines
5.1 KiB
Python

"""One UI publisher for state-bound SLC requests.
The planner alone decides whether an action is accepted. This module never
changes a displayed limit, cruise target, Params value, or camera state.
"""
from collections.abc import Callable
import math
import time
from typing import Any
import openpilot.cereal.messaging as messaging
from openpilot.starpilot.ui.onroad_state import SlcActionKind, SlcUiRequest
_MAX_STATE_AGE_NS = 100_000_000 # two periods of the 20 Hz slcState service
_DUPLICATE_WINDOW_NS = 2_000_000_000
def _positive_number(value: Any) -> bool:
try:
number = float(value)
except (TypeError, ValueError, OverflowError):
return False
return math.isfinite(number) and number > 0
class SlcActionDispatcher:
"""Validate a fresh state again at release, then publish one typed request."""
def __init__(self, publisher: Any, read_current: Callable[[int], Any | None],
clock: Callable[[], int] = time.monotonic_ns):
self.publisher = publisher
self.read_current = read_current
self.clock = clock
self._session = ""
self._last_sequence = 0
self._last_context: tuple[str, int, int, SlcActionKind] | None = None
self._last_context_ns = 0
self._feedback_sequence = 0
self._feedback_started_ns = 0
def dispatch(self, request: SlcUiRequest) -> bool:
if not _positive_number(request.candidate_speed_mps):
return False
now_ns = self.clock()
state = self.read_current(now_ns)
if state is None:
return False
try:
session = str(state.sessionId)
frame_ns = int(state.frameMonoTime)
decision_id = int(state.decisionId)
presentation_id = int(state.presentationId)
if (not session or session != request.session_id or not bool(state.enabled) or
str(state.observationKind) != "valid" or str(state.source) == "none" or not 0 < frame_ns <= now_ns or
now_ns - frame_ns > _MAX_STATE_AGE_NS or
decision_id != request.decision_id or presentation_id != request.presentation_id):
return False
if request.kind in (SlcActionKind.ACCEPT, SlcActionKind.REJECT):
if (not bool(state.hasPending) or bool(getattr(state, 'automaticAdoptionPending', False)) or
decision_id <= 0 or not _positive_number(state.pendingSpeedLimit) or
not math.isclose(float(state.pendingSpeedLimit), request.candidate_speed_mps, abs_tol=0.001)):
return False
elif request.kind == SlcActionKind.ADOPT:
if (bool(state.hasPending) or presentation_id <= 0 or not _positive_number(state.speedLimit) or
not math.isclose(float(state.speedLimit), request.candidate_speed_mps, abs_tol=0.001)):
return False
else:
return False
acknowledged_sequence = int(state.actionSequenceId)
if acknowledged_sequence < 0:
return False
except (AttributeError, TypeError, ValueError, OverflowError):
return False
context = (session, decision_id, presentation_id, request.kind)
if (self._last_context is not None and context[:3] == self._last_context[:3] and
now_ns - self._last_context_ns < _DUPLICATE_WINDOW_NS and
(context == self._last_context or acknowledged_sequence < self._last_sequence)):
return False
if session != self._session:
self._session = session
self._last_sequence = 0
self._last_context = None
sequence = max(now_ns, acknowledged_sequence + 1, self._last_sequence + 1, 1)
try:
message = messaging.new_message("slcAction", valid=True)
message.logMonoTime = now_ns
action = message.slcAction
action.sessionId = session
action.sequenceId = sequence
action.decisionId = decision_id
action.presentationId = presentation_id
action.kind = request.kind.value
self.publisher.send("slcAction", message)
except (OSError, RuntimeError, ValueError):
return False
self._last_sequence = sequence
self._last_context = context
self._last_context_ns = now_ns
self._feedback_sequence = sequence
self._feedback_started_ns = now_ns
return True
def clear_feedback(self):
self._feedback_sequence = 0
def feedback(self, observation) -> str:
if observation.session_id != self._session:
self._feedback_sequence = 0
if not self._feedback_sequence:
return ''
if (observation.pending_speed_limit_mps is not None and self._last_context is not None and
observation.decision_id != self._last_context[1]):
self.clear_feedback()
return ''
age = self.clock() - self._feedback_started_ns
if age > 4_000_000_000:
self._feedback_sequence = 0
return ''
if observation.action_sequence_id != self._feedback_sequence:
return 'Waiting…' if age < 2_000_000_000 else 'Not confirmed'
if observation.command_action_sequence_id == self._feedback_sequence:
return {'issued': 'Applying…', 'applied': 'Applied', 'rejected': 'Setpoint not applied',
'expired': 'Setpoint not applied'}.get(observation.command_status, 'Not confirmed')
return {'accept': 'Limit accepted', 'reject': 'Set speed kept', 'adopt': 'Limit adopted'}.get(
observation.action_status, 'Not applied')