Files
StarPilot/openpilot/starpilot/ui/spacepilot/ignition.py
T
firestar5683 718a2c3ddd Keep SpacePilot open across cold setup and interrupted clock reads
Discard a preempted clock pair and try up to three fresh pairs while
retaining the 1 ms accepted span and all existing freshness/resume gates.
Yield after receive-only decoder construction so normal UI collection
refreshes before full physical admission, Offroad request or input.

Recorded d5 clock failure; reproduced timing regressions; 250 focused
native cases, lint/type checks and independent source/evidence review.
No vehicle commands, device deployment or physical cure claim.
2026-10-26 18:17:20 -05:00

124 lines
5.6 KiB
Python

"""Release only this UI session's game request after a continuous physical key-off."""
from dataclasses import dataclass
import math
from openpilot.starpilot.parked_evidence import PANDA_TTL_NS, RESUME_SKEW_NS
from openpilot.starpilot.ui.spacepilot.guard import DWELL_NS, FAST_AGE_NS, SLOW_AGE_NS, read_offroad_witness, sample_clocks
@dataclass(frozen=True)
class IgnitionSample:
mono: int
boot: int
stamps: tuple[int, int]
topology: tuple[tuple[str, str], ...]
uptimes: tuple[int, ...]
pandad_pid: int
ignition: bool
def sample_ignition(ui, *, native_device: bool) -> IgnitionSample | None:
try:
mono, boot = sample_clocks()
if (not native_device or ui.projection_read_only or
ui.replay_clock is not None or getattr(ui.sm, 'replay_sample', None) is not None):
return None
sm = ui.sm
stamps = []
for name, now, ttl in (('pandaStates', boot, PANDA_TTL_NS), ('managerState', mono, SLOW_AGE_NS)):
stamp, received = int(sm.logMonoTime[name]), float(sm.recv_time[name])
if (not sm.valid[name] or not sm.alive[name] or sm.recv_frame[name] <= 0 or stamp <= 0 or
not 0 <= now - stamp <= ttl or not math.isfinite(received) or
not 0 <= mono - int(received * 1e9) <= ttl):
return None
stamps.append(stamp)
processes = tuple(sm['managerState'].processes)
names = [p.name for p in processes]
pandad = [p for p in processes if p.name == 'pandad']
if len(names) != len(set(names)) or len(pandad) != 1 or not pandad[0].running or pandad[0].pid <= 0:
return None
pandas = tuple(sm['pandaStates'])
if not pandas:
return None
for p in pandas:
if (str(p.pandaType) == 'unknown' or str(p.harnessStatus) == 'notConnected' or p.uptime < 0 or
p.controlsAllowed or p.heartbeatLost or p.safetyRxChecksInvalid or
str(p.faultStatus) != 'none' or len(p.faults)):
return None
return IgnitionSample(mono, boot, (stamps[0], stamps[1]),
tuple((str(p.pandaType), str(p.harnessStatus)) for p in pandas),
tuple(int(p.uptime) for p in pandas), int(pandad[0].pid),
any(p.ignitionLine or p.ignitionCan for p in pandas))
except (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError, OverflowError):
return None
class IgnitionReturn:
"""No persisted ownership: restarting UI cannot adopt someone else's Offroad.
A loss of continuity permanently disarms this return, even if data reconnects.
Ordinary game exit keeps observing, but holds no reader, widget or wake token.
"""
def __init__(self, ui, owner, revision: str, *, native_device: bool):
self.ui, self.owner, self.revision, self.native_device = ui, owner, revision, native_device
self.previous = sample_ignition(ui, native_device=native_device)
self.finished = self.previous is None or not self.previous.ignition
self.off_since: int | None = None
self.off_stamp: int | None = None
def _owned(self) -> bool:
state = self.owner.snapshot()
return bool(state.available and str(state.mode) == 'offroad' and state.revision == self.revision)
def _continuous(self, current: IgnitionSample | None) -> bool:
old = self.previous
return bool(current is not None and old is not None and
0 <= current.mono - old.mono <= FAST_AGE_NS and
abs((current.boot - current.mono) - (old.boot - old.mono)) <= RESUME_SKEW_NS and
current.topology == old.topology and current.pandad_pid == old.pandad_pid and
all(new >= previous for new, previous in zip(current.stamps, old.stamps, strict=True)) and
all(previous <= new <= previous + 1 for new, previous in zip(current.uptimes, old.uptimes, strict=True)))
def observe(self) -> bool:
"""Return true only after fresh, advancing all-off reports and quiet noOutput."""
if self.finished:
return False
try:
current = sample_ignition(self.ui, native_device=self.native_device)
if not self._owned() or not self._continuous(current):
self.finished = True
return False
assert current is not None
if self.previous is not None and current.ignition != self.previous.ignition and current.stamps[0] <= self.previous.stamps[0]:
self.finished = True
return False
self.previous = current
if current.ignition:
self.off_since = self.off_stamp = None
return False
# Require actual service shutdown too, without using old Park/CAN data
# after ignition off. An unknown/faulted offroad witness cannot release.
if read_offroad_witness(self.ui, current.mono, current.boot, native_device=self.native_device) is None:
self.finished = True
return False
if self.off_since is None:
self.off_since, self.off_stamp = current.mono, current.stamps[0]
return self.off_stamp is not None and current.mono - self.off_since >= DWELL_NS and current.stamps[0] > self.off_stamp
except (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError):
self.finished = True
return False
def release(self, game_closed) -> None:
"""Normal owner transaction rechecks physical evidence and closed game."""
if self.finished:
return
def authorized():
return bool(game_closed() and self.observe())
try:
if authorized():
self.owner.request('auto', expected_revision=self.revision, authorized=authorized, override_allowed=lambda: False)
finally:
# One bounded transaction; a busy/rejected owner is never retried blindly.
self.finished = True