mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
346 lines
16 KiB
Python
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
|