mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
e52e1a6a86
Implement Sentry scanning, notification state and worker cancellation through explicit policies and preferences.
216 lines
9.7 KiB
Python
216 lines
9.7 KiB
Python
import os
|
|
import signal
|
|
from contextlib import ExitStack
|
|
from pathlib import Path
|
|
from types import SimpleNamespace as NS
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
from openpilot.cereal import log, messaging
|
|
from openpilot.common.params import Params
|
|
from openpilot.starpilot.sentry_mode.preferences import KEY, Preferences, encode
|
|
from openpilot.starpilot.sentry_mode.runtime import SentryRuntime
|
|
from openpilot.starpilot.sentry_mode import runtime as runtime_module
|
|
|
|
|
|
class Clock:
|
|
def __init__(self):
|
|
self.mono = 1_000_000_000_000
|
|
self.offset = 50_000_000_000
|
|
|
|
def boot(self):
|
|
return self.mono + self.offset
|
|
|
|
|
|
class Messages:
|
|
def __init__(self, clock):
|
|
self.clock = clock
|
|
self.messages = {}
|
|
names = ("deviceState", "pandaStates", "peripheralState", "accelerometer")
|
|
self.seen = dict.fromkeys(names, True)
|
|
self.alive = dict.fromkeys(names, True)
|
|
self.valid = dict.fromkeys(names, True)
|
|
self.updated = dict.fromkeys(names, True)
|
|
self.logMonoTime = dict.fromkeys(names, 0)
|
|
self.recv_time = dict.fromkeys(names, 0.0)
|
|
self.sock = {}
|
|
|
|
def __getitem__(self, name):
|
|
return self.messages[name]
|
|
|
|
def update(self, _timeout):
|
|
pass
|
|
|
|
def set_sources(self, *, sample_stamp=None, acceleration=(0.0, 0.0, 9.81)):
|
|
now = self.clock.mono
|
|
for name in self.logMonoTime:
|
|
self.recv_time[name] = (now - 10_000_000) / 1e9
|
|
self.logMonoTime[name] = now - 10_000_000 if name in ("deviceState", "accelerometer") else self.clock.boot() - 10_000_000
|
|
self.updated[name] = True
|
|
self.messages["deviceState"] = NS(started=False, carBatteryCapacityUwh=1_000_000)
|
|
self.messages["pandaStates"] = [NS(ignitionLine=False, ignitionCan=False, pandaType="uno")]
|
|
self.messages["peripheralState"] = NS(pandaType="uno", voltage=12_500)
|
|
# A real Cap'n Proto event is serialized and decoded before projection.
|
|
event = messaging.new_message("accelerometer", valid=True)
|
|
event.accelerometer.timestamp = sample_stamp if sample_stamp is not None else now - 20_000_000
|
|
event.accelerometer.acceleration.v = list(acceleration)
|
|
return event.to_bytes()
|
|
|
|
|
|
class TestRuntime(unittest.TestCase):
|
|
def setUp(self):
|
|
self.directory = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.directory.cleanup)
|
|
self.environment = patch.dict(os.environ, PARAMS_ROOT=self.directory.name, OPENPILOT_PREFIX="sentryruntime",
|
|
STARPILOT_SENTRY_DEVELOPMENT="1")
|
|
self.environment.start()
|
|
self.addCleanup(self.environment.stop)
|
|
self.params = Params()
|
|
self.params.put_bool("IsOffroad", True, block=True)
|
|
Path(self.params.get_param_path(KEY)).write_bytes(encode(Preferences(True)))
|
|
self.clock = Clock()
|
|
self.messages = Messages(self.clock)
|
|
self.events = []
|
|
self.runtime = SentryRuntime(self.params, self.messages, mono_clock=lambda: self.clock.mono,
|
|
boot_clock=self.clock.boot,
|
|
record=lambda kind, stamp, permitted: self.events.append((kind, stamp, permitted())))
|
|
|
|
def tick(self, *, stamp=None, acceleration=(0.0, 0.0, 9.81)):
|
|
self.clock.mono += 100_000_000
|
|
raw = self.messages.set_sources(sample_stamp=stamp, acceleration=acceleration)
|
|
with log.Event.from_bytes(raw) as parsed:
|
|
self.messages.messages["accelerometer"] = parsed.accelerometer
|
|
return self.runtime.tick()
|
|
|
|
def test_status_reports_actual_policy_and_immediate_sensor_revocation(self):
|
|
status = Mock()
|
|
self.runtime.status = status
|
|
decision = self.tick()
|
|
status.publish.assert_called_once_with(decision, encode(Preferences(True)), "idle")
|
|
self.assertEqual(decision.state, "arming")
|
|
self.messages.updated["accelerometer"] = False
|
|
self.clock.mono += 100_000_000
|
|
decision = self.runtime.tick()
|
|
self.assertEqual(decision.state, "sensor_unavailable")
|
|
self.assertEqual(status.publish.call_args.args[0], decision)
|
|
|
|
def test_serialized_sensor_and_fresh_parked_evidence(self):
|
|
first = self.tick()
|
|
self.assertEqual(first.state, "arming")
|
|
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, "sensor_unavailable")
|
|
self.assertIsNone(self.runtime.policy.arm_started)
|
|
self.assertEqual(self.events, [])
|
|
|
|
|
|
def test_serialized_device_panda_peripheral_use_actual_clock_domains(self):
|
|
self.clock.mono += 100_000_000
|
|
sensor_raw = self.messages.set_sources()
|
|
device = messaging.new_message("deviceState", valid=True)
|
|
device.deviceState.started = False
|
|
device.deviceState.carBatteryCapacityUwh = 1_000_000
|
|
panda = messaging.new_message("pandaStates", 1, valid=True)
|
|
panda.pandaStates[0].pandaType = "uno"
|
|
peripheral = messaging.new_message("peripheralState", valid=True)
|
|
peripheral.peripheralState.pandaType = "uno"
|
|
peripheral.peripheralState.voltage = 12_500
|
|
with ExitStack() as stack:
|
|
readers = [stack.enter_context(log.Event.from_bytes(raw)) for raw in
|
|
(sensor_raw, device.to_bytes(), panda.to_bytes(), peripheral.to_bytes())]
|
|
self.messages.messages.update(accelerometer=readers[0].accelerometer, deviceState=readers[1].deviceState,
|
|
pandaStates=readers[2].pandaStates, peripheralState=readers[3].peripheralState)
|
|
self.assertEqual(self.runtime.tick().state, "arming")
|
|
# A C++ pandad BOOTTIME stamp incorrectly supplied in MONOTONIC units is stale.
|
|
self.messages.logMonoTime["pandaStates"] = self.clock.mono - 10_000_000
|
|
self.assertNotEqual(self.runtime.tick().state, "armed")
|
|
|
|
def test_unknown_ignition_power_and_shutdown_disarm(self):
|
|
self.tick()
|
|
self.messages.messages["pandaStates"][0].ignitionCan = True
|
|
self.assertEqual(self.runtime.tick().state, "disabled_ignition")
|
|
self.messages.messages["pandaStates"][0].ignitionCan = False
|
|
self.messages.messages["peripheralState"].voltage = 11_700
|
|
self.assertEqual(self.runtime.tick().state, "low_voltage")
|
|
self.messages.messages["peripheralState"].voltage = 12_500
|
|
self.params.put_bool("DoShutdown", True, block=True)
|
|
self.assertNotEqual(self.runtime.tick().state, "armed")
|
|
self.assertEqual(self.events, [])
|
|
|
|
def test_resume_barrier_rejects_queued_pre_resume_sensor(self):
|
|
self.tick()
|
|
old_stamp = self.messages.messages["accelerometer"].timestamp
|
|
self.clock.offset += 100_000_000
|
|
self.clock.mono += 100_000_000
|
|
raw = self.messages.set_sources(sample_stamp=old_stamp)
|
|
with log.Event.from_bytes(raw) as parsed:
|
|
self.messages.messages["accelerometer"] = parsed.accelerometer
|
|
self.assertNotEqual(self.runtime.tick().state, "armed")
|
|
self.assertIsNone(self.runtime.policy.arm_started)
|
|
self.assertEqual(self.events, [])
|
|
self.assertEqual(self.tick().state, "arming")
|
|
|
|
def test_revoked_settings_and_source_preserved(self):
|
|
self.assertEqual(self.tick().state, "arming")
|
|
raw = b'{"version":1,"enabled":true,"sensitivity":NaN,"warningTimeSeconds":1}'
|
|
Path(self.params.get_param_path(KEY)).write_bytes(raw)
|
|
self.assertEqual(self.tick().state, "disabled")
|
|
self.assertEqual(Path(self.params.get_param_path(KEY)).read_bytes(), raw)
|
|
self.assertEqual(self.events, [])
|
|
|
|
def test_full_arming_to_warning_records_once_with_fresh_final_authority(self):
|
|
for _ in range(901):
|
|
self.tick()
|
|
self.assertIsNotNone(self.runtime.policy.arm_started)
|
|
for index in range(11):
|
|
value = 10.0 if index % 2 else 9.81
|
|
self.tick(acceleration=(0.0, 0.0, value))
|
|
self.assertEqual(len(self.events), 1)
|
|
self.assertEqual(self.events[0][0], "warning")
|
|
self.assertTrue(self.events[0][2])
|
|
self.assertEqual(self.runtime.recording_state, "durability_unknown") # Stub supplies no durability receipt.
|
|
self.params.put_bool("DoShutdown", True, block=True)
|
|
self.assertNotEqual(self.tick().state, "armed")
|
|
self.assertEqual(len(self.events), 1)
|
|
|
|
def test_store_callback_rechecks_saved_revision_and_sample_age(self):
|
|
self.tick()
|
|
self.runtime.policy.arm_started = self.clock.mono / 1e9 - 90
|
|
self.runtime.policy.last_sample_time = self.clock.mono / 1e9 - 0.02
|
|
original = Path(self.params.get_param_path(KEY)).read_bytes()
|
|
self.assertTrue(self.runtime._permitted(original))
|
|
Path(self.params.get_param_path(KEY)).write_bytes(encode(Preferences(True, self.runtime.policy.settings.__class__(0.05, 1.0))))
|
|
self.assertFalse(self.runtime._permitted(original))
|
|
Path(self.params.get_param_path(KEY)).write_bytes(original)
|
|
self.clock.mono += 600_000_000
|
|
self.assertFalse(self.runtime._permitted(original))
|
|
|
|
|
|
class TestMainShutdown(unittest.TestCase):
|
|
def test_both_signals_close_owned_subscription_and_restore_handlers(self):
|
|
from openpilot.starpilot.sentry_mode import storage
|
|
for requested in (signal.SIGINT, signal.SIGTERM):
|
|
with self.subTest(requested=requested):
|
|
installed = {}
|
|
prior = object()
|
|
close = Mock()
|
|
def install(number, handler, target=installed):
|
|
target[number] = handler
|
|
def tick(which=requested, target=installed):
|
|
target[which](which, None)
|
|
with patch.object(signal, "getsignal", return_value=prior), patch.object(signal, "signal", side_effect=install), \
|
|
patch.object(messaging, "SubMaster"), patch.object(runtime_module, "Params"), \
|
|
patch.object(storage, "EventStore"), patch.object(runtime_module, "SentryRuntime") as owner:
|
|
owner.return_value.tick.side_effect = tick
|
|
owner.return_value.close = close
|
|
runtime_module.main()
|
|
close.assert_called_once()
|
|
self.assertIs(installed[signal.SIGINT], prior)
|
|
self.assertIs(installed[signal.SIGTERM], prior)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|