Files
StarPilot/openpilot/starpilot/galaxy/tests/test_device_state.py
T

255 lines
12 KiB
Python

import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
from openpilot.starpilot.galaxy.device_state import DeviceStateSource, TTL_NS
from openpilot.starpilot.parked_evidence import RESUME_SKEW_NS, host_parked
class Clock:
def __init__(self):
self.now = 10_000_000_000
self.offset = 20_000_000_000
self.sampling_delay = 0
def mono(self):
return self.now
def boot(self):
self.now += self.sampling_delay
return self.now + self.offset
class Messages:
def __init__(self):
self.updated = dict.fromkeys(('deviceState', 'pandaStates'), False)
self.seen = dict(self.updated)
self.valid = dict(self.updated)
self.logMonoTime: dict[str, int | float] = dict.fromkeys(self.updated, 0)
self.recv_time = dict(self.logMonoTime)
self.values = {'deviceState': SimpleNamespace(started=False), 'pandaStates': []}
self.timeouts = []
self.reads = []
self.sock = {'deviceState': SimpleNamespace(close=Mock()), 'pandaStates': SimpleNamespace(close=Mock())}
def update(self, timeout):
self.timeouts.append(timeout)
def __getitem__(self, key):
self.reads.append(key)
return self.values[key]
def publish(self, clock, *, started=False, ignition_line=False, ignition_can=False):
self.updated = dict.fromkeys(self.updated, True)
self.seen = dict.fromkeys(self.updated, True)
self.valid = dict.fromkeys(self.updated, True)
self.logMonoTime: dict[str, int | float] = {'deviceState': clock.now - 250_000_000, 'pandaStates': clock.now + clock.offset - 100_000_000}
self.recv_time = dict.fromkeys(self.updated, (clock.now - 125_000_000) / 1e9)
self.values = {'deviceState': SimpleNamespace(started=started),
'pandaStates': [SimpleNamespace(ignitionLine=ignition_line, ignitionCan=ignition_can)]}
class DeviceStateTest(unittest.TestCase):
def setUp(self):
self.clock = Clock()
self.messages = Messages()
self.source = DeviceStateSource(self.messages, mono=self.clock.mono, boot=self.clock.boot)
self.clock.now = 11_000_000_000
self.messages.publish(self.clock)
self.addCleanup(self.source.close)
def test_parked_driving_and_standby_are_display_only_and_nonblocking(self):
for started, line, can, expected in ((False, False, False, 'parked'), (True, True, False, 'driving'),
(False, True, False, 'standby'), (False, False, True, 'standby'),
(True, False, False, 'driving')):
with self.subTest(expected=expected, started=started, line=line, can=can):
self.messages.publish(self.clock, started=started, ignition_line=line, ignition_can=can)
self.assertEqual(self.source.sample(), {'state': expected, 'maxAgeMs': 2750})
self.assertEqual(self.messages.timeouts, [0] * 5)
self.assertEqual(set(self.messages.reads), {'deviceState', 'pandaStates'})
def test_startup_requires_a_post_subscription_device_update(self):
self.messages.logMonoTime['deviceState'] = 9_999_999_999
self.assertEqual(self.source.sample(), {'state': None, 'maxAgeMs': 0})
self.messages.publish(self.clock)
self.messages.updated['deviceState'] = False
self.assertIsNone(self.source.sample()['state'])
self.messages.updated['deviceState'] = True
self.assertEqual(self.source.sample()['state'], 'parked')
def test_missing_invalid_or_empty_sources_never_invent_a_state(self):
for collection in ('seen', 'valid'):
for service in ('deviceState', 'pandaStates'):
with self.subTest(collection=collection, service=service):
self.messages.publish(self.clock)
getattr(self.messages, collection)[service] = False
self.assertIsNone(self.source.sample()['state'])
self.messages.publish(self.clock)
self.messages.values['pandaStates'] = []
self.assertIsNone(self.source.sample()['state'])
def test_source_and_receive_times_reject_stale_and_future_values(self):
self.assertEqual(self.source.sample()['state'], 'parked')
self.clock.now += TTL_NS + 1_000_000_000
for service in ('deviceState', 'pandaStates'):
for field in ('logMonoTime', 'recv_time'):
for delta in (-TTL_NS, 1_000_000):
with self.subTest(service=service, field=field, delta=delta):
self.messages.publish(self.clock)
now = self.clock.now + (self.clock.offset if field == 'logMonoTime' and service == 'pandaStates' else 0)
getattr(self.messages, field)[service] = (now + delta) / (1e9 if field == 'recv_time' else 1)
self.assertEqual(self.source.sample(), {'state': None, 'maxAgeMs': 0})
def test_suspend_invalidates_cached_device_until_a_new_update(self):
self.assertEqual(self.source.sample()['state'], 'parked')
self.clock.offset += 60_000_000_000
self.clock.now += 100_000_000
self.messages.updated['deviceState'] = False
self.assertIsNone(self.source.sample()['state'])
self.clock.now += 100_000_000
self.messages.logMonoTime['pandaStates'] = self.clock.now + self.clock.offset
self.messages.recv_time['pandaStates'] = self.clock.now / 1e9
self.assertIsNone(self.source.sample()['state'])
self.clock.now += 500_000_000
self.messages.publish(self.clock, ignition_can=True)
self.assertEqual(self.source.sample()['state'], 'standby')
def test_unstable_clock_sample_and_failed_message_read_return_unknown(self):
self.clock.sampling_delay = RESUME_SKEW_NS + 1
self.assertIsNone(self.source.sample()['state'])
self.clock.sampling_delay = 0
self.messages.update = Mock(side_effect=OSError('subscription unavailable'))
self.assertEqual(self.source.sample(), {'state': None, 'maxAgeMs': 0})
self.messages.update.assert_called_once_with(0)
def test_close_releases_each_subscription_once(self):
self.source.close()
self.source.close()
for socket in self.messages.sock.values():
socket.close.assert_called_once_with()
with patch('builtins.__import__', side_effect=AssertionError('Closed source reopened a subscription')):
self.assertEqual(self.source.sample(), {'state': None, 'maxAgeMs': 0})
class SharedDeviceStateTest(unittest.TestCase):
def setUp(self):
from openpilot.starpilot.galaxy.evidence import EvidenceSource
self.clock = Clock()
self.messages = SharedMessages()
self.messages.alive = dict(self.messages.seen)
# The shared collector includes the other physical-admission services;
# this display adapter consumes only deviceState and pandaStates.
self.messages.values.update(carState=SimpleNamespace(), selfdriveState=SimpleNamespace(), carParams=SimpleNamespace())
self.shared = EvidenceSource(self.messages, mono=self.clock.mono, boot=self.clock.boot)
self.addCleanup(self.shared.close)
def publish(self, **state):
self.clock.now += 100_000_000
self.messages.publish(self.clock, **state)
self.messages.values.update(carState=SimpleNamespace(), selfdriveState=SimpleNamespace(), carParams=SimpleNamespace())
self.messages.alive = dict(self.messages.seen)
self.shared.poll()
def display(self):
result = DeviceStateSource(self.shared, mono=self.clock.mono, boot=self.clock.boot)
self.addCleanup(result.close)
return result
def test_actual_constructor_cold_then_first_idle_read_uses_shared_startup_floor(self):
self.shared.poll()
self.assertIsNone(self.display().sample()['state'])
for _ in range(40):
self.publish()
source = self.display() # Created after the latest publisher, unlike its server-owned collector.
calls = len(self.messages.timeouts)
self.assertEqual(source.sample()['state'], 'parked')
self.assertEqual(len(self.messages.timeouts), calls)
def test_shared_labels_and_display_ttl_remain_unchanged(self):
self.clock.now += 500_000_000
for started, ignition, expected in ((False, False, 'parked'), (False, True, 'standby'), (True, False, 'driving')):
self.publish(started=started, ignition_can=ignition)
source = self.display()
self.assertEqual(source.sample(), {'state': expected, 'maxAgeMs': 2750})
self.clock.now += TTL_NS
self.assertIsNone(source.sample()['state'])
def test_short_resume_before_sampler_poll_rejects_lazy_header_until_new_device(self):
self.clock.now += 500_000_000
self.publish()
self.assertEqual(self.display().sample()['state'], 'parked')
self.clock.offset += 100_000_000
source = self.display()
self.assertIsNone(source.sample()['state'])
self.shared.poll()
self.assertIsNone(source.sample()['state'])
self.clock.now += 500_000_000
self.publish(ignition_line=True)
self.assertEqual(source.sample()['state'], 'standby')
def test_actual_server_factory_borrows_one_collector_for_local_and_remote(self):
import tempfile
from pathlib import Path
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
from openpilot.starpilot.galaxy.remote import RemotePairing
from openpilot.starpilot.galaxy.server import make_remote_server, make_server
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / 'IsOffroad').write_bytes(b'1')
params = SimpleNamespace(get_param_path=lambda key: str(root / key))
with patch('openpilot.starpilot.galaxy.evidence.EvidenceSource', return_value=self.shared) as factory, \
patch('openpilot.common.params.Params', return_value=params):
local = make_server(port=0, owner=GalaxyAccessOwner(root / 'access'), remote_pairing=RemotePairing(root / 'pairing'))
self.addCleanup(local.server_close)
remote = make_remote_server(local, port=0)
self.addCleanup(remote.server_close)
factory.assert_called_once_with()
self.assertIs(local.device_state_source.messages, self.shared)
self.assertIs(local.evidence_source, self.shared)
self.assertTrue(self.shared.thread.is_alive())
remote.server_close()
self.assertTrue(self.shared.thread.is_alive())
local.server_close()
self.assertFalse(self.shared.thread.is_alive())
self.assertIsNone(local.device_state_source.sample()['state'])
def test_header_close_does_not_retire_shared_owner_and_owner_close_returns_unknown(self):
self.clock.now += 500_000_000
self.publish()
source = self.display()
self.assertEqual(source.sample()['state'], 'parked')
source.close()
self.assertIsNone(source.sample()['state'])
for socket in self.messages.sock.values():
socket.close.assert_not_called()
other = self.display()
self.assertEqual(other.sample()['state'], 'parked')
self.shared.close()
self.assertIsNone(other.sample()['state'])
for socket in self.messages.sock.values():
socket.close.assert_called_once_with()
class SharedMessages(Messages):
def __getitem__(self, key):
self.reads.append(key)
return self.values[key]
class TestHostParked(unittest.TestCase):
HOST = {'SP_HOST_PARKED': '1', 'SP_HOST_RUNTIME': '1', 'SP_HOST_PREFIX': 'starpilot-dev-abc',
'OPENPILOT_PREFIX': 'starpilot-dev-abc'}
def test_only_host_launchers_with_runner_prefix_are_parked(self):
self.assertTrue(host_parked(self.HOST))
self.assertFalse(host_parked({}))
for key in ('SP_HOST_PARKED', 'SP_HOST_RUNTIME', 'OPENPILOT_PREFIX'):
self.assertFalse(host_parked({k: v for k, v in self.HOST.items() if k != key}))
self.assertFalse(host_parked(self.HOST | {'OPENPILOT_PREFIX': 'replay-demo'}))
def test_host_header_reports_parked_without_publishers(self):
with patch.dict('os.environ', self.HOST):
self.assertEqual(DeviceStateSource(Messages()).sample(), {'state': 'parked', 'maxAgeMs': TTL_NS // 1_000_000})