Files

346 lines
16 KiB
Python

import hashlib
import re
import secrets
from dataclasses import dataclass
from functools import lru_cache
from opendbc.car import structs
from openpilot.starpilot.conditional_mode.manual import Button, ButtonTracker, Press, ioniq6_media_eligible
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.conditional_mode.ui_action import fresh_service
ACTIONS = {
"Off": 0, "Cycle personality": 1, "Force coast": 2, "Pause steering": 3, "Pause longitudinal": 4,
"Experimental override": 5, "Traffic mode": 6, "Switchback Mode": 7, "Bookmark": 8,
"Toggle AOL": 9, "Adopt speed limit": 10, "Quick Select 1": 11, "Quick Select 2": 12,
"Quick Select 3": 13, "Pulse and glide": 14,
}
KEYS = ("LKASButtonControl", "MainCruiseButtonControl", "DistanceButtonControl", "LongDistanceButtonControl",
"VeryLongDistanceButtonControl", "CancelButtonControl", "LongCancelButtonControl", "VeryLongCancelButtonControl",
"ModeButtonControl", "LongModeButtonControl", "VeryLongModeButtonControl", "StarButtonControl",
"LongStarButtonControl", "VeryLongStarButtonControl", "LeftPaddleButtonControl")
PADDLE_KEY = "LeftPaddleButtonControl"
LIFETIME_NS = 250_000_000
SESSION = re.compile(r"[0-9a-f]{32}\Z")
DELIVERED_ACTIONS = frozenset((1, 2, 5, 6, 7, 8, 10, 11, 12, 13, 14))
DEFAULTS = {"DistanceButtonControl": 1, "LongDistanceButtonControl": 5, "VeryLongDistanceButtonControl": 6}
BUTTON_KEYS = {
Button.LKAS: (KEYS[0],), Button.DISTANCE: KEYS[2:5], Button.MODE: KEYS[8:11], Button.CUSTOM: KEYS[11:14],
}
def eligible(cp):
try:
return bool(cp is not None and not cp.notCar and not cp.passive and not cp.dashcamOnly and cp.carFingerprint)
except (AttributeError, TypeError, ValueError):
return False
def button_reported(cp, key):
"""Expose only button events the current vehicle parser actually emits."""
if key == PADDLE_KEY:
from opendbc.car.hyundai.left_paddle import has_left_paddle
return has_left_paddle(cp)
brand = cp.brand
if key not in KEYS[:8]:
return True # Physical media rows have their separate receiver contract.
if brand in ('body', 'byd', 'mg', 'mock', 'psa', 'rivian', 'subaru'):
return False
if brand == 'toyota':
from opendbc.car.toyota import legacy_advert_filter, camera_2aa_filter
from opendbc.car.toyota.values import CAR, ToyotaFlags
legacy_advert = bool(cp.flags & ToyotaFlags.RADAR_CAN_FILTER) and legacy_advert_filter.configured(cp)
if key == 'MainCruiseButtonControl' or key in KEYS[5:8]:
return False
if key in KEYS[2:5] and bool(cp.flags & ToyotaFlags.RADAR_CAN_FILTER) and camera_2aa_filter.configured(cp):
return False # No verified FD_BUTTON receiver is implied by a 2AA discriminator.
if key == 'LKASButtonControl':
return bool(cp.flags & (ToyotaFlags.TSS2 | ToyotaFlags.LONG_FILTER) or
cp.carFingerprint == CAR.TOYOTA_PRIUS_RETROFIT or legacy_advert)
return bool(cp.flags & ToyotaFlags.TSS2 and not cp.flags & (ToyotaFlags.RADAR_ACC | ToyotaFlags.SECOC) or
cp.flags & ToyotaFlags.LONG_FILTER or legacy_advert or cp.carFingerprint in
(CAR.TOYOTA_PRIUS, CAR.TOYOTA_PRIUS_RETROFIT, CAR.TOYOTA_SIENNA_4TH_GEN))
if brand == 'tesla':
from opendbc.car.tesla.values import CAR, TeslaFlags
if key == 'LKASButtonControl':
return bool(cp.carFingerprint not in (CAR.TESLA_MODEL_S_PREAP, CAR.TESLA_MODEL_S_HW1) and
cp.flags & TeslaFlags.AOL_SCREEN_BUTTON)
return bool(key in KEYS[5:8] and cp.carFingerprint == CAR.TESLA_MODEL_S_PREAP)
if key == 'LKASButtonControl':
if brand in ('chrysler', 'gm', 'mazda', 'nissan', 'volkswagen', 'volvo'):
return False
if brand == 'hyundai':
from opendbc.car.hyundai.values import CAR, HyundaiFlags, is_blended
return bool(cp.flags & (HyundaiFlags.CANFD | HyundaiFlags.HAS_LDA_BUTTON) or
cp.carFingerprint == CAR.HYUNDAI_ELANTRA_HEV_2024 or is_blended(cp))
if key == 'MainCruiseButtonControl' and brand in ('chrysler', 'ford', 'mazda', 'nissan', 'volkswagen'):
return False
if key in KEYS[5:8]:
if brand in ('chrysler', 'nissan'):
return False
if brand == 'ford':
return bool(cp.openpilotLongitudinalControl)
if brand == 'mazda':
from opendbc.car.mazda.aol import qualified
return qualified(cp, marked_only=True)
return True
def paddle_bookmark_available(params, cp):
# An invalid pause setting never authorizes a second paddle action.
if not button_reported(cp, PADDLE_KEY):
return False
raw, readable = read_saved(params, "NostalgiaMode", 8)
return bool(readable and raw in (None, b"0"))
@lru_cache(maxsize=64)
def _cp_fingerprint(serialized):
# Default substructures can materialize without changing any CarParams values.
# Rebuild every field so allocation layout cannot invalidate a wheel command.
with structs.CarParams.from_bytes(serialized) as reader:
canonical = structs.CarParams.new_message(**reader.to_dict())
return hashlib.sha256(canonical.to_bytes()).hexdigest()
def cp_fingerprint(cp):
reader = cp.as_reader() if hasattr(cp, "as_reader") else cp
return _cp_fingerprint(reader.as_builder().to_bytes())
def capture(params):
result = {}
for key in KEYS:
raw, readable = read_saved(params, key, 8)
if not readable or raw is not None and raw not in tuple(str(value).encode() for value in range(15)):
return None
result[key] = int(raw) if raw is not None else DEFAULTS.get(key, 0)
return result
def key_for(gesture):
keys = BUTTON_KEYS[gesture.button]
index = (Press.SHORT, Press.LONG, Press.VERY_LONG).index(gesture.press)
return keys[index] if index < len(keys) else None
@dataclass(frozen=True)
class WheelCommand:
session: str
sequence: int
observed_ns: int
drive_id: int
source_car_ns: int
key: str
action: int
class WheelPublisher:
def __init__(self, params=None):
self.session = secrets.token_hex(16)
self.sequence = 0
self.drive = 0
self.tracker = ButtonTracker()
self.cancel_tracker = ButtonTracker()
self.main_tracker = ButtonTracker()
self.mapping = None
self.paddle_held = False
self.paddle_armed = False
self.last_heartbeat = 0
self.checked_ns = 0
self.media_sample_state = None
self.suppress_distance_release = False
initial = capture(params) if params is not None else None
self.axis_keys = {key for key, action in (initial or {}).items() if action in (3, 4, 9)}
self.initial_axis_read = initial is not None
def media_audit_needed(self, media):
if media is None:
return False
if not media.valid:
self.media_sample_state = None
return bool(media.samples)
if not media.samples:
return False
previous = self.media_sample_state
changed = previous is None or previous[0] != media.source_epoch
for sample in media.samples:
current = (media.source_epoch, sample.mode_held, sample.custom_held)
# Keep auditing a held gesture: an assignment can change and return to
# its original value before release, which must still cancel that press.
changed |= current != previous or sample.mode_held or sample.custom_held
previous = current
self.media_sample_state = previous
# Neutral media packets also used to audit assignments during conventional
# button holds. Preserve those revocations until every gesture is idle.
held = any(tracker.distance_held or tracker.media_held is not None
for tracker in (self.tracker, self.cancel_tracker, self.main_tracker))
return changed or bool(media.samples and held)
def observe(self, params, cp, state, *, now_ns, drive_id, media=None, blocked_keys=frozenset()):
self.suppress_distance_release = any(event.type == structs.CarState.ButtonEvent.Type.gapAdjustCruise and not event.pressed
for event in state.buttonEvents)
# Repeated neutral packets are not button edges. Keep held-input audits,
# the periodic audit, and the final read before emitted commands below.
media_edge = self.media_audit_needed(media)
edge = bool(state.buttonEvents) or media_edge
audit = self.mapping is None or drive_id != self.drive or edge or now_ns - self.checked_ns >= 1_000_000_000
source_eligible = bool(eligible(cp) and state.canValid and not state.canTimeout and type(now_ns) is int and
type(drive_id) is int and 0 < drive_id < now_ns)
mapping = capture(params) if audit and source_eligible else self.mapping
if audit:
self.checked_ns = now_ns
if not source_eligible or mapping is None:
self.tracker = ButtonTracker()
self.cancel_tracker = ButtonTracker()
self.main_tracker = ButtonTracker()
self.mapping = None
self.paddle_held = self.paddle_armed = False
return ()
if not self.initial_axis_read:
self.axis_keys = {key for key, action in mapping.items() if action in (3, 4, 9)}
self.initial_axis_read = True
if drive_id != self.drive or mapping != self.mapping:
self.tracker = ButtonTracker()
self.cancel_tracker = ButtonTracker()
self.main_tracker = ButtonTracker()
self.paddle_held = self.paddle_armed = False
self.drive, self.mapping = drive_id, mapping
gestures = [(key_for(gesture), gesture) for gesture in self.tracker.observe(
state, media if ioniq6_media_eligible(cp) else None)]
for tracker, source, keys in ((self.cancel_tracker, structs.CarState.ButtonEvent.Type.cancel, KEYS[5:8]),
(self.main_tracker, structs.CarState.ButtonEvent.Type.mainCruise, KEYS[1:2])):
if source == structs.CarState.ButtonEvent.Type.cancel and cp.brand == 'gm':
from opendbc.car.gm.cancel_remap import enabled as gm_cancel_remap_enabled, supported as gm_cancel_remap_supported
if gm_cancel_remap_supported(cp) and not gm_cancel_remap_enabled(cp):
self.cancel_tracker = ButtonTracker()
continue
if not any(mapping[key] in DELIVERED_ACTIONS for key in keys):
continue
copy = structs.CarState(canValid=state.canValid, canTimeout=state.canTimeout)
copy.buttonEvents = [structs.CarState.ButtonEvent(type=structs.CarState.ButtonEvent.Type.gapAdjustCruise,
pressed=event.pressed)
for event in state.buttonEvents if event.type == source]
for gesture in tracker.observe(copy.as_reader()):
index = (Press.SHORT, Press.LONG, Press.VERY_LONG).index(gesture.press)
if index < len(keys):
gestures.append((keys[index], gesture))
paddle_commands = []
paddle_edge = any(event.type == structs.CarState.ButtonEvent.Type.altButton2 for event in state.buttonEvents)
paddle_allowed = ((self.paddle_held or paddle_edge) and mapping[PADDLE_KEY] == 8 and PADDLE_KEY not in blocked_keys and
paddle_bookmark_available(params, cp) and read_saved(params, PADDLE_KEY, 8) == (b"8", True))
# Audit every held sample, so enabling pause even briefly cancels this press.
self.paddle_armed &= paddle_allowed
for event in state.buttonEvents:
if event.type != structs.CarState.ButtonEvent.Type.altButton2:
continue
if event.pressed:
if not self.paddle_held:
self.paddle_armed = paddle_allowed
self.paddle_held = True
else:
if self.paddle_held and self.paddle_armed:
paddle_commands.append((PADDLE_KEY, 8))
self.paddle_held = self.paddle_armed = False
commands = [(key, mapping[key]) for key, _ in gestures if key is not None and key not in blocked_keys and
key not in self.axis_keys and button_reported(cp, key) and mapping[key] in DELIVERED_ACTIONS and
(key != "MainCruiseButtonControl" or mapping[key] == 10)]
commands.extend(paddle_commands)
if commands and not audit:
current = capture(params)
self.checked_ns = now_ns
if current != mapping:
self.tracker = ButtonTracker()
self.cancel_tracker = ButtonTracker()
self.main_tracker = ButtonTracker()
self.mapping = current
return ()
if len(commands) > 1:
return ()
if not commands and now_ns - self.last_heartbeat >= 100_000_000:
commands.append(("", 0))
self.last_heartbeat = now_ns
return tuple(commands)
def publish(self, commands, cp, publisher, *, now_ns, drive_id, source_car_ns, source_control_ns):
from openpilot.cereal import messaging
for key, action in commands:
self.sequence += 1
event = messaging.new_message("slcCruiseEvent", valid=True, logMonoTime=now_ns)
event.slcCruiseEvent.kind = "wheelAction"
event.slcCruiseEvent.eventId = self.sequence
event.slcCruiseEvent.producerSessionId = self.session
event.slcCruiseEvent.observedMonoTime = now_ns
event.slcCruiseEvent.wheelAction = {
"version": 1, "sessionId": self.session, "sequence": self.sequence, "observedMonoTime": now_ns,
"validUntilMonoTime": now_ns + LIFETIME_NS, "driveStartMonoTime": drive_id,
"carFingerprint": cp.carFingerprint, "sourceCarControlMonoTime": source_control_ns,
"carParamsFingerprint": cp_fingerprint(cp),
"sourceCarStateMonoTime": source_car_ns, "buttonKey": key, "actionCode": action,
}
publisher.send("slcCruiseEvent", event)
class WheelConsumer:
def __init__(self):
self.drive = 0
self.session = None
self.retired = set()
self.sequence = 0
self.last_heartbeat = 0
def accept(self, event, params, cp, sm, *, now_ns):
try:
if not event.valid or str(event.slcCruiseEvent.kind) != "wheelAction" or not eligible(cp):
return None
wire = event.slcCruiseEvent.wheelAction
drive = int(sm["deviceState"].startedMonoTime)
if drive != self.drive:
self.__init__()
self.drive = drive
observed = int(wire.observedMonoTime)
source = int(wire.sourceCarStateMonoTime)
session = str(wire.sessionId)
key, action = str(wire.buttonKey), int(wire.actionCode)
if (wire.version != 1 or int(event.slcCruiseEvent.eventId) != int(wire.sequence) or
str(event.slcCruiseEvent.producerSessionId) != session or
int(event.slcCruiseEvent.observedMonoTime) != observed or not SESSION.fullmatch(session) or session in self.retired or
int(wire.driveStartMonoTime) != drive or str(wire.carFingerprint) != cp.carFingerprint or
str(wire.carParamsFingerprint) != cp_fingerprint(cp) or
not sm["deviceState"].started or not sm["carState"].canValid or sm["carState"].canTimeout or
not drive < source <= observed <= now_ns <= int(wire.validUntilMonoTime) or
int(wire.validUntilMonoTime) - observed != LIFETIME_NS or int(event.logMonoTime) != observed or
source > int(sm.logMonoTime["carState"]) or now_ns - source > LIFETIME_NS or
not all(fresh_service(sm, name, drive, now_ns, LIFETIME_NS) for name in ("carState", "carControl")) or
not fresh_service(sm, "deviceState", drive, now_ns, 1_000_000_000) or
not drive < int(wire.sourceCarControlMonoTime) <= observed or
int(wire.sourceCarControlMonoTime) > int(sm.logMonoTime["carControl"]) or
now_ns - int(wire.sourceCarControlMonoTime) > LIFETIME_NS):
return None
if action == 0:
if key:
return None
elif key not in KEYS or action not in DELIVERED_ACTIONS or not button_reported(cp, key):
return None
elif key == PADDLE_KEY and (action != 8 or not paddle_bookmark_available(params, cp)):
return None
else:
mapping = capture(params)
if mapping is None or mapping[key] != action:
return None
if session != self.session:
if self.session is not None:
if len(self.retired) >= 8:
return None
self.retired.add(self.session)
self.session, self.sequence = session, 0
if wire.sequence <= self.sequence:
return None
self.sequence = int(wire.sequence)
self.last_heartbeat = observed
return WheelCommand(session, self.sequence, observed, drive, source, key, action)
except (AttributeError, KeyError, TypeError, ValueError, OverflowError):
return None