From 68b75fc51ed159b0087109f7768e78650c1465f3 Mon Sep 17 00:00:00 2001 From: AngusBell97 <124716116+AngusBell97@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:05:01 +0100 Subject: [PATCH] Reuse the UI carState reader for standby button wake --- cereal/messaging/__init__.py | 27 +++- cereal/messaging/tests/test_pub_sub_master.py | 24 +++ selfdrive/ui/ui_state.py | 14 +- .../common/tests/test_screen_message_queue.py | 150 ++++++++++++++++++ starpilot/docs/screen_settings.md | 4 +- .../tests/test_car_state_button_wake.py | 123 -------------- .../system/wheel_controls/wheel_controlsd.py | 50 ------ 7 files changed, 212 insertions(+), 180 deletions(-) create mode 100644 starpilot/common/tests/test_screen_message_queue.py delete mode 100644 starpilot/system/wheel_controls/tests/test_car_state_button_wake.py diff --git a/cereal/messaging/__init__.py b/cereal/messaging/__init__.py index a00c6c7f49..d314216821 100644 --- a/cereal/messaging/__init__.py +++ b/cereal/messaging/__init__.py @@ -152,7 +152,8 @@ class FrequencyTracker: class SubMaster: def __init__(self, services: List[str], poll: Optional[str] = None, ignore_alive: Optional[List[str]] = None, ignore_avg_freq: Optional[List[str]] = None, - ignore_valid: Optional[List[str]] = None, addr: str = "127.0.0.1", frequency: Optional[float] = None): + ignore_valid: Optional[list[str]] = None, addr: str = "127.0.0.1", frequency: Optional[float] = None, + drain_services: list[str] | None = None): self.frame = -1 self.services = services self.seen = {s: False for s in services} @@ -160,6 +161,9 @@ class SubMaster: self.recv_time = {s: 0. for s in services} self.recv_frame = {s: 0 for s in services} self.sock = {} + self.drained = {s: [] for s in (drain_services or [])} + if not self.drained.keys() <= set(services): + raise ValueError("Drained services must be subscribed") self.data = {} self.logMonoTime = {s: 0 for s in services} @@ -187,7 +191,7 @@ class SubMaster: for s in services: p = self.poller if s not in self.non_polled_services else None - self.sock[s] = sub_sock(s, poller=p, addr=addr, conflate=True) + self.sock[s] = sub_sock(s, poller=p, addr=addr, conflate=s not in self.drained) try: data = new_message(s) @@ -207,14 +211,28 @@ class SubMaster: def _check_avg_freq(self, s: str) -> bool: return SERVICE_LIST[s].frequency > 0.99 and (s not in self.ignore_average_freq) and (s not in self.ignore_alive) + def _recv_socket(self, sock): + message = recv_one_or_none(sock) + if not self.drained or message is None: + return message + # Native Poller returns fresh socket wrappers; identify the service by data. + service = message.which() + if service not in self.drained: + return message + # Preserve event edges for observers, but update state/frequency only once. + self.drained[service] = [message, *drain_sock(sock)] + return self.drained[service][-1] + def update(self, timeout: int = 100) -> None: + for service in self.drained: + self.drained[service] = [] msgs = [] for sock in self.poller.poll(timeout): - msgs.append(recv_one_or_none(sock)) + msgs.append(self._recv_socket(sock)) # non-blocking receive for non-polled sockets for s in self.non_polled_services: - msgs.append(recv_one_or_none(self.sock[s])) + msgs.append(self._recv_socket(self.sock[s])) self.update_msgs(time.monotonic(), msgs) def update_msgs(self, cur_time: float, msgs: List[capnp.lib.capnp._DynamicStructReader]) -> None: @@ -262,6 +280,7 @@ class SubMaster: ignore_valid=self.ignore_valid, addr=self.addr, frequency=None if self.poll is not None else self.update_freq, + drain_services=list(self.drained), ) diff --git a/cereal/messaging/tests/test_pub_sub_master.py b/cereal/messaging/tests/test_pub_sub_master.py index 5e26b49701..b46a058639 100644 --- a/cereal/messaging/tests/test_pub_sub_master.py +++ b/cereal/messaging/tests/test_pub_sub_master.py @@ -1,5 +1,6 @@ import random import time +import pytest from typing import Sized, cast import cereal.messaging as messaging @@ -16,6 +17,29 @@ class TestSubMaster: # sleep to prevent multiple publishers error between tests zmq_sleep(3) + @pytest.mark.parametrize("poll", [None, "deviceState"]) + def test_drain_preserves_short_events_with_native_socket_wrappers(self, poll): + pub = messaging.PubMaster(["carState", "deviceState"]) + sm = messaging.SubMaster(["carState", "deviceState"], poll=poll, drain_services=["carState"]) + zmq_sleep() + pressed = messaging.new_message("carState", valid=True) + button = pressed.carState.init("buttonEvents", 1)[0] + button.type, button.pressed = "accelCruise", True + pub.send("carState", pressed) + latest = messaging.new_message("carState", valid=True) + latest.carState.vEgo = 12.0 + pub.send("carState", latest) + pub.send("deviceState", messaging.new_message("deviceState", valid=True)) + sm.update(1000) + assert len(sm.drained["carState"]) == 2 + assert sm.drained["carState"][0].carState.buttonEvents[0].pressed + assert sm["carState"].vEgo == 12.0 and not sm["carState"].buttonEvents + assert sm.logMonoTime["carState"] == latest.logMonoTime + assert sm.frame == 0 and all(sm.updated.values()) + sm.update(0) + assert sm.drained["carState"] == [] + assert sm.frame == 1 and not any(sm.updated.values()) + def test_init(self): sm = messaging.SubMaster(events) for p in [sm.updated, sm.recv_time, sm.recv_frame, sm.alive, diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index e04c6ca47d..3bcfb1007a 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -75,7 +75,8 @@ class UIState: "liveTracks", "liveDelay", "liveTorqueParameters", - ] + ], + drain_services=["carState"], ) self.prime_state = PrimeState() @@ -305,6 +306,7 @@ class Device: def __init__(self): self._ignition = False self._last_button_press = standby_button_press_time(ui_state.params_memory) + self._last_car_button_frame = int(time.monotonic() * 1e9) self._last_turn_signal = None self._interaction_time: float = -1 self._override_interactive_timeout: int | None = None @@ -506,6 +508,16 @@ class Device: button_pressed = button_time > self._last_button_press and 0 <= time.monotonic() - button_time / 1e9 < 2 self._last_button_press = button_time events = {"button"} if button_pressed else set() + # Reuse the UI reader: another carState subscriber can exhaust msgq slots. + frames = getattr(ui_state.sm, "drained", {}).get("carState", []) if ui_state.started else [] + now_ns = int(time.monotonic() * 1e9) + for message in frames: + timestamp = int(message.logMonoTime) + if not message.valid or not 0 <= now_ns - timestamp < 2_000_000_000 or timestamp <= self._last_car_button_frame: + continue + self._last_car_button_frame = timestamp + if any(event.pressed and str(event.type) not in ("unknown", "0") for event in message.carState.buttonEvents): + events.add("button") car_state = self._fresh_message("carState") if ui_state.started else None turn_signal = (int(car_state.leftBlinker) | (int(car_state.rightBlinker) << 1)) if car_state is not None else None if self._last_turn_signal is not None and turn_signal and turn_signal != self._last_turn_signal: diff --git a/starpilot/common/tests/test_screen_message_queue.py b/starpilot/common/tests/test_screen_message_queue.py new file mode 100644 index 0000000000..600d5b3e58 --- /dev/null +++ b/starpilot/common/tests/test_screen_message_queue.py @@ -0,0 +1,150 @@ +"""Keep every button edge without adding a messaging reader or UI update frame.""" +import ast +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Optional + +import capnp +import pytest +from cereal import log +from cereal.services import SERVICE_LIST + +from test_screen_device_runtime import make_device + +ROOT = Path(__file__).resolve().parents[3] + + +def packet(timestamp, pressed=None, *, valid=True, button_type='accelCruise'): + message = log.Event.new_message(logMonoTime=timestamp, valid=valid) + state = message.init('carState') + if pressed is not None: + buttons = state.init('buttonEvents', 1) + buttons[0].type, buttons[0].pressed = button_type, pressed + return message + + +def submaster_fixture(): + sockets = [] + + class Poller: + def __init__(self): + self.sockets = [] + + def poll(self, timeout): + # Native Poller returns new wrapper objects around the same C++ socket. + return [SocketView(sock) for sock in self.sockets if sock.queue] + + class SocketView: + def __init__(self, sock): + self.sock = sock + + def __getattr__(self, name): + return getattr(self.sock, name) + + class Socket: + def __init__(self, service, poller=None, conflate=False, **kwargs): + self.service, self.conflate, self.queue = service, conflate, [] + sockets.append(self) + if poller is not None: + poller.sockets.append(self) + + class FrequencyTracker: + def __init__(self, *args): + self.times, self.valid = [], True + + def record_recv_time(self, now): + self.times.append(now) + + def receive(sock): + if not sock.queue: + return None + if sock.conflate: + message, sock.queue[:] = sock.queue[-1], [] + return message + return sock.queue.pop(0) + + def drain(sock): + messages, sock.queue[:] = list(sock.queue), [] + return messages + + env = dict(List=list, Optional=Optional, Dict=dict, capnp=capnp, log=log, os=os, SERVICE_LIST=SERVICE_LIST, + time=SimpleNamespace(monotonic=lambda: 100.0), Poller=Poller, sub_sock=Socket, + FrequencyTracker=FrequencyTracker, recv_one_or_none=receive, drain_sock=drain) + source = ROOT / 'cereal/messaging/__init__.py' + nodes = [node for node in ast.parse(source.read_text()).body + if isinstance(node, (ast.ClassDef, ast.FunctionDef)) and node.name in ('SubMaster', 'new_message')] + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), env) + return env['SubMaster'], sockets + + +def test_default_submaster_keeps_existing_conflation(): + cls, sockets = submaster_fixture() + sm = cls(['carState', 'deviceState']) + assert len(sockets) == 2 and all(sock.conflate for sock in sockets) + sockets[0].queue[:] = [packet(1, True), packet(2)] + sm.update(0) + assert sm.logMonoTime['carState'] == 2 and sm.frame == 0 + assert not sm['carState'].buttonEvents + + +@pytest.mark.parametrize('poll', [None, 'deviceState']) +def test_selected_reader_drains_edges_and_updates_latest_state_once(poll): + cls, sockets = submaster_fixture() + sm = cls(['carState', 'deviceState'], poll=poll, drain_services=['carState']) + assert len(sockets) == 2 + assert sockets[0].conflate is False and sockets[1].conflate is True + sockets[0].queue[:] = [packet(99_500_000_000, True), packet(99_600_000_000)] + other = log.Event.new_message(logMonoTime=99_600_000_000, valid=True) + other.init('deviceState') + sockets[1].queue[:] = [other] + sm.update(0) + assert len(sm.drained['carState']) == 2 and sm.drained['carState'][0].carState.buttonEvents[0].pressed + assert sm.logMonoTime['carState'] == 99_600_000_000 and not sm['carState'].buttonEvents + assert sm.frame == 0 and all(sm.updated.values()) + assert sm.freq_tracker['carState'].times == sm.freq_tracker['deviceState'].times == [100.0] + sm.update(0) + assert sm.drained['carState'] == [] and not any(sm.updated.values()) and sm.frame == 1 + extended = sm.extend(['modelV2']) + assert set(extended.drained) == {'carState'} + assert extended.sock['carState'].conflate is False + + +@pytest.mark.parametrize('enabled', [False, True]) +@pytest.mark.parametrize('brightness', [0, 101]) +def test_ui_uses_queued_button_press_once_with_optional_wake(enabled, brightness): + device, state, _ = make_device(StandbyWakeButton=enabled, ScreenBrightnessOnroad=brightness) + device._last_car_button_frame = 99_000_000_000 + state.sm.drained = {'carState': [packet(99_500_000_000, True), packet(99_600_000_000)]} + device._update_wakefulness() + assert device.awake is enabled + device._interaction_time = 90 + device._update_wakefulness() + assert not device.awake + assert state.params_memory.get('StandbyButtonPressTime') is None + + +def test_ui_rejects_invalid_unknown_release_future_stale_and_reordered_buttons(): + device, state, _ = make_device(StandbyWakeButton=True) + device._last_car_button_frame = 97_000_000_000 + state.sm.drained = {'carState': [packet(97_500_000_000, True), packet(99_100_000_000, False), + packet(99_200_000_000, True, valid=False), packet(99_300_000_000, True, button_type='unknown'), + packet(100_100_000_000, True)]} + device._update_wakefulness() + assert not device.awake + state.sm.drained = {'carState': [packet(99_800_000_000, True)]} + device._update_wakefulness() + assert device.awake + device._interaction_time = 90 + state.sm.drained = {'carState': [packet(99_700_000_000, True)]} + device._update_wakefulness() + assert not device.awake + + +def test_vehicle_button_wake_adds_no_daemon_reader(): + source = (ROOT / 'starpilot/system/wheel_controls/wheel_controlsd.py').read_text() + assert 'sub_sock(' not in source + ui = ast.parse((ROOT / 'selfdrive/ui/ui_state.py').read_text()) + calls = [node for node in ast.walk(ui) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == 'SubMaster'] + assert len(calls) == 1 + assert any(kw.arg == 'drain_services' and ast.literal_eval(kw.value) == ['carState'] for kw in calls[0].keywords) diff --git a/starpilot/docs/screen_settings.md b/starpilot/docs/screen_settings.md index 652af21a42..0b5fb68101 100644 --- a/starpilot/docs/screen_settings.md +++ b/starpilot/docs/screen_settings.md @@ -32,11 +32,11 @@ Engagement and alert detection use Dom's existing status and alert predicates. E Dom's ignition transitions, screen-setting changes and page timeout handling are retained. There are no gear, brake-pedal or accelerator wake triggers. Standby powers the display down after the timeout using Dom's existing display-power path. -Vehicle buttons use the car interface's existing decoded `carState.buttonEvents`. The listener drains every message so short presses survive between UI refreshes. Controller buttons use the existing input-device reader. When button wake is enabled, fresh presses wake once; releases, key repeat and held buttons do not keep extending the timer. Mapped actions retain their separate enable setting and continue to work normally. Button coverage depends on what the existing vehicle interface and supported input devices expose; this PR introduces no manufacturer-specific CAN decoding. +Vehicle buttons use the car interface's existing decoded `carState.buttonEvents`. The existing UI subscriber drains every message so short presses survive between UI refreshes, while UI state and frequency tracking receive only the latest frame. No additional vehicle-message reader is opened. Controller buttons use the existing input-device reader. When button wake is enabled, fresh presses wake once; releases, key repeat and held buttons do not keep extending the timer. Mapped actions retain their separate enable setting and continue to work normally. Button coverage depends on what the existing vehicle interface and supported input devices expose; this PR introduces no manufacturer-specific CAN decoding. ## Persistence and compatibility -The existing brightness keys retain 101 as Auto and 0–100 as Manual. Four additional persistent integers store manual memory and relative offsets. Seven persistent booleans store wake selections. StandbyButtonPressTime carries fresh button timestamps in RAM, clears on manager start, and is excluded from logging. +The existing brightness keys retain 101 as Auto and 0–100 as Manual. Four additional persistent integers store manual memory and relative offsets. Seven persistent booleans store wake selections. StandbyButtonPressTime carries fresh external-controller button timestamps in RAM, clears on manager start, and is excluded from logging. Native UI and Galaxy writes use one shared validator and an advisory nonblocking file lock outside the Params key directory. Snapshot, write, readback and rollback run within that transaction; UI caches invalidate inside and after it. A busy or failed save is reported and can be retried. Other direct Params writers must use the shared helper to participate in this transaction contract. diff --git a/starpilot/system/wheel_controls/tests/test_car_state_button_wake.py b/starpilot/system/wheel_controls/tests/test_car_state_button_wake.py deleted file mode 100644 index b469a0af48..0000000000 --- a/starpilot/system/wheel_controls/tests/test_car_state_button_wake.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Preserve short carState button edges between UI refreshes, without vehicle actions.""" -import ast -import sys -from pathlib import Path -from typing import Optional -from types import SimpleNamespace - -import capnp -import pytest -from cereal import log - -from openpilot.starpilot.system.wheel_controls import wheel_controlsd -from test_wheel_controlsd import FakeParams - - -def packet(timestamp, pressed=None, *, valid=True, button_type='accelCruise'): - message = log.Event.new_message(logMonoTime=timestamp, valid=valid) - state = message.init('carState') - if pressed is not None: - buttons = state.init('buttonEvents', 1) - buttons[0].type, buttons[0].pressed = button_type, pressed - return message - - -@pytest.fixture -def car_buttons(monkeypatch): - params = FakeParams({'ScreenManagement': True, 'StandbyMode': True, 'StandbyWakeButton': True, 'IsOnroad': True}) - memory = FakeParams() - daemon = wheel_controlsd.WheelControlsDaemon(params, memory) - queued, subscriptions = [], [] - now_boot, now_mono = [10_000_000_000], [1_000_000_000] - - def subscribe(endpoint, *, conflate=False): - assert endpoint == 'carState' - assert conflate is False, 'Every published button edge must be read' - sock = object() - subscriptions.append(sock) - return sock - - def drain(sock, wait_for_one=False): - assert wait_for_one is False - assert sock is subscriptions[-1] - result, queued[:] = list(queued), [] - return result - - import cereal - messaging = SimpleNamespace(sub_sock=subscribe, drain_sock=drain) - monkeypatch.setitem(sys.modules, 'cereal.messaging', messaging) - monkeypatch.setattr(cereal, 'messaging', messaging, raising=False) - monkeypatch.setattr(wheel_controlsd.time, 'clock_gettime_ns', lambda _clock: now_boot[0]) - monkeypatch.setattr(wheel_controlsd.time, 'monotonic_ns', lambda: now_mono[0]) - yield daemon, params, memory, queued, subscriptions, now_boot, now_mono - daemon.close() - - -def test_short_button_edge_survives_later_empty_frame_and_is_consumed_once(car_buttons): - daemon, _params, memory, queued, subscriptions, _boot, now = car_buttons - daemon._configure_car_buttons() - now[0] += 100_000_000 - queued[:] = [packet(1_020_000_000, True), packet(1_030_000_000)] - daemon._poll_car_buttons() - assert memory.get_int('StandbyButtonPressTime') == 1_020_000_000 - daemon._poll_car_buttons() - assert memory.get_int('StandbyButtonPressTime') == 1_020_000_000 - assert len(subscriptions) == 1 - - -@pytest.mark.parametrize('disabled', ['ScreenManagement', 'StandbyMode', 'StandbyWakeButton', 'IsOnroad']) -def test_subscription_only_runs_when_needed_and_reopens_cleanly(car_buttons, disabled): - daemon, params, memory, queued, subscriptions, _boot, now = car_buttons - params.put_bool(disabled, False) - daemon._configure_car_buttons() - assert subscriptions == [] - params.put_bool(disabled, True) - daemon._configure_car_buttons() - assert len(subscriptions) == 1 - params.put_bool(disabled, False) - daemon._configure_car_buttons() - daemon._poll_car_buttons() - assert memory.get('StandbyButtonPressTime') is None - now[0] += 10_000_000 - params.put_bool(disabled, True) - daemon._configure_car_buttons() - queued[:] = [packet(1_005_000_000, True)] # Retained message predates re-enable. - daemon._poll_car_buttons() - assert memory.get('StandbyButtonPressTime') is None - assert len(subscriptions) == 2 - - -def test_releases_invalid_unknown_future_and_stale_packets_cannot_wake(car_buttons): - daemon, _params, memory, queued, _subscriptions, _boot, now = car_buttons - daemon._configure_car_buttons() - now[0] = 4_000_000_000 - queued[:] = [packet(1_500_000_000, True), packet(3_000_000_000, False), packet(3_100_000_000, True, valid=False), - packet(3_200_000_000, True, button_type='unknown'), packet(4_100_000_000, True)] - daemon._poll_car_buttons() - assert memory.get('StandbyButtonPressTime') is None - queued[:] = [packet(3_900_000_000, True)] - daemon._poll_car_buttons() - assert memory.get_int('StandbyButtonPressTime') == 3_900_000_000 - queued[:] = [packet(3_800_000_000, True), packet(3_900_000_000, True)] - daemon._poll_car_buttons() - assert memory.get_int('StandbyButtonPressTime') == 3_900_000_000 - - -def test_python_car_state_clock_stays_monotonic_after_suspend(car_buttons): - daemon, _params, memory, queued, _subscriptions, now_boot, now_mono = car_buttons - # Execute the actual factory used by card.py, without opening native IPC. - source = Path(__file__).resolve().parents[4] / 'cereal/messaging/__init__.py' - factory = next(node for node in ast.parse(source.read_text()).body if isinstance(node, ast.FunctionDef) and node.name == 'new_message') - env = dict(log=log, Optional=Optional, capnp=capnp, time=SimpleNamespace(monotonic=lambda: now_mono[0] / 1e9)) - exec(compile(ast.Module(body=[factory], type_ignores=[]), str(source), 'exec'), env) - now_boot[0] = 10_000_000_000 - daemon._configure_car_buttons() - now_boot[0] = 20_000_000_000 - now_mono[0] = 4_500_000_000 - message = env['new_message']('carState', valid=True) - button = message.carState.init('buttonEvents', 1)[0] - button.type, button.pressed = 'accelCruise', True - queued[:] = [message] - now_mono[0] = 5_000_000_000 - daemon._poll_car_buttons() - assert memory.get_int('StandbyButtonPressTime') == 4_500_000_000 diff --git a/starpilot/system/wheel_controls/wheel_controlsd.py b/starpilot/system/wheel_controls/wheel_controlsd.py index e5a3c1192e..96ed4ab6e0 100644 --- a/starpilot/system/wheel_controls/wheel_controlsd.py +++ b/starpilot/system/wheel_controls/wheel_controlsd.py @@ -513,12 +513,8 @@ class WheelControlsDaemon: self.last_tested: dict[str, Any] | None = None self.last_scan = 0.0 self.last_status = 0.0 - self._car_state_sock = None - self._car_state_messaging = None - self._last_car_button_frame = 0 def close(self) -> None: - self._close_car_buttons() for fd in list(self.sources): self._remove(fd) self.selector.close() @@ -671,50 +667,6 @@ class WheelControlsDaemon: # A display notification must not interrupt existing controller actions. cloudlog.exception("wheel controls: screen wake notification failed") - def _close_car_buttons(self) -> None: - self._car_state_sock = None - self._car_state_messaging = None - self._last_car_button_frame = 0 - - def _configure_car_buttons(self) -> None: - if not all(self.params.get_bool(key) for key in ("ScreenManagement", "StandbyMode", "StandbyWakeButton", "IsOnroad")): - self._close_car_buttons() - return - if self._car_state_sock is not None: - return - try: - from cereal import messaging - - # UI SubMaster conflates frames and can discard one-frame button events. - self._car_state_sock = messaging.sub_sock("carState", conflate=False) - self._car_state_messaging = messaging - self._last_car_button_frame = time.monotonic_ns() - except Exception: - self._close_car_buttons() - cloudlog.exception("wheel controls: car button observer unavailable") - - def _poll_car_buttons(self) -> None: - if self._car_state_sock is None: - return - try: - messages = self._car_state_messaging.drain_sock(self._car_state_sock, wait_for_one=False) - # card publishes with Python messaging.new_message: already CLOCK_MONOTONIC. - now_ns = time.monotonic_ns() - pressed_at = 0 - for message in messages: - timestamp = int(message.logMonoTime) - if not message.valid or not 0 <= now_ns - timestamp < 2_000_000_000 or timestamp <= self._last_car_button_frame: - continue - self._last_car_button_frame = timestamp - if any(event.pressed and str(event.type) not in ("unknown", "0") for event in message.carState.buttonEvents): - pressed_at = timestamp - if pressed_at: - self._publish_button_press(pressed_at) - except Exception: - self._close_car_buttons() - cloudlog.exception("wheel controls: car button read failed") - - def _publish_status(self, now: float) -> None: remaining = max(0, round(self.learning_deadline - now, 1)) if self.learning_slot is not None else 0 status = { @@ -737,14 +689,12 @@ class WheelControlsDaemon: self._update_testing() if now - self.last_scan >= DEVICE_SCAN_INTERVAL_SECONDS: self._scan_devices() - self._configure_car_buttons() self.last_scan = now for key, _mask in self.selector.select(timeout=0.1): try: self._read_events(key.fd) except (KeyError, OSError): self._remove(key.fd) - self._poll_car_buttons() now = time.monotonic() if now - self.last_status >= STATUS_INTERVAL_SECONDS: self._publish_status(now)