From a16ecb2b8c6b1132d0d12e2125be768f4aa73e00 Mon Sep 17 00:00:00 2001 From: Danny Date: Sun, 8 Nov 2026 10:40:52 -0800 Subject: [PATCH] Sentry: Poll without Message Resulted in Reset of Countdown --- .../starpilot/galaxy/tests/test_settings.mjs | 1 + openpilot/starpilot/galaxy/web/css/settings.css | 10 +++++----- openpilot/starpilot/galaxy/web/js/settings.js | 3 ++- openpilot/starpilot/sentry_mode/README.md | 2 ++ openpilot/starpilot/sentry_mode/policy.py | 9 ++++++++- openpilot/starpilot/sentry_mode/runtime.py | 5 ++++- .../starpilot/sentry_mode/tests/test_policy.py | 16 ++++++++++++++++ .../starpilot/sentry_mode/tests/test_runtime.py | 13 +++++++++++++ 8 files changed, 51 insertions(+), 8 deletions(-) diff --git a/openpilot/starpilot/galaxy/tests/test_settings.mjs b/openpilot/starpilot/galaxy/tests/test_settings.mjs index 7d2d704568..05c8978fe3 100644 --- a/openpilot/starpilot/galaxy/tests/test_settings.mjs +++ b/openpilot/starpilot/galaxy/tests/test_settings.mjs @@ -425,6 +425,7 @@ for (const subtitle of ["Off: Motion monitoring is disabled.", "Not armed: Batte assert.equal(sentryMonitorStatus({ subtitle }).detail, subtitle) } assert.equal(sentryMonitorStatus({ subtitle: "Arming · 82s: Monitoring begins after 90 seconds parked." }).tone, "warning") +assert.equal(sentryMonitorStatus({ subtitle: "Arming · 82s: Monitoring begins after 90 seconds parked." }).label, "Arming") const armedStatus = { subtitle: "Monitoring motion: Watching for movement while parked." } assert.equal(sentryMonitorStatus(armedStatus).label, "Armed") assert.equal(sentryMonitorStatus(armedStatus, "Connection lost").label, "Status unavailable") diff --git a/openpilot/starpilot/galaxy/web/css/settings.css b/openpilot/starpilot/galaxy/web/css/settings.css index 73097358a9..c1573b95d4 100644 --- a/openpilot/starpilot/galaxy/web/css/settings.css +++ b/openpilot/starpilot/galaxy/web/css/settings.css @@ -741,9 +741,9 @@ html.gx-maker-open, html.gx-maker-open body { touch-action: manipulation; } .gx-model-card--polaris .gx-model-card__title { background: none; -webkit-text-fill-color: currentColor; color: CanvasText; } } -.gx-sentry-status { flex: 1 1 100%; order: 1; display: grid; gap: var(--sp-2); } -.gx-sentry-status__label { width: fit-content; padding: 4px 12px; border: 1px solid currentColor; border-radius: 999px; } -.gx-sentry-status--armed { color: var(--success); } -.gx-sentry-status--disarmed { color: var(--error); } -.gx-sentry-status--warning { color: var(--warning); } +.gx-sentry-status { flex: 1 1 100%; order: 1; display: grid; gap: var(--sp-1); color: var(--on-surface-variant); font-size: var(--fs-sm); } +.gx-sentry-status__label { display: inline-flex; align-items: center; gap: var(--sp-2); width: fit-content; font-weight: 500; } +.gx-sentry-status__label::before { content: ""; width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--sentry-status-dot, currentColor); } +.gx-sentry-status--armed { --sentry-status-dot: var(--success); } +.gx-sentry-status--warning { --sentry-status-dot: var(--warning); } .gx-sentry-status__detail { color: var(--on-surface-variant); font-size: var(--fs-sm); } diff --git a/openpilot/starpilot/galaxy/web/js/settings.js b/openpilot/starpilot/galaxy/web/js/settings.js index 14852f4a43..b289ffdb5e 100644 --- a/openpilot/starpilot/galaxy/web/js/settings.js +++ b/openpilot/starpilot/galaxy/web/js/settings.js @@ -49,7 +49,8 @@ export function sentryMonitorStatus(data, error = "") { if (error || !subtitle || label.startsWith("Waiting for monitor") || label === "Unavailable" || subtitle.includes("status is unavailable")) return { label: "Status unavailable", tone: "warning", detail: error ? "Reconnecting to the motion monitor…" : subtitle || "Checking motion monitor…" } if (label === "Monitoring motion") return { label: "Armed", tone: "armed", detail: subtitle } - return { label: "Disarmed", tone: label.startsWith("Arming") ? "warning" : "disarmed", detail: subtitle } + if (label.startsWith("Arming")) return { label: "Arming", tone: "warning", detail: subtitle } + return { label: "Disarmed", tone: "disarmed", detail: subtitle } } export class SettingsFeed { diff --git a/openpilot/starpilot/sentry_mode/README.md b/openpilot/starpilot/sentry_mode/README.md index 58bf184302..f20d32aaab 100644 --- a/openpilot/starpilot/sentry_mode/README.md +++ b/openpilot/starpilot/sentry_mode/README.md @@ -31,6 +31,8 @@ threshold and warning-count behavior. An alarm requires more than 25 qualifying deltas and at least 30 seconds within a 60-second motion window. A stale or repeated sample, sensor loss, owner-loop gap, system resume, settings change, ignition or shutdown request disarms the policy. Recovery requires rearming. +Polling without a new sensor message preserves arming while the last accepted +sample remains fresh (at most 0.5 seconds); it never counts as another motion sample. Hardwared retains battery accounting and shutdown authority; Sentry neither extends parked runtime nor clears shutdown requests. diff --git a/openpilot/starpilot/sentry_mode/policy.py b/openpilot/starpilot/sentry_mode/policy.py index cba222e623..1ac790f7d2 100644 --- a/openpilot/starpilot/sentry_mode/policy.py +++ b/openpilot/starpilot/sentry_mode/policy.py @@ -54,10 +54,11 @@ class Inputs: device_fresh: bool ignition_off: bool sample: MotionSample | None = None + sample_pending: bool = False def __post_init__(self) -> None: if any(type(value) is not bool for value in (self.enabled, self.offroad, self.voltage_ok, - self.device_fresh, self.ignition_off)) or \ + self.device_fresh, self.ignition_off, self.sample_pending)) or \ (self.sample is not None and not isinstance(self.sample, MotionSample)): raise ValueError("Sentry authority inputs must be explicit booleans and a validated motion sample") @@ -123,6 +124,12 @@ class SentryPolicy: return Decision("unavailable") sample = inputs.sample + # Polling without a new message is not sensor loss. Preserve continuity only + # while the last accepted sample is fresh, without counting it as motion. + if sample is None and inputs.sample_pending and self.arm_started is not None and \ + self.last_sample_time is not None and 0 <= now - self.last_sample_time <= MAX_SAMPLE_AGE_SECONDS: + remaining = ARM_DELAY_SECONDS - (now - self.arm_started) + return Decision("arming", seconds_remaining=max(0, math.ceil(remaining))) if remaining > 0 else Decision("armed") if sample is None or sample.mono_time > now or now - sample.mono_time > MAX_SAMPLE_AGE_SECONDS or \ (self.last_sample_time is not None and sample.mono_time <= self.last_sample_time): last_sample_time = self.last_sample_time diff --git a/openpilot/starpilot/sentry_mode/runtime.py b/openpilot/starpilot/sentry_mode/runtime.py index 5b7abb3138..4d0bf949ac 100644 --- a/openpilot/starpilot/sentry_mode/runtime.py +++ b/openpilot/starpilot/sentry_mode/runtime.py @@ -157,7 +157,10 @@ class SentryRuntime: self.saved_raw = saved.raw offroad, ignition_off, voltage_ok, fresh, now_ns = self._authority() sample = self._sample(now_ns) if active else None - decision = self.policy.update(now_ns / 1e9, Inputs(active, offroad, voltage_ok, fresh, ignition_off, sample)) + sm = self.messages + sample_pending = bool(not sm.updated["accelerometer"] and sm.seen["accelerometer"] and + sm.alive["accelerometer"] and sm.valid["accelerometer"]) + decision = self.policy.update(now_ns / 1e9, Inputs(active, offroad, voltage_ok, fresh, ignition_off, sample, sample_pending)) expected_raw = saved.raw if decision.event is not None and self.record is not None and expected_raw is not None and self._permitted(expected_raw): try: diff --git a/openpilot/starpilot/sentry_mode/tests/test_policy.py b/openpilot/starpilot/sentry_mode/tests/test_policy.py index a689ccedab..5c7dac4b4b 100644 --- a/openpilot/starpilot/sentry_mode/tests/test_policy.py +++ b/openpilot/starpilot/sentry_mode/tests/test_policy.py @@ -74,6 +74,22 @@ def test_missing_sensor_during_arming_and_after_armed_requires_full_rearm(): assert policy.update(180.5, active(motion(180.5, 9.8))).state == "arming" +def test_pending_messages_preserve_countdown_but_never_count_as_motion(): + policy = SentryPolicy() + pending = Inputs(True, True, True, True, True, sample_pending=True) + assert policy.update(0.0, pending).state == "sensor_unavailable" + for tenth in range(1, 905): + now = tenth / 10 + decision = policy.update(now, active(motion(now, 9.8)) if tenth % 2 else pending) + assert decision.state == ("arming" if now < 90.1 else "armed") + assert decision.event is None + assert policy.trigger_count == 0 + # The previous sample is 90.3; repeated polls cannot renew its freshness. + assert policy.update(90.7, pending).state == "armed" + assert policy.update(90.9, pending).state == "sensor_unavailable" + assert policy.update(91.0, active(motion(91.0, 9.8))).seconds_remaining == 90 + + def test_disabled_onroad_low_voltage_and_lost_device_rearm_for_full_90_seconds(): for stopped, expected in ((Inputs(False, True, True, True, True), "disabled"), (Inputs(True, False, True, True, True), "disabled_onroad"), diff --git a/openpilot/starpilot/sentry_mode/tests/test_runtime.py b/openpilot/starpilot/sentry_mode/tests/test_runtime.py index a3995ce2ba..f6d8ff1d3b 100644 --- a/openpilot/starpilot/sentry_mode/tests/test_runtime.py +++ b/openpilot/starpilot/sentry_mode/tests/test_runtime.py @@ -90,6 +90,7 @@ class TestRuntime(unittest.TestCase): status.publish.assert_called_once_with(decision, encode(Preferences(True)), "idle") self.assertEqual(decision.state, "arming") self.messages.updated["accelerometer"] = False + self.messages.valid["accelerometer"] = False self.clock.mono += 100_000_000 decision = self.runtime.tick() self.assertEqual(decision.state, "sensor_unavailable") @@ -101,10 +102,22 @@ class TestRuntime(unittest.TestCase): self.assertIsNotNone(self.runtime.policy.last_sample_time) self.messages.updated["accelerometer"] = False self.clock.mono += 100_000_000 + self.assertEqual(self.runtime.tick().state, "arming") + self.clock.mono += 400_000_000 self.assertEqual(self.runtime.tick().state, "sensor_unavailable") self.assertIsNone(self.runtime.policy.arm_started) self.assertEqual(self.events, []) + def test_arming_completes_with_brief_gaps_between_sensor_messages(self): + for _ in range(451): + self.tick() + self.messages.updated["accelerometer"] = False + self.clock.mono += 100_000_000 + decision = self.runtime.tick() + self.assertIn(decision.state, ("arming", "armed")) + self.assertEqual(decision.state, "armed") + self.assertEqual(self.events, []) + def test_serialized_device_panda_peripheral_use_actual_clock_domains(self): self.clock.mono += 100_000_000