Reuse the UI carState reader for standby button wake

This commit is contained in:
AngusBell97
2026-09-15 18:05:01 +01:00
committed by firestar5683
parent 7ff3682aba
commit 68b75fc51e
7 changed files with 212 additions and 180 deletions
+23 -4
View File
@@ -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),
)
@@ -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,
+13 -1
View File
@@ -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:
@@ -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)
+2 -2
View File
@@ -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 0100 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 0100 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.
@@ -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
@@ -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)