Integrate vehicle-specific steering, longitudinal control, startup, and handoff policies, including Ioniq, EV9, GV70, and non-SCC configurations. Preserve the corresponding native safety and CAN routing contracts.

Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>

Co-authored-by: Intelli <contact@intelli.software>
This commit is contained in:
firestar5683
2026-09-25 14:53:19 -05:00
parent cf21c9729f
commit 2b1152adf7
168 changed files with 26742 additions and 325 deletions
@@ -0,0 +1,142 @@
"""Experimental mixed longitudinal diagnostic protocol."""
import time
from opendbc.car.carlog import carlog
from opendbc.car.isotp_parallel_query import IsoTpParallelQuery
EXT_DIAG_REQUEST = b'\x10\x03'
EXT_DIAG_RESPONSE = b'\x50\x03'
RESET_REQUEST = b'\x11\x01'
RESET_RESPONSE = b''
ecu_log = carlog.warning
def disable_ecu(can_recv, can_send, bus=0, addr=0x7d0, sub_addr=None, com_cont_req=b'\x28\x83\x01', timeout=0.1, retry=10, reset=False,
require_response=False, diag_request=EXT_DIAG_REQUEST, diag_response=EXT_DIAG_RESPONSE, response_offset=0x8):
"""Silence an ECU by disabling sending and receiving messages using UDS 0x28.
The ECU will stay silent as long as openpilot keeps sending Tester Present.
Set require_response for takeovers that must fail closed unless the ECU confirms communication control.
This is used to disable the radar in some cars. Openpilot will emulate the radar.
WARNING: THIS DISABLES AEB!"""
ecu_log(f"=== ECU DISABLE START === addr={hex(addr)}, bus={bus}")
if reset:
try:
ecu_log("sending ECU reset before communication control...")
reset_query = IsoTpParallelQuery(can_send, can_recv, bus, [(addr, sub_addr)], [RESET_REQUEST], [RESET_RESPONSE],
response_offset=response_offset)
reset_query.get_data(timeout=timeout)
time.sleep(0.2)
except Exception as e:
ecu_log(f"reset exception: {e}")
# Try multiple times with different approaches
for i in range(retry):
try:
# Enter extended diagnostic session
ecu_log(f"attempt {i+1}/{retry}: diag session...")
query = IsoTpParallelQuery(can_send, can_recv, bus, [(addr, sub_addr)], [diag_request], [diag_response],
response_offset=response_offset)
for _, _ in query.get_data(timeout).items():
ecu_log("diag session OK")
# Small delay to let ECU fully enter diagnostic mode
time.sleep(0.05)
# Send CC command and log the response
ecu_log("sending CC...")
cc_query = IsoTpParallelQuery(can_send, can_recv, bus, [(addr, sub_addr)], [com_cont_req], [b''],
response_offset=response_offset)
cc_response = cc_query.get_data(timeout)
# Log what we got back
cc_success = False
cc_rejected = False
cc_nrc = None
for (_rx_addr, _), data in cc_response.items():
ecu_log(f"CC response: {data.hex() if data else 'empty'}")
# Check for positive response (0x68 = 0x28 + 0x40)
if len(data) >= 1 and data[0] == 0x68:
ecu_log("=== ECU DISABLE CONFIRMED ===")
cc_success = True
# Check for negative response
elif len(data) >= 3 and data[0] == 0x7F:
nrc = data[2]
cc_nrc = nrc
nrc_meanings = {
0x12: "subFunctionNotSupported",
0x13: "incorrectMessageLengthOrInvalidFormat",
0x22: "conditionsNotCorrect (car must be in IGN-ON, not READY)",
0x31: "requestOutOfRange",
0x33: "securityAccessDenied",
}
nrc_name = nrc_meanings.get(nrc, "unknown")
ecu_log(f"CC rejected: NRC 0x{nrc:02x} = {nrc_name}")
cc_rejected = True
if cc_success:
return True
elif cc_rejected:
if reset and cc_nrc == 0x22 and i < retry - 1:
ecu_log("CC rejected with NRC 0x22 after reset; retrying...")
time.sleep(0.2)
continue
# ECU explicitly rejected - don't retry, it won't work
ecu_log("=== ECU DISABLE REJECTED ===")
return False
elif require_response:
ecu_log("CC response required but none received; retrying...")
continue
else:
# No response - consider it sent (ECU might have stopped responding)
ecu_log("=== ECU DISABLE SENT (no response) ===")
return True
except Exception as e:
ecu_log(f"attempt {i+1} exception: {e}")
time.sleep(0.1)
ecu_log("=== ECU DISABLE FAILED ===")
return False
def communication_control(can_recv, can_send, *, bus, addr, control_type, reset=False, timeout=.1, retry=10):
"""Matched unsuppressed SID/subfunction acknowledgment, following UdsClient.
Original suppressed28 83/80 are preserved above as source sent-request
behavior, never reused as confirmed takeover/restoration proof.
"""
if reset:
try:
query = IsoTpParallelQuery(can_send, can_recv, bus, [(addr, None)], [RESET_REQUEST], [RESET_RESPONSE])
query.get_data(timeout)
time.sleep(.2)
except Exception:
carlog.exception('Mixed-CAN pre-takeover reset query failed')
for _ in range(retry):
try:
diagnostic = IsoTpParallelQuery(can_send, can_recv, bus, [(addr, None)],
[EXT_DIAG_REQUEST], [EXT_DIAG_RESPONSE])
if not diagnostic.get_data(timeout):
continue
control = IsoTpParallelQuery(can_send, can_recv, bus, [(addr, None)],
[bytes((0x28, control_type, 1))], [bytes((0x68, control_type))])
if control.get_data(timeout):
return True
except Exception:
carlog.exception('Mixed-CAN communication-control acknowledgment failed')
return False
def confirm_disable_ecu(can_recv, can_send, *, bus, addr, com_cont_req, reset):
if com_cont_req != b'\x28\x03\x01' or reset is not True:
raise ValueError('Unexpected mixed-CAN takeover request')
return communication_control(can_recv, can_send, bus=bus, addr=addr, control_type=3, reset=True)
def restore_ecu(can_recv, can_send, *, bus, addr, timeout=.1, retry=10):
return communication_control(can_recv, can_send, bus=bus, addr=addr, control_type=0, timeout=timeout, retry=retry)
@@ -0,0 +1,513 @@
"""Experimental mixed-CAN longitudinal ownership; native admission is mandatory.
A caller must finish this transaction before publishing/construing active CP.
Shared Ioniq and generic Hyundai initialization are intentionally independent.
"""
from dataclasses import dataclass
from enum import Enum
from opendbc.car.hyundai.values import CAR, is_blended, HyundaiFlags, HyundaiSafetyFlags
DISABLE = b'\x28\x03\x01' # Unsuppressed takeover requires a matching68 03 acknowledgment.
ENABLE = b'\x28\x00\x01' # Unsuppressed restoration: source-equivalent sent is not confirmation.
class Phase(Enum):
IDLE = 'idle'
PENDING = 'pending'
OWNED = 'owned'
FAILED = 'failed'
RESTORING = 'restoring'
RESTORED = 'restored'
class Outcome(Enum):
OWNED = 'owned'
STOCK = 'stock'
STOCK_UNTOUCHED = 'stock_untouched'
ABORT_UNCERTAIN = 'abort_uncertain'
@dataclass(frozen=True)
class TakeoverResult:
outcome: Outcome
def __bool__(self):
raise TypeError('Inspect explicit takeover outcome; uncertain ECU state is not stock admission')
@property
def admissible(self):
return self.outcome in (Outcome.OWNED, Outcome.STOCK, Outcome.STOCK_UNTOUCHED)
def alpha_eligible(cp):
# Pinned original: classic non-legacy/non-unsupported eligibility plus the
# dedicated HDAII allowlist, whose only member is this exact platform.
return (is_blended(cp) and
not cp.flags & (HyundaiFlags.CANFD | HyundaiFlags.LEGACY | HyundaiFlags.UNSUPPORTED_LONGITUDINAL | HyundaiFlags.NON_SCC) and
not (cp.flags & HyundaiFlags.USE_FCA and not cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG))
def candidate_from_stock(cp, *, alpha_requested, native_qualified=False):
"""Only a separately qualified mixed namespace may construct a long candidate."""
if not alpha_requested or not native_qualified or not alpha_eligible(cp) or cp.passive:
return None
if len(cp.safetyConfigs) != 1 or cp.openpilotLongitudinalControl or not cp.pcmCruise:
return None
stock_word = 0x2010 if cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG else 0x2000
if cp.safetyConfigs[0].safetyParam != stock_word:
return None
# get_params returns a mutable builder; convert to a reader then own a copy.
candidate = cp.as_reader().as_builder()
candidate.alphaLongitudinalAvailable = True
candidate.openpilotLongitudinalControl = True
candidate.pcmCruise = False
from opendbc.car.hyundai.radar_interface import blended_track_radar
candidate.radarUnavailable = cp.radarUnavailable if blended_track_radar(cp) else True
candidate.safetyConfigs[0].safetyParam |= HyundaiSafetyFlags.LONG.value
# Modern mixed alpha namespace2004/2014; cancel/LDA semantics are local.
candidate.stopAccel = -.85
return candidate
class BlendedLongitudinalOwner:
"""Explicit scoped takeover/restore; never consumes another car's saved failure."""
def __init__(self, cp, CAN, exchange, restore_exchange=None):
if not is_blended(cp):
raise ValueError('Mixed-CAN owner requires the exact platform')
self.cp = cp
self.exchange = exchange
if restore_exchange is None:
from opendbc.car.hyundai.blended_disable_ecu import restore_ecu
restore_exchange = restore_ecu
self.restore_exchange = restore_exchange
self.bus = CAN.ECAN
self.address = 0x730 if cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG else 0x7d0
self.phase = Phase.IDLE
self.cancelled = False
self.restore_confirmed = False
self.result = None
self.published = False
self.takeover_attempted = False
def _fallback(self):
if self.published:
return
self.cp.safetyConfigs[-1].safetyParam &= ~HyundaiSafetyFlags.LONG.value
self.cp.openpilotLongitudinalControl = False
self.cp.pcmCruise = True
def begin(self, can_recv, can_send, *, unpublished, admission):
if self.phase is not Phase.IDLE or self.result is not None:
raise RuntimeError('Takeover is single-use')
if unpublished is not True:
raise RuntimeError('Takeover must precede active CP publication')
if admission is not True or not self.cp.openpilotLongitudinalControl:
self._fallback()
self.result = TakeoverResult(Outcome.STOCK_UNTOUCHED)
return self.result
self.phase = Phase.PENDING
self.takeover_attempted = True
try:
accepted = self.exchange(can_recv, can_send, bus=self.bus, addr=self.address,
com_cont_req=DISABLE, reset=True)
except BaseException:
self._fallback()
self.phase = Phase.FAILED
self.restore(can_recv, can_send)
raise
if accepted is True and not self.cancelled:
self.phase = Phase.OWNED
self.result = TakeoverResult(Outcome.OWNED)
return self.result
self._fallback()
self.phase = Phase.FAILED
# A missing/rejected response can still follow a partially completed disable.
restored = self.restore(can_recv, can_send)
self.result = TakeoverResult(Outcome.STOCK if restored else Outcome.ABORT_UNCERTAIN)
return self.result
def cancel(self):
self.cancelled = True
def restore(self, can_recv, can_send):
if self.phase is Phase.RESTORED:
return self.restore_confirmed
self.phase = Phase.RESTORING
self.result = TakeoverResult(Outcome.ABORT_UNCERTAIN)
self._fallback()
try:
accepted = self.restore_exchange(can_recv, can_send, bus=self.bus, addr=self.address)
except BaseException:
self.phase = Phase.FAILED
raise
self.restore_confirmed = accepted is True
self.result = TakeoverResult(Outcome.STOCK if self.restore_confirmed else Outcome.ABORT_UNCERTAIN)
self.phase = Phase.RESTORED if self.restore_confirmed else Phase.FAILED
return self.restore_confirmed
def publication_cp(self):
if self.result is None or not self.result.admissible or (self.cancelled and self.phase is not Phase.RESTORED):
raise RuntimeError("Uncertain ECU ownership forbids constructor/publication continuation")
return self.cp
def active(self):
return self.phase is Phase.OWNED and not self.cancelled and self.cp.openpilotLongitudinalControl
class BlendedLongitudinalController:
"""Platform extension for owned diagnostic keepalive and original ACC cadence."""
def __init__(self, owner, packer, CAN):
self.owner = owner
self.packer = packer
self.CAN = CAN
self.accel_last = 0.0
self.normal_sent = False
def tester_present(self, frame):
if not self.owner.active() or self.normal_sent and frame % 100:
return []
from opendbc.car import make_tester_present_msg
return [make_tester_present_msg(self.owner.address, self.owner.bus, suppress_response=True)]
def commands(self, frame, CC, CS, accel, stopping, set_speed):
if not self.owner.active():
return []
from opendbc.car.hyundai import blended_longitudinal_can as can
cp = self.owner.cp
hda2 = bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG)
messages = can.create_blended_adrv_messages(self.packer, self.CAN, frame) if hda2 else []
messages.extend(can.create_radar_aux_messages(self.packer, self.CAN, frame, hda2=hda2))
if frame % 2 == 0:
from opendbc.car import structs
jerk = 3.0 if CC.actuators.longControlState == structs.CarControl.Actuators.LongControlState.pid else 1.0
use_fca = cp.flags & HyundaiFlags.USE_FCA.value
if hda2:
messages.extend(can.create_acc_commands_can_canfd_blended_hda2(
self.packer, CC.enabled, accel, self.accel_last, jerk, frame // 2, CC.hudControl,
set_speed, stopping and CS.out.vEgoRaw < .1, CC.cruiseControl.override, use_fca, cp))
self.accel_last = accel
else:
messages.extend(can.create_acc_commands_can_canfd_blended(
self.packer, CC.enabled, accel, jerk, frame // 2, CC.hudControl, set_speed,
stopping, CC.cruiseControl.override, use_fca, cp))
return messages
def mixed_startup_qualified(cp, *, is_release=False, allow_marked=False):
"""Exact prepared mixed owner; stock topology remains immutable."""
from opendbc.car import structs
from opendbc.car.hyundai.hyundaicanfd import CanBus
declared = int(CAR.HYUNDAI_PALISADE_2023.config.flags)
hda2 = bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG)
dynamic = int(HyundaiFlags.HAS_LDA_BUTTON | HyundaiFlags.SEND_LFA)
if hda2:
dynamic |= int(HyundaiFlags.CANFD_LKA_STEER_MSG | HyundaiFlags.USE_FCA)
if (is_release or int(cp.flags) & ~dynamic != declared or not alpha_eligible(cp) or cp.passive or cp.dashcamOnly or cp.notCar or
cp.brand != 'hyundai' or cp.steerControlType != structs.CarParams.SteerControlType.torque or
cp.alternativeExperience not in ((0, 32) if allow_marked else (0,)) or
len(cp.safetyConfigs) != 1):
return False
safety = cp.safetyConfigs[0]
word = (0x2014 if hda2 else 0x2004) if cp.openpilotLongitudinalControl and not cp.pcmCruise else (0x2010 if hda2 else 0x2000)
buses = CanBus(cp)
return (safety.safetyModel == structs.CarParams.SafetyModel.hyundai and safety.safetyParam == word and
(cp.openpilotLongitudinalControl != cp.pcmCruise) and
(buses.ECAN, buses.ACAN, buses.CAM) == ((1, 0, 2) if hda2 else (0, 1, 2)))
def hdai_startup_qualified(cp, *, is_release=False, allow_marked=False):
return (not cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG and
mixed_startup_qualified(cp, is_release=is_release, allow_marked=allow_marked))
def startup_required(cp):
# Active mixed CP must never bypass its car-owned prepublication transaction.
return is_blended(cp) and cp.openpilotLongitudinalControl
class BlendedStartup:
def __init__(self, stock_cp, candidate, callbacks):
import threading
import time
from opendbc.car.hyundai.blended_disable_ecu import confirm_disable_ecu
from opendbc.car.hyundai.hyundaicanfd import CanBus
self.stock_cp = stock_cp
self.candidate_snapshot = candidate.to_dict()
self.final_snapshot = None
self.configured_ci = None
self.callbacks = callbacks
self.owner = BlendedLongitudinalOwner(candidate, CanBus(candidate), confirm_disable_ecu)
self.stop_event = threading.Event()
self.thread = None
self.clock = time.monotonic
self.started_at = None
self.fault = None
self.handed_off = False
self.closed = False
self.diagnostic_admission = None
self.source_floor_ns = 0
self.controller = None
self.last_tester_at = None
self.enable_gesture = None
self.enable_credit = None
self.enable_generation = 0
self.enable_button = 0
self.native_off_witness = False
self.cruise_resume_hint = None
def prepare(self, *, admission):
self.diagnostic_admission = admission
result = self.owner.begin(*self.callbacks, unpublished=True, admission=admission())
if not result.admissible:
raise RuntimeError('Mixed-CAN ECU state uncertain; abort before constructors/publication')
if result.outcome in (Outcome.STOCK, Outcome.STOCK_UNTOUCHED):
return self.stock_cp
import time
self.source_floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
self.started_at = self.clock()
self._tester() # Continuity starts before parser/controller construction.
import threading
def loop():
while not self.stop_event.wait(.5):
self._tester()
self.thread = threading.Thread(target=loop, name='blended-startup-tester', daemon=True)
self.thread.start()
return self.owner.publication_cp()
def _tester(self):
from opendbc.car import make_tester_present_msg
if self.started_at is None or self.fault is not None:
return
if not self.handed_off and self.clock() - self.started_at >= 60.:
self.fault = 'Startup keepalive bound exceeded'
self.owner.cancel()
self.stop_event.set()
return
try:
self.callbacks[1]([make_tester_present_msg(self.owner.address, self.owner.bus, suppress_response=True)])
self.last_tester_at = self.clock()
except Exception:
self.fault = 'Startup diagnostic sender failed'
self.owner.cancel()
self.stop_event.set()
def prepared_for(self, cp):
return (not self.closed and self.fault is None and self.owner.active() and self.owner.result is not None and
self.owner.result.outcome is Outcome.OWNED and self.source_floor_ns > 0 and
self.owner.cp.to_dict() == (self.final_snapshot or self.candidate_snapshot) == cp.to_dict())
def configure(self, ci):
self.check()
if self.closed or self.owner.published:
raise RuntimeError("Mixed startup configuration is closed")
if self.owner.active():
self.controller = BlendedLongitudinalController(self.owner, ci.CC.packer, ci.CC.CAN)
ci.CC.blended_longitudinal = self.controller
self.configured_ci = ci
def finalize_aol_configuration(self, ci):
self.check()
if self.closed or self.owner.published or ci is not self.configured_ci:
raise RuntimeError('Mixed AOL finalization requires configured unpublished startup')
if self.owner.active() and (self.controller is None or ci.CC.blended_longitudinal is not self.controller):
raise RuntimeError('Mixed AOL finalization requires its installed controller')
if self.final_snapshot is not None:
if not self.prepared_for(ci.CP):
raise RuntimeError('Mixed AOL final configuration is frozen')
return
if not self.owner.active():
return
expected = dict(self.candidate_snapshot)
if ci.CP.alternativeExperience == 32:
from opendbc.car.hyundai.blended_stock_aol import qualified_alpha
if not qualified_alpha(ci.CP, marked_only=True):
raise RuntimeError('Unsupported mixed alpha AOL composition')
expected['alternativeExperience'] = 32
if ci.CP.to_dict() != expected or self.owner.cp.to_dict() != expected:
raise RuntimeError('Mixed alpha final CP differs from prepared transaction')
self.final_snapshot = expected
def seal_publication(self):
self.check()
if self.owner.active() and not self.prepared_for(self.owner.cp):
raise RuntimeError('Mixed alpha publication requires exact prepared final CP')
self.owner.published = True
def check(self):
if self.fault is not None:
raise RuntimeError(self.fault)
if self.owner.result is None or not self.owner.result.admissible:
raise RuntimeError('Uncertain mixed-CAN ECU ownership')
def sources_current(self, ci, now_ns, *, health_checked=False):
from opendbc.car import Bus
from opendbc.can.parser import MAX_BAD_COUNTER
if not self.owner.active():
return True
if self.source_floor_ns <= 0:
return False
# Independent sources consumed after ECU suppression; SCC output is not a
# takeover proof. Disabled SCC parser/source selection remains separate.
pt_names = ('MDPS12', 'TCS11', 'TCS13', 'TCS15', 'CLU11', 'CLU15', 'ESP12',
'CGW1', 'CGW2', 'WHL_SPD11', 'SAS11', 'EMS12', 'EMS16', 'LVR12')
camera = 'CAM_0x2a4' if self.owner.cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG else 'LKAS11'
from opendbc.car.hyundai.hyundaicanfd import CanBus
CAN = CanBus(self.owner.cp)
for bus, names, expected_bus in ((Bus.pt, pt_names, CAN.ECAN), (Bus.cam, (camera,), CAN.CAM)):
parser = ci.can_parsers[bus]
if parser.bus != expected_bus or (not health_checked and (not parser.can_valid or parser.bus_timeout)):
return False
for name in names:
address = parser.dbc.name_to_msg[name].address
state = parser.message_states.get(address)
if state is None or not state.timestamps or state.counter_fail >= MAX_BAD_COUNTER:
return False
stamp = int(state.timestamps[-1])
if not self.source_floor_ns < stamp <= now_ns or now_ns - stamp > state.timeout_threshold:
return False
return True
def after_state(self, *, ci, state, now_ns, control_enabled, control_current, pandas,
panda_log_ns, panda_recv_ns, panda_current):
from opendbc.car import Bus
from opendbc.car.hyundai.values import Buttons
cp = self.owner.cp
if not self.owner.active():
self.cruise_resume_hint = None
return
state.buttonEnable = False
exact = (is_blended(cp) and cp.openpilotLongitudinalControl and not cp.pcmCruise and
len(cp.safetyConfigs) == 1 and cp.safetyConfigs[0].safetyParam in (0x2004, 0x2014))
matched = False
if exact and panda_current and len(pandas) == 1:
panda = pandas[0]
config = cp.safetyConfigs[0]
matched = (panda.safetyModel == config.safetyModel and panda.safetyParam == config.safetyParam and
panda.alternativeExperience == cp.alternativeExperience and not panda.safetyRxChecksInvalid and
not panda.faults and (panda.ignitionLine or panda.ignitionCan) and
0 < panda_log_ns <= now_ns and now_ns - panda_log_ns <= 300_000_000 and
0 < panda_recv_ns <= now_ns and now_ns - panda_recv_ns <= 300_000_000)
transport_guard = (matched and control_current and state.canValid and not state.canTimeout and
state.cruiseState.available and not state.brakePressed and not state.gasPressed and
self.sources_current(ci, now_ns, health_checked=True))
base_guard = transport_guard and not control_enabled
parser = ci.can_parsers[Bus.pt]
samples = list(parser.vl_all['CLU11']['CF_Clu_CruiseSwState'])
edges = []
for sample in samples:
button = int(sample)
if button != self.enable_button:
edges.append((self.enable_button, button))
self.enable_button = button
if self.cruise_resume_hint is not None:
_, hint_ns = self.cruise_resume_hint
if (not transport_guard or edges or now_ns < hint_ns or now_ns - hint_ns > 500_000_000 or
not ci.CS.blended_cancel_sources_current(parser, cancel=False)):
self.cruise_resume_hint = None
native_on = bool(matched and pandas[0].controlsAllowed)
previous_off = self.native_off_witness
self.native_off_witness = bool(base_guard and not native_on)
if not base_guard:
self.enable_gesture = None
self.enable_credit = None
return
if self.enable_credit is not None:
generation, release_ns, created_ns, button = self.enable_credit
cancel = button == Buttons.CANCEL
if (edges or now_ns < created_ns or now_ns - created_ns > 500_000_000 or
not ci.CS.blended_cancel_sources_current(parser, cancel=cancel)):
self.enable_credit = None
elif native_on and panda_log_ns > release_ns:
state.buttonEnable = True
self.cruise_resume_hint = (button in (Buttons.CANCEL, Buttons.RES_ACCEL), now_ns)
self.enable_credit = None
for previous, button in edges:
self.enable_credit = None
gesture = self.enable_gesture
self.enable_gesture = None
if (button == Buttons.NONE and gesture is not None and previous == gesture[1] and
ci.CS.blended_cancel_sources_current(parser, cancel=gesture[1] == Buttons.CANCEL)):
# No immediate host event; require a subsequent fresh native ON sample.
release_ns = parser.ts_nanos['CLU11']['CF_Clu_CruiseSwState']
self.enable_credit = (gesture[0], release_ns, now_ns, gesture[1])
elif (previous == Buttons.NONE and button in (Buttons.CANCEL, Buttons.RES_ACCEL, Buttons.SET_DECEL) and
previous_off and not native_on and
ci.CS.blended_cancel_sources_current(parser, cancel=button == Buttons.CANCEL)):
self.enable_generation += 1
self.enable_gesture = (self.enable_generation, button)
if self.enable_gesture is not None:
cancel = self.enable_gesture[1] == Buttons.CANCEL
if not ci.CS.blended_cancel_sources_current(parser, cancel=cancel):
self.enable_gesture = None
def consume_cruise_resume(self):
hint = self.cruise_resume_hint
self.cruise_resume_hint = None
return None if hint is None else hint[0]
def before_control(self, *, configured, sources_current, control_current):
self.check()
if not self.owner.active():
return True
if not configured or not sources_current or not control_current:
return False
# Keep the worker alive until a valid controller tester actually reaches
# PubSocket.send. Merely reaching CI.apply is not a handoff receipt.
return True
def maintain(self, *, configured):
self.check()
if not self.handed_off or not self.owner.active() or not configured:
return
# A missing control/input sample inhibits actuation, not ECU keepalive.
# This runs from Card's main loop even when controls are unavailable.
if self.last_tester_at is None or self.clock() - self.last_tester_at >= 1.:
self._tester()
def sent(self, frames, *, valid):
if not valid or not self.owner.active():
return
from opendbc.car import make_tester_present_msg
tester = make_tester_present_msg(self.owner.address, self.owner.bus, suppress_response=True)
if tester not in frames:
return
self.last_tester_at = self.clock()
if self.handed_off:
return
# Caller has released send_lock; join cannot deadlock the worker sender.
self._stop()
self.check()
self.handed_off = True
if self.controller is not None:
self.controller.normal_sent = True
def _stop(self):
import threading
self.stop_event.set()
if self.thread is not None and self.thread is not threading.current_thread():
self.thread.join()
def close(self):
if self.closed:
if self.owner.result is not None and self.owner.result.outcome is Outcome.ABORT_UNCERTAIN:
raise RuntimeError("Stock ECU restoration remains unverified")
return
self.closed = True
self._stop()
if not self.owner.takeover_attempted:
return
if self.diagnostic_admission is None or self.diagnostic_admission() is not True:
self.owner.result = TakeoverResult(Outcome.ABORT_UNCERTAIN)
self.owner.phase = Phase.FAILED
raise RuntimeError("Diagnostic admission unavailable; stock ECU restoration remains unverified")
# The output loop is stopped. Published CP is never relabeled on teardown.
if not self.owner.restore(*self.callbacks):
raise RuntimeError('Mixed-CAN stock ECU restoration is unverified')
def startup_owner(cp, callbacks, *, requested):
candidate = candidate_from_stock(cp, alpha_requested=requested,
native_qualified=cp.alphaLongitudinalAvailable and mixed_startup_qualified(cp))
return None if candidate is None else BlendedStartup(cp, candidate, callbacks)
@@ -0,0 +1,144 @@
"""Mixed longitudinal CAN message builders."""
from opendbc.car.hyundai.hyundaican import hyundai_checksum
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import HyundaiFlags
def create_checksum_can_canfd_blended(packer, bus, addr, values):
dat = packer.make_can_msg(addr, bus, values)[1]
return hyundai_checksum(dat[1:8])
def create_acc_commands_can_canfd_blended(packer, enabled, accel, upper_jerk, idx, hud_control, set_speed,
stopping, long_override, use_fca, CP):
commands = []
bus = CanBus(CP).ECAN
scc11_values = {
"aReqRaw": accel,
"aReqValue": accel,
"JerkUpperLimit": upper_jerk,
"JerkLowerLimit": 5.0,
"ComfortBandUpper": 0.0,
"ComfortBandLower": 0.0,
"COUNTER": idx % 0x10,
}
scc11_values["CHECKSUM"] = create_checksum_can_canfd_blended(packer, bus, "SCC11", scc11_values)
commands.append(packer.make_can_msg("SCC11", bus, scc11_values))
scc12_values = {
"MainMode_ACC": 1,
"ACCMode_Inactive": 0 if enabled else 1,
"TauGapSet": hud_control.leadDistanceBars,
"VSetDis": set_speed if enabled else 0,
"ACC_ObjDist": 1,
"ACCMode": 2 if enabled and long_override else 1 if enabled else 0,
"StopReq": 1 if stopping else 0,
"ACC_ObjDist_Ref": 1,
"COUNTER": idx % 0x10,
}
scc12_values["CHECKSUM"] = create_checksum_can_canfd_blended(packer, bus, "SCC12", scc12_values)
commands.append(packer.make_can_msg("SCC12", bus, scc12_values))
scc14_values = {
"ACC_ObjRelSpd": 0,
"ObjValid": 1,
"ObjStatus": 1,
"COUNTER": idx % 0x10,
}
scc14_values["CHECKSUM"] = create_checksum_can_canfd_blended(packer, bus, "SCC14", scc14_values)
commands.append(packer.make_can_msg("SCC14", bus, scc14_values))
if use_fca and not (CP.flags & HyundaiFlags.CAMERA_SCC):
fca11_values = {
"cr_vsm_deccmd": 255,
"cf_vsm_deccmdact": 127,
"COUNTER": idx % 0x10,
}
fca11_values["CHECKSUM"] = create_checksum_can_canfd_blended(packer, bus, "FCA11", fca11_values)
commands.append(packer.make_can_msg("FCA11", bus, fca11_values))
return commands
def create_acc_commands_can_canfd_blended_hda2(packer, enabled, accel, accel_last, upper_jerk, idx,
hud_control, set_speed, stopping, long_override, use_fca, CP):
commands = []
bus = CanBus(CP).ECAN
jerk = 5.0
if not enabled or long_override:
accel_raw, accel_value = 0.0, 0.0
else:
accel_raw = accel
accel_value = max(accel_last - jerk / 50.0, min(accel, accel_last + jerk / 50.0))
message_values = [
("SCC11", {
"aReqRaw": accel_raw,
"aReqValue": accel_value,
"JerkUpperLimit": upper_jerk,
"JerkLowerLimit": jerk if enabled else 1.0,
}),
("SCC12", {
"MainMode_ACC": 1,
"ACCMode_Inactive": 0 if enabled else 1,
"TauGapSet": hud_control.leadDistanceBars,
"VSetDis": set_speed,
"ACC_ObjDist": 1,
"ACCMode": 2 if enabled and long_override else 1 if enabled else 0,
"StopReq": 1 if stopping else 0,
}),
("SCC14", {
"ACC_ObjRelSpd": 0,
"ObjValid": 0,
"ObjStatus": 2 if hud_control.leadVisible and enabled else 1 if hud_control.leadVisible else 0,
}),
]
if use_fca and not (CP.flags & HyundaiFlags.CAMERA_SCC):
# Retained original bytes; the FCA deceleration sentinel semantics remain unverified.
message_values.append(("FCA11", {
"cr_vsm_deccmd": 255,
"cf_vsm_deccmdact": 0,
}))
for name, values in message_values:
values["COUNTER"] = idx % 0xF
values["CHECKSUM"] = create_checksum_can_canfd_blended(packer, bus, name, values)
commands.append(packer.make_can_msg(name, bus, values))
return commands
def create_radar_aux_messages(packer, CAN, frame, hda2=False):
commands = []
message_specs = (
("RADAR_0x363", 2, {"FCA_ESA": 1}),
("RADAR_0x398", 5, {"BYTE4": 0x80, "BYTE5": 0x5D}),
("RADAR_0x399", 5, {"BYTE2": 0x02}),
("RADAR_0x39a", 5, {"BYTE7": 0xFF}),
("RADAR_0x39b", 5, {}),
("RADAR_0x39c", 5, {"BYTE5": 0xE0, "BYTE6": 0x79}),
("RADAR_0x43a", 20, {"BYTE2": 0x07}),
) if hda2 else (
("RADAR_0x363", 2, {"FCA_ESA": 1}),
("RADAR_0x398", 5, {"BYTE4": 0x80, "BYTE5": 0x10}),
)
for addr, freq, values in message_specs:
if frame % freq != 0:
continue
msg_values = values | {"COUNTER": frame % (0xF if hda2 else 0x10)}
msg_values["CHECKSUM"] = create_checksum_can_canfd_blended(packer, CAN.ECAN, addr, msg_values)
commands.append(packer.make_can_msg(addr, CAN.ECAN, msg_values))
return commands
def create_blended_adrv_messages(packer, CAN, frame):
# Exact reached original create_adrv_messages(..., blended_hda2=True) branch.
return [packer.make_can_msg("ADRV_0x51", CAN.ACAN, {})]
@@ -0,0 +1,72 @@
from opendbc.car import structs
from opendbc.car.hyundai.values import CAR, HyundaiFlags
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.classic_scc_aol import ClassicSccLkasSources
AOL_EXPERIENCE = 32
WORDS = frozenset((0x2000, 0x2010))
def qualified(cp, *, marked_only=False):
if cp.carFingerprint != CAR.HYUNDAI_PALISADE_2023 or cp.brand != 'hyundai':
return False
declared = int(CAR.HYUNDAI_PALISADE_2023.config.flags)
dynamic = int(HyundaiFlags.CANFD_LKA_STEER_MSG | HyundaiFlags.HAS_LDA_BUTTON | HyundaiFlags.USE_FCA | HyundaiFlags.SEND_LFA)
if (
int(cp.flags) & ~dynamic != declared
or cp.passive
or cp.notCar
or cp.dashcamOnly
or cp.openpilotLongitudinalControl
or not cp.pcmCruise
or cp.steerControlType != structs.CarParams.SteerControlType.torque
or len(cp.safetyConfigs) != 1
or cp.alternativeExperience not in ((AOL_EXPERIENCE,) if marked_only else (0, AOL_EXPERIENCE))
):
return False
hda2 = bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG)
bus = CanBus(cp)
return (
(bus.ECAN, bus.ACAN, bus.CAM) == ((1, 0, 2) if hda2 else (0, 1, 2))
and cp.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.hyundai
and cp.safetyConfigs[0].safetyParam == (0x2010 if hda2 else 0x2000)
)
class MixedStockLkasSources(ClassicSccLkasSources):
def __init__(self, cp):
super().__init__(cp.carFingerprint)
self.cp = cp
self.bus = CanBus(cp).ECAN
@property
def active(self):
return qualified(self.cp, marked_only=True) or qualified_alpha(self.cp, marked_only=True)
def update(self, packets):
if not self.active:
return
selected = [(stamp, [(address, data, 0) for address, data, bus in frames if bus == self.bus]) for stamp, frames in packets]
super().update(selected)
ALPHA_WORDS = frozenset((0x2004, 0x2014))
def qualified_alpha(cp, *, marked_only=False):
from opendbc.car.hyundai.blended_longitudinal import mixed_startup_qualified
return (mixed_startup_qualified(cp, allow_marked=True) and cp.alphaLongitudinalAvailable and
cp.openpilotLongitudinalControl and not cp.pcmCruise and
cp.safetyConfigs[0].safetyParam == (0x2014 if cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG else 0x2004) and
cp.alternativeExperience in ((32,) if marked_only else (0, 32)))
def camera_current(parser):
from opendbc.can.parser import MAX_BAD_COUNTER
source = parser.message_states.get(0x2A4)
now = parser._last_update_nanos
bus_timeout = parser.bus_timeout
healthy = all(state.valid(now, bus_timeout) and state.counter_fail < MAX_BAD_COUNTER for state in parser.message_states.values())
return bool(healthy and not bus_timeout and source is not None and source.timestamps
and source.counter_fail < MAX_BAD_COUNTER and 0 < int(source.timestamps[-1]) <= now
and now - int(source.timestamps[-1]) <= source.timeout_threshold)
@@ -0,0 +1,64 @@
"""Reached mixed-Palisade stopping behavior from pinned original 86599a27."""
import numpy as np
from opendbc.car.structs import car
from opendbc.car.hyundai import blended_longitudinal
from opendbc.car.hyundai.values import HyundaiFlags
from opendbc.car.hyundai.longitudinal_mode import HKGModePolicy
LongCtrlState = car.CarControl.Actuators.LongControlState
def eligible(cp):
if not blended_longitudinal.mixed_startup_qualified(cp, allow_marked=True):
return False
word = 0x2014 if cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG else 0x2004
if (not cp.openpilotLongitudinalControl or cp.pcmCruise or cp.passive or
len(cp.safetyConfigs) != 1 or cp.safetyConfigs[0].safetyParam != word):
return False
return True
def policy_for(cp, dt):
return BlendedStoppingPolicy(dt) if eligible(cp) else None
class BlendedStoppingPolicy(HKGModePolicy):
# Preserve the original CarParams Float32 representation.
stopping_decel_rate = float(np.float32(.35))
def __init__(self, dt):
self.dt = dt
self.release_frames = int(round(.35 / dt))
self.init_mode(dt)
self.reset()
def reset(self):
self.release_counter = 0
def transition(self, native_state, previous_state, active, CS, a_target, should_stop, *, has_lead):
# Original mixed platform startingState is false: stopping releases to PID.
if previous_state != LongCtrlState.stopping or not active:
self.reset()
return native_state
if should_stop or CS.brakePressed:
self.reset()
return LongCtrlState.stopping
if (CS.vEgo > .5 or (has_lead and a_target > .15) or
(a_target >= .45 and not CS.cruiseState.standstill)):
self.release_counter = self.release_frames
elif a_target > .15:
self.release_counter = min(self.release_counter + 1, self.release_frames)
else:
self.reset()
return LongCtrlState.pid if self.release_counter >= self.release_frames else LongCtrlState.stopping
def stopping_output(self, output, a_target, should_stop, CS):
# Original other-vehicle stopping tune branches are no-ops for this identity.
follow_min_speed = max(1.5, .35 + 1.0)
if not should_stop or CS.brakePressed or CS.vEgo <= follow_min_speed:
return output
if a_target >= output - .25:
return output
step = np.interp(CS.vEgo, [follow_min_speed, 3., 6., 10.], [.02, .03, .05, .07])
return max(float(a_target), output - float(step))
@@ -0,0 +1,210 @@
import math
from opendbc.car import structs
from opendbc.car.hyundai.values import CAR, HyundaiFlags, CANFD_ANGLE_MODEL_BITS
AOL_EXPERIENCE = 32
MODELS = frozenset(CANFD_ANGLE_MODEL_BITS)
ANGLE_AOL_WORDS = frozenset(
(
0x4008,
0x4010,
0x4028,
0x4090,
0x4208,
0x4228,
0x4408,
0x440A,
0x4410,
0x4412,
0x4428,
0x442A,
0x4490,
0x4492,
0x4608,
0x460A,
0x4628,
0x462A,
0x4809,
0x4811,
0x4829,
0x4891,
0x4A09,
0x4A29,
0x4C08,
0x4C28,
0x4E08,
0x4E28,
0x5008,
0x5010,
0x5028,
0x5090,
0x5208,
0x5228,
0x5491,
0x5809,
0x5811,
0x5829,
0x5891,
0x5A09,
0x5A29,
0x5C91,
0x6008,
0x600A,
0x6010,
0x6012,
0x6028,
0x602A,
0x6090,
0x6092,
0x6208,
0x620A,
0x6228,
0x622A,
0x6808,
0x680A,
0x6810,
0x6812,
0x6828,
0x682A,
0x6890,
0x6892,
0x6A08,
0x6A0A,
0x6A28,
0x6A2A,
0x7008,
0x700A,
0x7010,
0x7012,
0x7028,
0x702A,
0x7090,
0x7092,
0x7208,
0x720A,
0x7228,
0x722A,
0x7809,
0x7829,
)
)
def stock_word(cp):
flags = int(cp.flags)
word = 0x4000 | CANFD_ANGLE_MODEL_BITS[str(cp.carFingerprint)]
if flags & HyundaiFlags.EV:
word |= 1
if flags & HyundaiFlags.HYBRID:
word |= 2
if flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
word |= 16
if flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT:
word |= 128
else:
word |= 8
if flags & HyundaiFlags.CANFD_ALT_BUTTONS:
word |= 32
if flags & HyundaiFlags.SEND_LFA:
word |= 512
return word
def qualified(cp, *, marked_only=False):
identity = str(cp.carFingerprint)
if identity not in MODELS or cp.brand != 'hyundai':
return False
declared = int(CAR(identity).config.flags)
dynamic = int(
HyundaiFlags.HYBRID
| HyundaiFlags.CANFD_LKA_STEER_MSG
| HyundaiFlags.CANFD_LKA_STEER_MSG_ALT
| HyundaiFlags.CANFD_ALT_BUTTONS
| HyundaiFlags.CANFD_CAMERA_SCC
| HyundaiFlags.CANFD_ALT_GEARS
| HyundaiFlags.CANFD_ALT_GEARS_2
| HyundaiFlags.SEND_LFA
)
flags = int(cp.flags)
if (
flags & ~dynamic != declared & ~dynamic
or flags & ~(declared | dynamic)
or cp.passive
or cp.notCar
or cp.dashcamOnly
or cp.openpilotLongitudinalControl
or not cp.pcmCruise
or cp.steerControlType != structs.CarParams.SteerControlType.angle
or cp.alternativeExperience not in ((AOL_EXPERIENCE,) if marked_only else (0, AOL_EXPERIENCE))
or len(cp.safetyConfigs) != 1
or cp.safetyConfigs[0].safetyModel != structs.CarParams.SafetyModel.hyundaiCanfd
):
return False
if not flags & HyundaiFlags.EV and (not flags & HyundaiFlags.CANFD_ALT_GEARS_2 or flags & HyundaiFlags.CANFD_ALT_GEARS):
return False
if flags & HyundaiFlags.EV and flags & HyundaiFlags.HYBRID:
return False
lka = bool(flags & HyundaiFlags.CANFD_LKA_STEER_MSG)
if (
bool(flags & HyundaiFlags.CANFD_CAMERA_SCC) == lka
or (flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT and not lka)
or (lka and flags & HyundaiFlags.CANFD_ALT_BUTTONS)
):
return False
if identity == 'KIA_SPORTAGE_2026' and (lka or flags & HyundaiFlags.HYBRID):
return False
if identity in ('HYUNDAI_IONIQ_5_PE', 'KIA_EV9'):
expected = 0x5491 if identity == 'HYUNDAI_IONIQ_5_PE' else 0x5C91
return stock_word(cp) == expected and cp.safetyConfigs[0].safetyParam == expected
if identity == 'KIA_EV6_2025' and stock_word(cp) not in (0x7809, 0x7829):
return False
if flags & HyundaiFlags.HYBRID and identity not in (
'HYUNDAI_AZERA_HEV_7TH_GEN',
'KIA_SORENTO_HEV_4TH_GEN_LFA2',
'KIA_SPORTAGE_HEV_2026',
'HYUNDAI_SANTA_FE_HEV_5TH_GEN',
):
return False
return stock_word(cp) in ANGLE_AOL_WORDS and cp.safetyConfigs[0].safetyParam == stock_word(cp)
def command_allowed(cp, state):
return (
qualified(cp, marked_only=True)
and state.canValid
and not state.canTimeout
and state.gearShifter == structs.CarState.GearShifter.drive
and not state.standstill
and math.isfinite(state.vEgoRaw)
and state.vEgoRaw > 0.3
and not (state.brakePressed or state.gasPressed or state.steerFaultTemporary or state.steerFaultPermanent)
)
def temporary_restriction(cp, state):
gears = structs.CarState.GearShifter
return bool(
state is not None
and qualified(cp, marked_only=True)
and state.canValid
and not state.canTimeout
and not state.steerFaultPermanent
and state.gearShifter in (gears.drive, gears.reverse, gears.park, gears.neutral)
and math.isfinite(state.vEgoRaw)
and (state.gearShifter != gears.drive or state.standstill or state.vEgoRaw <= 0.3 or state.steerFaultTemporary or state.brakePressed or state.gasPressed)
)
ANGLE_ACK_TIMEOUT_NS = 200_000_000
def native_observation(native, *, latched, panda_ready, restricted, now_ns, pending_since_ns):
lost = not panda_ready or native is None
if not latched or lost or restricted:
return 0, lost, False
if native.requestedLateral and native.lateralAllowed:
return 0, False, False
pending_since_ns = pending_since_ns or now_ns
reset = now_ns - pending_since_ns >= ANGLE_ACK_TIMEOUT_NS
return pending_since_ns, False, reset
@@ -0,0 +1,75 @@
"""Optional isolated CANFD camera lead decoding, outside required controls health."""
import math
import time
from dataclasses import dataclass
from opendbc.can.parser import CANParser
from opendbc.car import Bus
from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum
from opendbc.car.hyundai.values import DBC
CAMERA_ADDRESS = 0x1B5
CAMERA_MAX_AGE_NS = 300_000_000
@dataclass(frozen=True)
class CameraLeadObservation:
producer_boot_ns: int
visible: bool
distance_m: float
relative_speed_mps: float
class CANFDCameraLead:
def __init__(self, cp, bus):
self.bus = bus
self.parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('FR_CMR_03_50ms', math.nan)], self.bus)
self.observation = None
self.last_packet_ns = 0
self.last_counter = None
def update(self, can_packets):
boot_clock = getattr(time, "CLOCK_BOOTTIME", None)
if boot_clock is None:
return
try:
now_boot_ns = time.clock_gettime_ns(boot_clock)
except OSError:
return
for stamp, frames in can_packets:
if type(stamp) is not int or not self.last_packet_ns < stamp <= now_boot_ns:
continue
for address, data, bus in frames:
if address != CAMERA_ADDRESS or bus != self.bus or len(data) != 32 or stamp <= self.last_packet_ns:
continue
self.last_packet_ns = stamp
# Semantic camera field names do not opt the shared parser into integrity
# checks. Keep validation local so existing CCNC controls health is unchanged.
if int.from_bytes(data[:2], 'little') != hkg_can_fd_checksum(CAMERA_ADDRESS, None, bytearray(data)):
continue
counter = data[2]
advancing = self.last_counter is None or counter == (self.last_counter + 1) % 256
self.last_counter = counter
if not advancing:
continue
previous = self.parser.ts_nanos['FR_CMR_03_50ms']['FR_CMR_Crc3Val']
accepted = self.parser.update([(stamp, [(address, data, bus)])])
if CAMERA_ADDRESS not in accepted or stamp <= previous:
continue
if self.observation is not None and stamp <= self.observation.producer_boot_ns:
continue
values = self.parser.vl['FR_CMR_03_50ms']
distance = float(values['Longitudinal_Distance'])
relative = float(values['Relative_Velocity'])
if not math.isfinite(distance) or not math.isfinite(relative):
continue
visible = distance > .1
self.observation = CameraLeadObservation(stamp, visible, distance if visible else 0., relative if visible else 0.)
def current(self, now_boot_ns, floor_boot_ns=0):
observation = self.observation
if (type(now_boot_ns) is not int or type(floor_boot_ns) is not int or observation is None or
not max(0, floor_boot_ns) < observation.producer_boot_ns <= now_boot_ns or
now_boot_ns - observation.producer_boot_ns > CAMERA_MAX_AGE_NS):
return None
return observation
@@ -0,0 +1,19 @@
from opendbc.car import DT_CTRL
class CanFDDashIcons:
def __init__(self):
self.disengage_frame = 0
self.disengaging = False
self.previous_lateral = False
def update(self, frame, enabled, lateral_active):
if lateral_active:
self.disengaging = False
elif self.previous_lateral:
self.disengaging = True
if not self.disengaging:
self.disengage_frame = frame
blinking = self.disengaging and (frame - self.disengage_frame) * DT_CTRL < 1.0
self.previous_lateral = lateral_active
return 2 if enabled or lateral_active else 3 if blinking else 0
@@ -0,0 +1,58 @@
"""Stateless CANFD lead selection; observations carry owner-qualified BOOTTIME receipts."""
from dataclasses import dataclass
import math
CAMERA_MAX_AGE_NS = 300_000_000
MIN_DISTANCE = 0.1
@dataclass(frozen=True)
class CANFDLeadObservation:
visible: bool
distance: float
relative_speed: float
producer_ns: int
@dataclass(frozen=True)
class CANFDLeadSelection:
visible: bool
distance: float
relative_speed: float
ABSENT = CANFDLeadSelection(False, 0., 0.)
def _current(observation: CANFDLeadObservation | None, observed_ns: int,
epoch_floor_ns: int) -> bool:
return (observation is not None and type(observation.producer_ns) is int and
epoch_floor_ns < observation.producer_ns <= observed_ns and
math.isfinite(observation.distance) and math.isfinite(observation.relative_speed))
def _selection(observation: CANFDLeadObservation) -> CANFDLeadSelection:
return CANFDLeadSelection(True, min(max(observation.distance, 0.), 204.7),
min(max(observation.relative_speed, -16.4), 34.7))
def select_lead(radar: CANFDLeadObservation | None, camera: CANFDLeadObservation | None, *,
hud_visible: bool, observed_ns: int, epoch_floor_ns: int) -> CANFDLeadSelection:
"""Radar freshness is qualified by transport; camera integrity by its parser owner.
Owner supplies one BOOTTIME domain and epoch floor. Camera age is bounded here;
no G90 hysteresis or additional radar freshness policy is introduced.
"""
if (type(observed_ns) is not int or type(epoch_floor_ns) is not int or
epoch_floor_ns < 0 or observed_ns <= epoch_floor_ns):
return ABSENT
radar_current = _current(radar, observed_ns, epoch_floor_ns)
radar_visible = radar_current and radar.visible
if radar_visible and radar.distance > MIN_DISTANCE:
return _selection(radar)
if (_current(camera, observed_ns, epoch_floor_ns) and camera.visible and
camera.distance > MIN_DISTANCE and observed_ns-camera.producer_ns <= CAMERA_MAX_AGE_NS):
return _selection(camera)
if radar_visible or hud_visible:
return CANFDLeadSelection(True, 20., 0.)
return ABSENT
@@ -0,0 +1,36 @@
"""Selected safety slot for the exact first-generation captured CANFD owner."""
from opendbc.car.structs import CarParams
from opendbc.car.hyundai.values import CAR, HyundaiFlags
def topology_index(cp):
if len(cp.safetyConfigs) == 1:
return 0 if cp.safetyConfigs[0].safetyModel == CarParams.SafetyModel.hyundaiCanfd else None
if len(cp.safetyConfigs) != 2:
return None
auxiliary, owner = cp.safetyConfigs
required = HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_LKA_STEER_MSG
excluded = (HyundaiFlags.CANFD_ANGLE_STEERING | HyundaiFlags.CANFD_LKA_STEER_MSG_ALT |
HyundaiFlags.CANFD_ALT_BUTTONS | HyundaiFlags.CANFD_CAMERA_SCC | HyundaiFlags.CCNC | HyundaiFlags.HYBRID)
if (cp.brand != 'hyundai' or cp.carFingerprint not in (CAR.KIA_EV6, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN) or
cp.passive or cp.dashcamOnly or cp.notCar or cp.steerControlType != CarParams.SteerControlType.torque or
cp.flags & required != required or cp.flags & excluded or
auxiliary.safetyModel != CarParams.SafetyModel.noOutput or auxiliary.safetyParam != 0 or
owner.safetyModel != CarParams.SafetyModel.hyundaiCanfd):
return None
return 1
def config_index(cp):
owner = topology_index(cp)
if owner != 1:
return owner
raw = int(cp.safetyConfigs[owner].safetyParam)
if cp.openpilotLongitudinalControl and not cp.pcmCruise:
if raw == 0x15 and cp.alternativeExperience == 0:
return 1
if cp.carFingerprint == CAR.KIA_EV6 and raw == 0x815 and cp.alternativeExperience == 32:
return 1
elif not cp.openpilotLongitudinalControl and cp.pcmCruise and cp.alternativeExperience == 0 and raw in (0x11, 0x811):
return 1
return None
@@ -0,0 +1,114 @@
"""AOL admission for the ordinary CAN FD torque and physical-button layouts."""
from opendbc.car.structs import CarParams
from opendbc.car.hyundai.values import CAR, HyundaiFlags
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.canfd_owner import config_index
STOCK_AOL_MARKER = 0x0800
STOCK_EV_CARS = frozenset(
(
CAR.HYUNDAI_KONA_EV_2ND_GEN,
CAR.HYUNDAI_IONIQ_5,
CAR.HYUNDAI_IONIQ_5_N,
CAR.KIA_NIRO_EV_2ND_GEN,
CAR.KIA_EV6,
CAR.GENESIS_GV60_EV_1ST_GEN,
CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN,
)
)
CARNIVAL_CARS = frozenset((CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN))
STOCK_AOL_WORDS = frozenset(
0x0800 | gas | topology | extra
for gas in (0, 1, 2)
for topology in (0, 8, 16, 144, 32, 40, 48, 176)
for extra in (0, 0x0400, 0x2000, 0x2400)
if (topology not in (48, 176) or extra & 0x2000)
and (not extra & 0x0400 or topology in (8, 40))
and (not extra & 0x2000 or (topology in (32, 40, 48, 176) and gas != 1))
)
STOCK_AOL_FLAGS = (
HyundaiFlags.CANFD
| HyundaiFlags.EV
| HyundaiFlags.HYBRID
| HyundaiFlags.CANFD_LKA_STEER_MSG
| HyundaiFlags.CANFD_LKA_STEER_MSG_ALT
| HyundaiFlags.CANFD_ALT_BUTTONS
| HyundaiFlags.CANFD_CAMERA_SCC
| HyundaiFlags.CANFD_ALT_GEARS
| HyundaiFlags.CANFD_ALT_GEARS_2
| HyundaiFlags.CANFD_RADAR_SCC
| HyundaiFlags.CANFD_NO_RADAR_DISABLE
| HyundaiFlags.CCNC
)
def _base_word(cp, *, experiences=(0,)):
owner = config_index(cp)
allowed = STOCK_AOL_FLAGS
try:
factory = CAR(cp.carFingerprint)
except ValueError:
return None
if (
cp.brand != 'hyundai'
or factory == CAR.HYUNDAI_IONIQ_6
or not factory.config.flags & HyundaiFlags.CANFD
or factory.config.flags & (HyundaiFlags.CANFD_ANGLE_STEERING | HyundaiFlags.NON_SCC)
or cp.passive
or cp.dashcamOnly
or cp.notCar
or cp.alternativeExperience not in experiences
or cp.steerControlType != CarParams.SteerControlType.torque
or not cp.flags & HyundaiFlags.CANFD
or int(cp.flags) & ~int(allowed)
or owner is None
):
return None
# EV identity is static; hybrid fuel is selected by the actual 0xFA fingerprint.
if bool(cp.flags & HyundaiFlags.EV) != bool(factory.config.flags & HyundaiFlags.EV):
return None
ev, hybrid = bool(cp.flags & HyundaiFlags.EV), bool(cp.flags & HyundaiFlags.HYBRID)
lka = bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG)
alt_lka = bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT)
alt_buttons = bool(cp.flags & HyundaiFlags.CANFD_ALT_BUTTONS)
camera = bool(cp.flags & HyundaiFlags.CANFD_CAMERA_SCC)
if (ev and hybrid) or (alt_lka and not lka) or (lka and camera):
return None
bus = CanBus(cp)
offset = 4 * owner
if (bus.ACAN - offset, bus.ECAN - offset, bus.CAM - offset) != ((0, 1, 2) if lka else (1, 0, 2)):
return None
word = (1 if ev else 2 if hybrid else 0) | (16 if lka else 0) | (128 if alt_lka else 0)
word |= (32 if alt_buttons else 0) | (8 if camera else 0)
if cp.flags & HyundaiFlags.CCNC and camera:
word |= 0x0400
if factory in CARNIVAL_CARS and alt_buttons:
word |= 0x2000
return word if word | STOCK_AOL_MARKER in STOCK_AOL_WORDS else None
def qualified(cp, *, marked_only=False):
word = _base_word(cp)
if word is None or cp.openpilotLongitudinalControl or not cp.pcmCruise:
return False
return cp.safetyConfigs[config_index(cp)].safetyParam in ((word | STOCK_AOL_MARKER,) if marked_only else (word, word | STOCK_AOL_MARKER))
LONG_AOL_WORDS = frozenset((0x0815, 0x0895))
LONG_EV_CARS = frozenset((CAR.HYUNDAI_IONIQ_5, CAR.HYUNDAI_KONA_EV_2ND_GEN))
def qualified_long(cp, *, marked_only=False):
if cp.carFingerprint == CAR.KIA_EV6:
from opendbc.car.hyundai.ev6_aol import qualified as ev6_qualified
return ev6_qualified(cp, marked_only=marked_only)
if cp.carFingerprint not in LONG_EV_CARS or not cp.openpilotLongitudinalControl or cp.pcmCruise:
return False
word = _base_word(cp, experiences=(0, 32))
if word not in (0x11, 0x91):
return False
raw = int(cp.safetyConfigs[0].safetyParam)
return (raw == (word | 0x804) and cp.alternativeExperience == 32) or (
not marked_only and raw == (word | 4) and cp.alternativeExperience == 0
)
@@ -0,0 +1,53 @@
"""Immutable captured status shared by exact first-generation ADRV senders."""
from dataclasses import dataclass
from opendbc.car.can_definitions import CanData
from opendbc.car.crc import CRC16_XMODEM
def _checksum(data: bytes | bytearray) -> int:
crc = 0
for byte in data[2:]:
crc = ((crc << 8) ^ CRC16_XMODEM[(crc >> 8) ^ byte]) & 0xFFFF
for byte in (0x51, 0):
crc = ((crc << 8) ^ CRC16_XMODEM[(crc >> 8) ^ byte]) & 0xFFFF
return crc ^ 0x9F5B
@dataclass(frozen=True)
class CapturedADRVTemplate:
data: bytes
def __post_init__(self):
if not isinstance(self.data, bytes) or len(self.data) != 32:
raise ValueError('Captured ADRV template requires immutable 32-byte capture')
if not any(self.data[3:]):
raise ValueError('Captured ADRV capture has no opaque status body')
if int.from_bytes(self.data[:2], 'little') != _checksum(self.data):
raise ValueError('Captured ADRV capture checksum is invalid')
@classmethod
def capture(cls, data: bytes):
return cls(data)
@property
def counter(self) -> int:
return self.data[2]
def _frame_data(self, frame: int, drive_gear: bool, bus: int) -> bytearray:
if type(frame) is not int or frame < 0:
raise ValueError('Captured ADRV frame must be a nonnegative integer')
if type(drive_gear) is not bool or type(bus) is not int or bus != 0:
raise ValueError('Captured ADRV requires physical Drive state and A-CAN bus zero')
data = bytearray(self.data)
data[2] = (self.counter + frame + 1) & 0xFF
data[3] = (data[3] & ~1) | int(drive_gear)
return data
@staticmethod
def _finish(data: bytearray, bus: int) -> CanData:
data[:2] = _checksum(data).to_bytes(2, 'little')
return CanData(0x51, bytes(data), bus)
def frame(self, frame: int, drive_gear: bool, bus: int = 0) -> CanData:
return self._finish(self._frame_data(frame, drive_gear, bus), bus)
@@ -0,0 +1,107 @@
"""Shared captured ADRV startup for explicitly selected first-generation cars."""
import time
from opendbc.car import Bus
from opendbc.can.parser import MAX_BAD_COUNTER
from opendbc.car.hyundai.ecu_startup import HyundaiECUStartup, Outcome
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import HyundaiFlags
from opendbc.car.hyundai.canfd_owner import topology_index
def first_generation(cp):
owner = topology_index(cp)
if owner is None:
return False
buses = CanBus(cp)
offset = 4 * owner
excluded = (HyundaiFlags.CANFD_ANGLE_STEERING | HyundaiFlags.CANFD_LKA_STEER_MSG_ALT |
HyundaiFlags.CANFD_ALT_BUTTONS | HyundaiFlags.CANFD_CAMERA_SCC | HyundaiFlags.CCNC |
HyundaiFlags.HYBRID)
return (cp.flags & HyundaiFlags.CANFD and
cp.flags & HyundaiFlags.EV and cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG and
not cp.flags & excluded and (buses.ACAN, buses.ECAN, buses.CAM) == (offset, offset + 1, offset + 2) and
not cp.passive and not cp.dashcamOnly and not cp.notCar)
class CapturedADRVStartup(HyundaiECUStartup):
def __init__(self, cp, callbacks, *, label, template_type):
buses = CanBus(cp)
super().__init__(cp, callbacks, address=0x730, bus=buses.ECAN, label=label)
self.template_bus = buses.ACAN
self.camera_bus = buses.CAM
self.template_type = template_type
self.template = None
def _capture(self):
floor = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
deadline = time.monotonic() + 1.
previous = None
while time.monotonic() < deadline:
for packet in self.callbacks[0]() or []:
stamp = int(getattr(packet, 'log_mono_time_ns', 0))
now = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
if not floor < stamp <= now or now - stamp > 150_000_000:
continue
for msg in packet:
if msg.src != self.template_bus or msg.address != 0x51:
continue
try:
candidate = self.template_type.capture(bytes(msg.dat))
except ValueError:
previous = None
continue
if previous is not None and stamp > previous[0] and candidate.counter == (previous[1].counter + 1) % 256:
return candidate
previous = stamp, candidate
time.sleep(.005)
return None
def prepare(self, *, admission):
# No ECU request is issued when current-session capture is unavailable.
self.template = self._capture()
return super().prepare(admission=admission if self.template is not None else lambda: False)
def configure(self, ci):
super().configure(ci)
if self.outcome is Outcome.SENT_UNCONFIRMED:
if self.template is None:
raise RuntimeError(f'{self.label} sent startup lacks its current-instance template')
ci.CC.adrv_template = self.template
def _sources(self, ci, now, *, floor):
state = ci.CS.out
if not state.canValid or state.canTimeout:
return False
pt = ci.can_parsers[Bus.pt]
cam = ci.can_parsers[Bus.cam]
if pt.bus != self.bus or cam.bus != self.camera_bus:
return False
names = ['ACCELERATOR', 'TCS', 'WHEEL_SPEEDS', 'MDPS', 'CRUISE_BUTTONS', ci.CS.gear_msg_canfd]
if not ci.CP.openpilotLongitudinalControl:
names.append('SCC_CONTROL')
for parser, sources in ((pt, set(names)), (cam, {'CAM_0x2a4'})):
for name in sources:
source = parser.message_states.get(parser.dbc.name_to_msg[name].address)
if source is None or not source.timestamps or source.counter_fail >= MAX_BAD_COUNTER:
return False
stamp = int(source.timestamps[-1])
# Existing ordinary buttons can pause ~0.5s: retain the 1Hz host policy.
if not floor < stamp <= now or now - stamp > source.timeout_threshold:
return False
return True
def _warm_stock(self, ci):
deadline = time.monotonic() + 3.
while time.monotonic() < deadline:
packets = self.callbacks[0]()
stamped = [(int(getattr(packet, 'log_mono_time_ns', 0)), list(packet)) for packet in packets]
if any(stamp <= 0 for stamp, _ in stamped):
raise RuntimeError(f'{self.label} stock warmup requires actual timestamped CAN')
ci.update(stamped)
if self._sources(ci, time.clock_gettime_ns(time.CLOCK_BOOTTIME), floor=self.floor_ns):
self.ready = True
return
time.sleep(.005)
self.outcome = Outcome.ABORT_UNCERTAIN
raise RuntimeError(f'{self.label} stock CANFD sources did not become fresh before publication')
+405 -36
View File
@@ -1,12 +1,37 @@
from opendbc.car.hyundai.canfd_angle_aol import qualified as qualified_angle_aol, command_allowed as angle_aol_command_allowed
from opendbc.car.hyundai.ev9_longitudinal import (
EV9LongitudinalPolicy,
LegacyAngleEnvelope,
lateral_request_allowed,
qualified as ev9_long_qualified,
update_blindspot_warning,
BlindspotWarningOutput,
)
from opendbc.car.hyundai import ev9_long_sender
from opendbc.car.common.filter_simple import FirstOrderFilter
from opendbc.car.lateral import get_max_angle_vm, get_max_angle_delta_vm
import numpy as np
from opendbc.can import CANPacker
from opendbc.car import Bus, DT_CTRL, make_tester_present_msg, structs
from opendbc.car.lateral import apply_driver_steer_torque_limits, common_fault_avoidance
from opendbc.car import Bus, DT_CTRL, make_tester_present_msg, rate_limit, structs
from opendbc.car.lateral import apply_driver_steer_torque_limits, apply_steer_angle_limits_vm, common_fault_avoidance
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.hyundai import hyundaicanfd, hyundaican
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import HyundaiFlags, Buttons, CarControllerParams, CAR
from opendbc.car.hyundai.ray_pedal import create_ray_pedal_command, ray_pedal_gas, ray_pedal_enabled
from opendbc.car.hyundai.ioniq6_longitudinal import Ioniq6LongitudinalPolicy
from opendbc.car.hyundai.canfd_dash_icons import CanFDDashIcons
from opendbc.car.hyundai.g90_longitudinal import G90LongitudinalPolicy
from opendbc.car.hyundai.gv70_longitudinal import is_gv70, scc_request, suppress_stock_cancel
from opendbc.car.hyundai.g90_lead import G90LeadState, eligible as g90_lead_eligible
from opendbc.car.hyundai.gv70_camera_lead import eligible as gv70_lead_eligible
from opendbc.car.hyundai.canfd_lead import select_lead, ABSENT
from opendbc.car.hyundai.torque_ev_scc import eligible as torque_ev_scc_eligible, options as torque_ev_scc_options, clock_boot_ns, fallback_lead
from opendbc.car.hyundai.ccnc_ev_stock import replacement_requested
from opendbc.car.hyundai.values import is_blended, HyundaiFlags, HyundaiSafetyFlags, Buttons, CarControllerParams, CAR
from opendbc.car.interfaces import CarControllerBase
from opendbc.car.vehicle_model import VehicleModel
EV9_OP_LONG_ANGLE_TARGET_MAX = 140.0
VisualAlert = structs.CarControl.HUDControl.VisualAlert
LongCtrlState = structs.CarControl.Actuators.LongControlState
@@ -24,13 +49,30 @@ MAX_ANGLE_CONSECUTIVE_FRAMES = 2
CANCEL_BUTTON_DELAY_FRAMES = 10
def angle_torque_reduction_gain(steering_torque, v_ego, lat_active, last_gain):
# The frozen ADAS angle protocol scales driver torque into a reduction gain.
if lat_active:
ceiling = np.interp(v_ego, [0.5, 1.5], [1.0, 0.85])
shelf = np.interp(v_ego, [2.0, 11.0], [0.45, 0.6])
floor = np.interp(v_ego, [2.0, 22.0], [0.1, 0.3])
bp1 = np.interp(v_ego, [2.0, 11.0], [75.0, 125.0])
bp2 = np.interp(v_ego, [2.0, 11.0], [125.0, 150.0])
bp3 = np.interp(v_ego, [2.0, 11.0], [175.0, 275.0])
bp4 = np.interp(v_ego, [2.0, 22.0], [400.0, 700.0])
target = np.interp(abs(steering_torque), [bp1, bp2, bp3, bp4], [ceiling, shelf, shelf, floor])
else:
target = 0.0
return round(rate_limit(target, last_gain, -0.014, 0.004) / 0.004) * 0.004
def process_hud_alert(enabled, fingerprint, hud_control):
sys_warning = (hud_control.visualAlert in (VisualAlert.steerRequired, VisualAlert.ldw))
# initialize to no line visible
# TODO: this is not accurate for all cars
sys_state = 1
if hud_control.leftLaneVisible and hud_control.rightLaneVisible or sys_warning: # HUD alert only display when LKAS status is active
if hud_control.leftLaneVisible and hud_control.rightLaneVisible or sys_warning:
# HUD alert only display when LKAS status is active
sys_state = 3 if enabled or sys_warning else 4
elif hud_control.leftLaneVisible:
sys_state = 5
@@ -55,20 +97,77 @@ class CarController(CarControllerBase):
self.params = CarControllerParams(CP)
self.packer = CANPacker(dbc_names[Bus.pt])
self.angle_limit_counter = 0
self.angle_vm = VehicleModel(CP) if CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING else None
self.apply_angle_last = 0.0
self.angle_gain_last = 0.0
self.accel_last = 0
self.apply_torque_last = 0
self.car_fingerprint = CP.carFingerprint
self.last_button_frame = 0
self.cancel_counter = 0
self.blended_longitudinal = None
self.blended_previous_lateral = False
self.blended_disengage_frame = None
self.last_ccnc_161_ts_ns = 0
self.last_ccnc_162_ts_ns = 0
self.last_ccnc_now_ns = 0
self.ccnc_baselined = False
self.ioniq6_longitudinal = (Ioniq6LongitudinalPolicy()
if CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and CP.openpilotLongitudinalControl else None)
self.ev9_longitudinal = EV9LongitudinalPolicy() if ev9_long_qualified(CP) else None
self.ev9_lead_inputs = None
self.ev9_legacy_envelope = LegacyAngleEnvelope() if self.ev9_longitudinal is not None else None
self.ev9_angle_filter = FirstOrderFilter(0.0, 0.2, DT_CTRL) if self.ev9_longitudinal is not None else None
self.ioniq6_accel_request = None
self.ioniq6_lead_inputs = None
self.torque_ev_scc_enabled = torque_ev_scc_eligible(CP)
self.torque_ev_lead_inputs = None
self.torque_ev_floor_boot_ns = clock_boot_ns() if self.torque_ev_scc_enabled else 0
self.adrv_template = None
self.gv70_stock_fallback = False
self.gv70_lead_inputs = None
self.gv70_lead_enabled = gv70_lead_eligible(CP)
self.g90_lead_inputs = None
self.g90_lead_state = G90LeadState() if g90_lead_eligible(CP) else None
self.g90_longitudinal = (G90LongitudinalPolicy()
if CP.carFingerprint == CAR.GENESIS_G90 and CP.openpilotLongitudinalControl else None)
ioniq6_status = (self.ioniq6_longitudinal is not None and
int(CP.safetyConfigs[-1].safetyParam) in (0x8015, 0x8095, 0x8815, 0x8895))
self.ioniq6_bsm_enabled = ioniq6_status
self.canfd_dash_icons = CanFDDashIcons() if ioniq6_status or self.torque_ev_scc_enabled else None
self.ioniq6_bsm_counter = 0
self.ioniq6_bsm_last_can_ns = 0
self._ray_pedal = ray_pedal_enabled(CP)
self._ray_pedal_gas_last = 0.0
self._ray_pedal_packer = CANPacker("hyundai_kia_ray_pedal") if self._ray_pedal else None
self._ray_lfa_8byte = CP.carFingerprint == CAR.KIA_RAY_EV and bool(CP.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CAN_REFRESH_MSGS)
self._ray_lfa_packer = CANPacker("hyundai_kia_ray_lfa") if self._ray_lfa_8byte else None
self._ray_lkas11_active = False
self._ray_lfa_icon = 0
self._ray_prev_lat_active = False
self._ray_disengage_frame = None
def update(self, CC, CS, now_nanos):
actuators = CC.actuators
if self.CP.carFingerprint == CAR.KIA_EV6_2025 and self.CP.dashcamOnly:
return actuators.as_builder(), []
hud_control = CC.hudControl
if self.CP.carFingerprint == CAR.KIA_RAY_EV:
if CC.latActive:
self._ray_disengage_frame = None
elif self._ray_prev_lat_active:
self._ray_disengage_frame = self.frame - 1
disengaging = self._ray_disengage_frame is not None and (self.frame - self._ray_disengage_frame) * DT_CTRL < 1.0
self._ray_lfa_icon = 2 if CC.enabled or CC.latActive else 3 if disengaging else 0
self._ray_prev_lat_active = CC.latActive
# steering torque
if self.CP.flags & HyundaiFlags.CANFD and self.angle_vm is None:
self.params = CarControllerParams(self.CP, CS.out.vEgoRaw)
new_torque = int(round(actuators.torque * self.params.STEER_MAX))
apply_torque = apply_driver_steer_torque_limits(new_torque, self.apply_torque_last, CS.out.steeringTorque, self.params)
apply_torque = (0 if self.angle_vm is not None else
apply_driver_steer_torque_limits(new_torque, self.apply_torque_last, CS.out.steeringTorque, self.params))
# >90 degree steering fault prevention
self.angle_limit_counter, apply_steer_req = common_fault_avoidance(abs(CS.out.steeringAngleDeg) >= MAX_ANGLE, CC.latActive,
@@ -80,17 +179,38 @@ class CarController(CarControllerBase):
self.apply_torque_last = apply_torque
# Original lead hysteresis counts100Hz controller updates, not50Hz SCC.
lead_data = None
if self.g90_lead_state is not None and self.g90_lead_inputs is not None:
lead_data = self.g90_lead_state.update(self.g90_lead_inputs.update(), hud_control.leadVisible)
# accel + longitudinal
accel = float(np.clip(actuators.accel, CarControllerParams.ACCEL_MIN, CarControllerParams.ACCEL_MAX))
accel = float(np.clip(actuators.accel, self.params.ACCEL_MIN, self.params.ACCEL_MAX))
stopping = actuators.longControlState == LongCtrlState.stopping
if self.ev9_longitudinal is not None:
accel = self.ev9_longitudinal.update(self.frame, float(np.clip(actuators.accel, -3.5, 3.5)), CS.out.vEgo,
CS.out.aEgo, actuators.longControlState, CC.enabled, CC.cruiseControl.override)
if self.ioniq6_longitudinal is not None:
self.ioniq6_accel_request = self.ioniq6_longitudinal.update(
self.frame, active=CC.enabled and CC.longActive, override=CC.cruiseControl.override,
accel=accel, speed=CS.out.vEgo, measured_accel=CS.out.aEgo,
control_state=actuators.longControlState, last_sent_accel=self.accel_last)
accel = self.ioniq6_accel_request.accel
stopping = self.ioniq6_accel_request.stopping
if self.g90_longitudinal is not None:
accel = self.g90_longitudinal.update(accel, CS.out.vEgo, actuators.longControlState, self.CP.openpilotLongitudinalControl)
set_speed_in_units = hud_control.setSpeed * (CV.MS_TO_KPH if CS.is_metric else CV.MS_TO_MPH)
can_sends = []
# *** common hyundai stuff ***
if self.blended_longitudinal is not None:
can_sends.extend(self.blended_longitudinal.tester_present(self.frame))
# tester present - w/ no response (keeps relevant ECU disabled)
if self.frame % 100 == 0 and not (self.CP.flags & HyundaiFlags.CANFD_CAMERA_SCC) and self.CP.openpilotLongitudinalControl:
if (self.frame % 100 == 0 and not (self.CP.flags & HyundaiFlags.CANFD_CAMERA_SCC) and
self.CP.openpilotLongitudinalControl and not self._ray_pedal and self.blended_longitudinal is None):
# for longitudinal control, either radar or ADAS driving ECU
addr, bus = 0x7d0, self.CAN.ECAN if self.CP.flags & HyundaiFlags.CANFD else 0
if self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG.value:
@@ -108,40 +228,77 @@ class CarController(CarControllerBase):
# *** CAN/CAN FD specific ***
if self.CP.flags & HyundaiFlags.CANFD:
can_sends.extend(self.create_canfd_msgs(apply_steer_req, apply_torque, set_speed_in_units, accel,
stopping, hud_control, CS, CC))
stopping, hud_control, CS, CC, now_nanos))
else:
# Hold torque with induced temporary fault when cutting the actuation bit
# FIXME: we don't use this with CAN FD?
torque_fault = CC.latActive and not apply_steer_req
can_sends.extend(self.create_can_msgs(apply_steer_req, apply_torque, torque_fault, set_speed_in_units, accel,
stopping, hud_control, actuators, CS, CC))
stopping, hud_control, actuators, CS, CC, lead_data=lead_data))
new_actuators = actuators.as_builder()
new_actuators.torque = apply_torque / self.params.STEER_MAX
new_actuators.torqueOutputCan = apply_torque
if self.angle_vm is not None:
new_actuators.steeringAngleDeg = self.apply_angle_last
new_actuators.accel = accel
self.frame += 1
return new_actuators, can_sends
def create_can_msgs(self, apply_steer_req, apply_torque, torque_fault, set_speed_in_units, accel, stopping, hud_control, actuators, CS, CC):
def create_can_msgs(self, apply_steer_req, apply_torque, torque_fault, set_speed_in_units, accel, stopping,
hud_control, actuators, CS, CC, *, lead_data=None):
can_sends = []
# HUD messages
sys_warning, sys_state, left_lane_warning, right_lane_warning = process_hud_alert(CC.enabled, self.car_fingerprint,
hud_enabled = CC.enabled or (self.CP.carFingerprint == CAR.KIA_STINGER_2022 and CC.latActive)
sys_warning, sys_state, left_lane_warning, right_lane_warning = process_hud_alert(hud_enabled, self.car_fingerprint,
hud_control)
can_sends.append(hyundaican.create_lkas11(self.packer, self.frame, self.CP, apply_torque, apply_steer_req,
torque_fault, CS.lkas11, sys_warning, sys_state, CC.enabled,
if is_blended(self.CP):
if CC.latActive:
self.blended_disengage_frame = None
elif self.blended_previous_lateral:
self.blended_disengage_frame = (self.frame - 1 if self.blended_longitudinal is not None
else self.frame)
self.blended_previous_lateral = CC.latActive
disengaging = self.blended_disengage_frame is not None and self.frame - self.blended_disengage_frame < 100
icon = 2 if CC.enabled or CC.latActive else 3 if disengaging else 1
if self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
can_sends.append(hyundaican.create_blended_steering(self.packer, self.CAN, apply_torque, apply_steer_req, icon))
if self.blended_longitudinal is not None and self.blended_longitudinal.owner.active():
can_sends.extend(hyundaican.create_lkas11_can_canfd_blended(
self.packer, self.frame, self.CP, apply_torque, apply_steer_req, torque_fault, CS.lkas11,
sys_warning, sys_state, CC.enabled, hud_control.leftLaneVisible, hud_control.rightLaneVisible,
left_lane_warning, right_lane_warning, {}, include_alerts=False, counter_mod=0xF,
fcw_opt_usm=2 if apply_steer_req or icon == 3 else 1))
if (self.frame % 5 == 0 and CS.out.canValid and not CS.out.canTimeout and
CS.blended_camera_current):
can_sends.append(hyundaicanfd.create_suppress_lfa(self.packer, self.CAN, CS.lfa_block_msg, False))
else:
can_sends.extend(hyundaican.create_lkas11_can_canfd_blended(
self.packer, self.frame, self.CP, apply_torque, apply_steer_req, torque_fault, CS.lkas11,
sys_warning, sys_state, CC.enabled, hud_control.leftLaneVisible, hud_control.rightLaneVisible,
left_lane_warning, right_lane_warning, CS.msg_364))
elif self.CP.carFingerprint != CAR.KIA_RAY_EV or self._ray_lkas11_active:
can_sends.append(hyundaican.create_lkas11(self.packer, self.frame, self.CP, apply_torque, apply_steer_req,
torque_fault, CS.lkas11, sys_warning, sys_state, hud_enabled,
hud_control.leftLaneVisible, hud_control.rightLaneVisible,
left_lane_warning, right_lane_warning))
left_lane_warning, right_lane_warning,
ray_lka_icon=(self._ray_lfa_icon or 1) if self.CP.carFingerprint == CAR.KIA_RAY_EV else None))
if self.CP.carFingerprint == CAR.KIA_RAY_EV:
self._ray_lkas11_active = True
# Button messages
if not self.CP.openpilotLongitudinalControl:
if self.cancel_counter > CANCEL_BUTTON_DELAY_FRAMES:
if not self.CP.openpilotLongitudinalControl or self._ray_pedal:
if self._ray_pedal and CC.enabled and CS.out.cruiseState.enabled:
if (self.frame - self.last_button_frame) * DT_CTRL > 0.04:
can_sends.append(hyundaican.create_clu11(self.packer, self.frame, CS.clu11, Buttons.CANCEL, self.CP))
self.last_button_frame = self.frame
elif self.cancel_counter > CANCEL_BUTTON_DELAY_FRAMES:
can_sends.append(hyundaican.create_clu11(self.packer, self.frame, CS.clu11, Buttons.CANCEL, self.CP))
elif CC.cruiseControl.resume:
elif CC.cruiseControl.resume and not self._ray_pedal and self.blended_longitudinal is None:
# send resume at a max freq of 10Hz
if (self.frame - self.last_button_frame) * DT_CTRL > 0.1:
# send 25 messages at a time to increases the likelihood of resume being accepted
@@ -149,64 +306,268 @@ class CarController(CarControllerBase):
if (self.frame - self.last_button_frame) * DT_CTRL >= 0.15:
self.last_button_frame = self.frame
if self.frame % 2 == 0 and self.CP.openpilotLongitudinalControl:
if self._ray_pedal and self.frame % 4 == 0:
pedal_active = (CC.longActive and CS.ray_pedal_valid and CS.ray_pedal_state == 0 and
not CC.cruiseControl.override and not CS.out.gasPressed and not CS.out.brakePressed)
self._ray_pedal_gas_last = ray_pedal_gas(self._ray_pedal_gas_last, CS.out.vEgo, accel, hud_control.setSpeed) if pedal_active else 0.0
can_sends.append(create_ray_pedal_command(self._ray_pedal_packer, self._ray_pedal_gas_last, (self.frame // 4) & 0xF))
if self.blended_longitudinal is not None:
can_sends.extend(self.blended_longitudinal.commands(self.frame, CC, CS, accel, stopping, set_speed_in_units))
if self.frame % 2 == 0 and self.CP.openpilotLongitudinalControl and not self._ray_pedal and self.blended_longitudinal is None:
# TODO: unclear if this is needed
jerk = 3.0 if actuators.longControlState == LongCtrlState.pid else 1.0
use_fca = self.CP.flags & HyundaiFlags.USE_FCA.value
can_sends.extend(hyundaican.create_acc_commands(self.packer, CC.enabled, accel, jerk, int(self.frame / 2),
hud_control, set_speed_in_units, stopping,
CC.cruiseControl.override, use_fca, self.CP))
CC.cruiseControl.override, use_fca, self.CP, lead_data=lead_data))
# 20 Hz LFA MFA message
if self.frame % 5 == 0 and self.CP.flags & HyundaiFlags.SEND_LFA.value:
can_sends.append(hyundaican.create_lfahda_mfc(self.packer, CC.enabled))
if self.frame % 5 == 0 and (self.CP.flags & HyundaiFlags.SEND_LFA.value or
self.blended_longitudinal is not None and self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG):
if self._ray_lfa_8byte:
can_sends.append(hyundaican.create_ray_lfahda_mfc(self._ray_lfa_packer, CC.latActive, self._ray_lfa_icon))
elif is_blended(self.CP):
lfa_icon = 2 if CC.enabled or CC.latActive else 3 if disengaging else 0
can_sends.append(hyundaican.create_blended_lfahda(self.packer, self.CAN, self.frame, lfa_icon))
else:
can_sends.append(hyundaican.create_lfahda_mfc(self.packer, CC.enabled))
# 5 Hz ACC options
if self.frame % 20 == 0 and self.CP.openpilotLongitudinalControl:
if self.frame % 20 == 0 and self.CP.openpilotLongitudinalControl and not self._ray_pedal and self.blended_longitudinal is None:
can_sends.extend(hyundaican.create_acc_opt(self.packer, self.CP))
# 2 Hz front radar options
if self.frame % 50 == 0 and self.CP.openpilotLongitudinalControl:
if self.frame % 50 == 0 and self.CP.openpilotLongitudinalControl and not self._ray_pedal and self.blended_longitudinal is None:
can_sends.append(hyundaican.create_frt_radar_opt(self.packer))
return can_sends
def create_canfd_msgs(self, apply_steer_req, apply_torque, set_speed_in_units, accel, stopping, hud_control, CS, CC):
def create_canfd_msgs(self, apply_steer_req, apply_torque, set_speed_in_units, accel, stopping, hud_control, CS, CC, now_nanos):
can_sends = []
canfd_lfa_icon = (self.canfd_dash_icons.update(self.frame, CC.enabled, CC.latActive)
if self.canfd_dash_icons is not None else None)
lka_steering = self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG
lka_steering_long = lka_steering and self.CP.openpilotLongitudinalControl
ccnc_non_lka = bool(self.CP.flags & HyundaiFlags.CCNC and not lka_steering)
if ccnc_non_lka:
# A new controller instance or reversed clock must wait for a fresh pair.
if not self.ccnc_baselined or now_nanos < self.last_ccnc_now_ns:
self.last_ccnc_161_ts_ns = CS.ccnc_161_ts_ns
self.last_ccnc_162_ts_ns = CS.ccnc_162_ts_ns
self.ccnc_baselined = True
self.last_ccnc_now_ns = now_nanos
ev9_long = self.ev9_longitudinal is not None
angle_aol = qualified_angle_aol(self.CP, marked_only=True)
angle_aol_active = angle_aol and CC.latActive and angle_aol_command_allowed(self.CP, CS.out)
ccnc_ev_stock = self.CP.carFingerprint in (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9) and not ev9_long
ccnc_ev_replacement = replacement_requested(self.CP, CC, CS.out) if ccnc_ev_stock else False
# steering control
can_sends.extend(hyundaicanfd.create_steering_messages(self.packer, self.CP, self.CAN, CC.enabled, apply_steer_req, apply_torque))
if ev9_long:
drive = CS.out.gearShifter == structs.CarState.GearShifter.drive
measured = CS.angle_steering_angle
safety_speed = max(CS.out.vEgoRaw - 1.0, 1.0)
bound = min(get_max_angle_vm(safety_speed, self.ev9_legacy_envelope, self.params),
get_max_angle_vm(safety_speed, self.angle_vm, self.params))
ev9_lateral_allowed = lateral_request_allowed(CC, CS.out, CS.angle_steering_fault)
active = bool(ev9_lateral_allowed and CC.latActive and abs(measured) <= bound and abs(self.apply_angle_last) <= bound)
self.ev9_angle_filter.update_alpha(float(np.interp(CS.out.vEgo, [5.0, 10.0, 20.0], [0.2, 0.1, 0.0])))
desired = self.ev9_angle_filter.update(float(np.clip(CC.actuators.steeringAngleDeg,
-EV9_OP_LONG_ANGLE_TARGET_MAX, EV9_OP_LONG_ANGLE_TARGET_MAX)))
# Preserve the original two VM passes and its final tolerance-envelope delta.
angle = apply_steer_angle_limits_vm(desired, self.apply_angle_last, CS.out.vEgoRaw, measured,
active, self.params, self.angle_vm)
angle = apply_steer_angle_limits_vm(angle or desired, self.apply_angle_last, CS.out.vEgoRaw, measured,
active, self.params, self.ev9_legacy_envelope)
if active:
delta = min(get_max_angle_delta_vm(safety_speed, self.ev9_legacy_envelope, self.params),
self.params.ANGLE_LIMITS.MAX_ANGLE_RATE)
angle = float(np.clip(angle, self.apply_angle_last - delta, self.apply_angle_last + delta))
# Exact typed EV9 safety envelope is an additional bound, not a new tune.
self.apply_angle_last = apply_steer_angle_limits_vm(angle, self.apply_angle_last, safety_speed, measured,
active, self.params, self.angle_vm)
self.angle_gain_last = angle_torque_reduction_gain(CS.out.steeringTorque, CS.out.vEgoRaw, active, self.angle_gain_last)
if not active:
self.ev9_angle_filter.x = self.apply_angle_last
# Native checks health even for an inactive CB; do not emit stale measurements.
if CS.out.canValid and not CS.out.canTimeout:
if drive:
can_sends.append(ev9_long_sender.create_angle_adas_cmd(self.packer, self.CAN, self.apply_angle_last,
active, self.angle_gain_last))
else:
can_sends.extend(ev9_long_sender.create_inactive_angle_steering_messages(self.packer, self.CAN,
self.apply_angle_last))
elif self.angle_vm is not None:
drive_gear_required = self.CP.carFingerprint in (CAR.HYUNDAI_AZERA_HEV_7TH_GEN,
CAR.KIA_SORENTO_HEV_4TH_GEN_LFA2,
CAR.KIA_SPORTAGE_HEV_2026,
CAR.HYUNDAI_SANTA_FE_HEV_5TH_GEN,
CAR.KIA_SPORTAGE_2026)
angle_active = bool(CC.latActive and CS.out.cruiseState.enabled and not CS.out.brakePressed and
not CS.out.gasPressed and not CS.out.steerFaultTemporary and
(not (self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT or drive_gear_required) or
CS.out.gearShifter == structs.CarState.GearShifter.drive))
if angle_aol:
angle_active = angle_aol_active
if ccnc_ev_stock:
angle_active = ccnc_ev_replacement
desired_angle = float(np.clip(CC.actuators.steeringAngleDeg, -self.params.ANGLE_LIMITS.STEER_ANGLE_MAX,
self.params.ANGLE_LIMITS.STEER_ANGLE_MAX))
safety_speed = max(CS.out.vEgoRaw - 1.0, 1.0)
self.apply_angle_last = apply_steer_angle_limits_vm(
desired_angle, self.apply_angle_last, safety_speed, CS.out.steeringAngleDeg,
angle_active, self.params, self.angle_vm)
self.angle_gain_last = angle_torque_reduction_gain(CS.out.steeringTorque, CS.out.vEgoRaw,
angle_active, self.angle_gain_last)
if not ccnc_ev_stock or ccnc_ev_replacement:
can_sends.extend(
hyundaicanfd.create_angle_steering_messages(
self.packer,
self.CP,
self.CAN,
CC.enabled or (angle_aol and angle_active),
angle_active,
self.apply_angle_last,
self.angle_gain_last,
CS.angle_lkas_status,
CS.out.steeringAngleDeg,
)
)
else:
steering_status_active = CC.enabled
if self.ioniq6_longitudinal is not None and self.CP.safetyConfigs[-1].safetyParam in (0x8815, 0x8895):
# AOL may steer while ordinary cruise engagement remains off.
steering_status_active |= CC.latActive
can_sends.extend(hyundaicanfd.create_steering_messages(self.packer, self.CP, self.CAN,
steering_status_active, apply_steer_req, apply_torque))
# prevent LFA from activating on LKA steering cars by sending "no lane lines detected" to ADAS ECU
if self.frame % 5 == 0 and lka_steering:
if (
self.frame % 5 == 0
and lka_steering
and (not angle_aol or angle_aol_active)
and (not ccnc_ev_stock or ccnc_ev_replacement)
and (not ev9_long or ev9_lateral_allowed)
):
can_sends.append(hyundaicanfd.create_suppress_lfa(self.packer, self.CAN, CS.lfa_block_msg,
self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT))
# LFA and HDA icons
if self.frame % 5 == 0 and (not lka_steering or lka_steering_long):
can_sends.append(hyundaicanfd.create_lfahda_cluster(self.packer, self.CAN, CC.enabled))
if self.frame % 5 == 0 and (not lka_steering or lka_steering_long) and not ev9_long:
if ccnc_non_lka:
# Re-emit one paired camera display sample at most once. Missing or
# held optional frames must not be replaced by a zero-filled payload.
pair_ts = max(CS.ccnc_161_ts_ns, CS.ccnc_162_ts_ns)
fresh_pair = (CS.ccnc_161_ts_ns > self.last_ccnc_161_ts_ns and
CS.ccnc_162_ts_ns > self.last_ccnc_162_ts_ns and
pair_ts <= now_nanos and
now_nanos - min(CS.ccnc_161_ts_ns, CS.ccnc_162_ts_ns) <= 50_000_000 and
abs(CS.ccnc_161_ts_ns - CS.ccnc_162_ts_ns) <= 50_000_000)
if fresh_pair:
support_current = (CS.ccnc_1b5_ts_ns > 0 and CS.ccnc_1b5_ts_ns <= now_nanos and
abs(CS.ccnc_1b5_ts_ns - pair_ts) <= 50_000_000)
can_sends.extend(hyundaicanfd.create_ccnc(self.packer, self.CAN, self.CP.openpilotLongitudinalControl,
CC.enabled, hud_control, CC.leftBlinker, CC.rightBlinker,
CS.ccnc_161.copy(), CS.ccnc_162.copy(), CS.ccnc_1b5 if support_current else None,
CS.is_metric, CS.out, CS.out.cruiseState.available, CC.latActive))
self.last_ccnc_161_ts_ns = CS.ccnc_161_ts_ns
self.last_ccnc_162_ts_ns = CS.ccnc_162_ts_ns
else:
can_sends.append(hyundaicanfd.create_lfahda_cluster(self.packer, self.CAN, CC.enabled, lfa_icon=canfd_lfa_icon))
# blinkers
if lka_steering and self.CP.flags & HyundaiFlags.CANFD_ENABLE_BLINKERS:
can_sends.extend(hyundaicanfd.create_spas_messages(self.packer, self.CAN, CC.leftBlinker, CC.rightBlinker))
if self.CP.openpilotLongitudinalControl:
if lka_steering:
can_sends.extend(hyundaicanfd.create_adrv_messages(self.packer, self.CAN, self.frame))
else:
if ev9_long:
left_escalated = CS.left_blindspot_from_radar and CC.leftBlinker and not CC.rightBlinker
right_escalated = CS.right_blindspot_from_radar and CC.rightBlinker and not CC.leftBlinker
left_warning, right_warning = BlindspotWarningOutput(), BlindspotWarningOutput()
if self.frame % 5 == 0:
left_warning = update_blindspot_warning(self.ev9_longitudinal.left_warning, left_escalated, CC.leftBlinker)
right_warning = update_blindspot_warning(self.ev9_longitudinal.right_warning, right_escalated, CC.rightBlinker)
can_sends.extend(ev9_long_sender.create_ccnc_adrv_messages(
self.packer, self.CP, self.CAN, self.frame, CC.enabled, CS.out.cruiseState.available, hud_control, CS.out,
CS.is_metric, CC.latActive or CC.enabled, active and self.angle_gain_last != 0.0 and not CS.out.steeringPressed,
CS.left_blindspot_from_radar, CS.right_blindspot_from_radar, drive_gear=drive, hba_icon=CS.hba_icon,
left_escalated=left_escalated, right_escalated=right_escalated,
left_warning_lamp=left_warning.mirror_lamp_active, right_warning_lamp=right_warning.mirror_lamp_active,
left_sound_active=left_warning.sound_active, right_sound_active=right_warning.sound_active))
can_sends.append(ev9_long_sender.create_radar_heartbeat(self.frame, CS.out.brakePressed, CS.out.gasPressed))
elif lka_steering:
can_sends.extend(hyundaicanfd.create_adrv_messages(
self.packer, self.CAN, self.frame, template=self.adrv_template,
drive_gear=CS.out.gearShifter == structs.CarState.GearShifter.drive,
speed=CS.out.vEgoRaw if self.CP.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN else None))
if self.ioniq6_longitudinal is not None and self.frame % 4 == 0:
can_sends.append(hyundaicanfd.create_ioniq6_radar_heartbeat(self.frame // 4, CS.out.brakePressed, CS.out.gasPressed))
if self.ioniq6_bsm_enabled and self.frame % 5 == 0:
sources = getattr(CS, "ioniq6_bsm_sources", None)
can_now_ns = getattr(CS, "ioniq6_bsm_now_ns", 0)
status = (sources.status(can_now_ns) if sources is not None and can_now_ns > self.ioniq6_bsm_last_can_ns else None)
if status is not None:
can_sends.extend(hyundaicanfd.create_ioniq6_blindspot_status(self.ioniq6_bsm_counter, status))
self.ioniq6_bsm_counter = (self.ioniq6_bsm_counter + 1) & 0xff
self.ioniq6_bsm_last_can_ns = can_now_ns
elif not ccnc_non_lka:
can_sends.extend(hyundaicanfd.create_fca_warning_light(self.packer, self.CAN, self.frame))
if self.frame % 2 == 0:
can_sends.append(hyundaicanfd.create_acc_control(self.packer, self.CAN, CC.enabled, self.accel_last, accel, stopping, CC.cruiseControl.override,
set_speed_in_units, hud_control))
acc_enabled = CC.enabled
acc_options = {}
if self.torque_ev_scc_enabled:
lead = (self.torque_ev_lead_inputs.update(CS.torque_ev_camera_lead, hud_control.leadVisible, now_nanos)
if self.torque_ev_lead_inputs is not None else
fallback_lead(CS.torque_ev_camera_lead, hud_control.leadVisible, self.torque_ev_floor_boot_ns))
acc_options = torque_ev_scc_options(CS.out.cruiseState.available, CC.actuators.longControlState, lead)
elif is_gv70(self.CP):
request = scc_request(CC.enabled, CC.cruiseControl.override, stopping, accel, self.accel_last)
accel = request.accel
acc_options = {"direct_accel": True, "raw_accel": request.raw_accel,
"jerk_upper": request.jerk_upper, "jerk_lower": request.jerk_lower}
elif self.ioniq6_accel_request is not None:
lead = (self.ioniq6_lead_inputs.update(CS.ioniq6_camera_lead, hud_control.leadVisible, now_nanos)
if self.ioniq6_lead_inputs is not None else
select_lead(None, None, hud_visible=hud_control.leadVisible, observed_ns=now_nanos, epoch_floor_ns=0))
acc_enabled = CC.enabled
acc_options = {"direct_accel": True, "main_mode_acc": int(CS.out.cruiseState.available),
"jerk_upper": self.ioniq6_accel_request.jerk_upper, "jerk_lower": self.ioniq6_accel_request.jerk_lower,
"lead_visible": lead.visible, "lead_distance": lead.distance, "lead_rel_speed": lead.relative_speed}
if self.gv70_lead_enabled:
lead = self.gv70_lead_inputs.update(CS.gv70_camera_lead, hud_control.leadVisible, now_nanos) \
if self.gv70_lead_inputs is not None else ABSENT
acc_options.update(lead_distance=lead.distance, lead_rel_speed=lead.relative_speed, lead_visible=lead.visible)
if ev9_long:
tuning = self.ev9_longitudinal.tuning
dynamic = CC.actuators.longControlState in (LongCtrlState.starting, LongCtrlState.pid, LongCtrlState.stopping)
jerk_lower = tuning.jerk_lower if dynamic else (5.0 if CC.enabled else 1.0)
jerk_upper = tuning.jerk_upper if dynamic else (3.0 if CC.actuators.longControlState == LongCtrlState.pid else 1.0)
zero = self.ev9_longitudinal.update_stop(CC.enabled, CC.cruiseControl.override, stopping, CS.out.vEgo)
if zero is not None:
accel = zero
lead = (self.ev9_lead_inputs.update(CS.ev9_camera_lead, hud_control.leadVisible, now_nanos)
if self.ev9_lead_inputs is not None else ABSENT)
lead_visible, lead_distance, lead_rel_speed = lead.visible, lead.distance, lead.relative_speed
accel = float(np.clip(accel, -3.5, 2.2))
can_sends.append(ev9_long_sender.create_ccnc_acc_control(
self.packer, self.CAN, CC.enabled, accel, self.ev9_longitudinal.stop.stop_request,
self.ev9_longitudinal.stop.cruise_standstill, CC.cruiseControl.override, set_speed_in_units,
int(CS.out.cruiseState.available), lead_distance, lead_rel_speed, lead_visible, CS.out.vEgo,
jerk_lower=jerk_lower, jerk_upper=jerk_upper))
else:
can_sends.append(hyundaicanfd.create_acc_control(self.packer, self.CAN, acc_enabled, self.accel_last, accel, stopping, CC.cruiseControl.override,
set_speed_in_units, hud_control, **acc_options))
self.accel_last = accel
else:
# button presses
if (self.frame - self.last_button_frame) * DT_CTRL > 0.25:
# cruise cancel
if CC.cruiseControl.cancel:
if CC.cruiseControl.cancel and not suppress_stock_cancel(
self.CP, CS.out.brakePressed, CC.latActive, stock_fallback=self.gv70_stock_fallback):
# Here we send ACC message to cancel, not buttons. Don't delay
if self.CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS:
can_sends.append(hyundaicanfd.create_acc_cancel(self.packer, self.CP, self.CAN, CS.cruise_info))
@@ -218,8 +579,16 @@ class CarController(CarControllerBase):
# cruise standstill resume
elif CC.cruiseControl.resume:
if self.CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS:
# TODO: resume for alt button cars
if (not self.CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING and
self.CP.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CARNIVAL_ALT_RESUME):
source = CS.cruise_buttons_alt_msg
source_age = now_nanos - CS.cruise_buttons_alt_ts_ns
if (source and CS.cruise_buttons_alt_ts_ns > 0 and 0 <= source_age <= 100_000_000 and
source["CRUISE_BUTTONS"] == 0 and source["ADAPTIVE_CRUISE_MAIN_BTN"] == 0 and
source["NORMAL_CRUISE_MAIN_BTN"] == 0 and source["LDA_BTN"] == 0):
can_sends.append(hyundaicanfd.create_carnival_alt_resume(self.packer, self.CP, self.CAN, source))
self.last_button_frame = self.frame
elif self.CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS:
pass
else:
for _ in range(20):
+388 -30
View File
@@ -1,3 +1,10 @@
from opendbc.car.hyundai.blended_stock_aol import qualified as qualified_blended_stock, qualified_alpha as qualified_blended_alpha, MixedStockLkasSources
from opendbc.car.hyundai.classic_long_aol import qualified as qualified_classic_long
from opendbc.car.hyundai.classic_scc_aol import qualified as qualified_classic_scc, ClassicSccLkasSources
from opendbc.car.hyundai.ev9_camera_lead import EV9CameraLead
from opendbc.car.hyundai.canfd_camera_lead import CANFDCameraLead
from opendbc.car.hyundai.torque_ev_scc import eligible as torque_ev_scc_eligible
from opendbc.car.hyundai.ev9_longitudinal import qualified as ev9_long_qualified
from collections import deque
import copy
import math
@@ -6,14 +13,30 @@ from opendbc.can import CANDefine, CANParser
from opendbc.car import Bus, create_button_events, structs
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import HyundaiFlags, CAR, DBC, Buttons, CarControllerParams
from opendbc.car.hyundai.non_scc_aol import NonSccLkasSources, qualified as qualified_non_scc
from opendbc.car.hyundai.gv70_camera_lead import GV70CameraLead, eligible as gv70_lead_eligible
from opendbc.car.hyundai.ioniq6_bsm import Ioniq6BlindspotSources
from opendbc.car.hyundai.ray_pedal import ray_pedal_enabled
from opendbc.car.hyundai.values import is_blended, HyundaiFlags, HyundaiSafetyFlags, CAR, DBC, Buttons, CarControllerParams
from opendbc.car.interfaces import CarStateBase
from opendbc.car.dashboard_speed_limit import Tracker as LimitTracker, hyundai_canfd_sign, parser_expiry
ButtonType = structs.CarState.ButtonEvent.Type
PREV_BUTTON_SAMPLES = 8
CLUSTER_SAMPLE_RATE = 20 # frames
STANDSTILL_THRESHOLD = 12 * 0.03125
IONIQ_6_BSM_MAX_AGE_NS = 200_000_000
IONIQ_6_BSM_LEFT_MASK = 0x10
IONIQ_6_BSM_RIGHT_MASK = 0x08
def ioniq_6_bsm_source_fresh(now_ns: int, source_ns: int) -> bool:
return source_ns > 0 and 0 <= now_ns - source_ns <= IONIQ_6_BSM_MAX_AGE_NS
def decode_ioniq_6_corner_bsm(state: int) -> tuple[bool, bool]:
return bool(state & IONIQ_6_BSM_LEFT_MASK), bool(state & IONIQ_6_BSM_RIGHT_MASK)
# Cancel button can sometimes be ACC pause/resume button, main button can also enable on some cars
ENABLE_BUTTONS = (Buttons.RES_ACCEL, Buttons.SET_DECEL, Buttons.CANCEL)
@@ -21,14 +44,45 @@ BUTTONS_DICT = {Buttons.RES_ACCEL: ButtonType.accelCruise, Buttons.SET_DECEL: Bu
Buttons.GAP_DIST: ButtonType.gapAdjustCruise, Buttons.CANCEL: ButtonType.cancel}
def get_non_scc_cruise_signals(flags: int, car=None) -> tuple[str, str, str, str, str, str]:
if car == CAR.KIA_RAY_EV:
return "LABEL11", "CC_React", "LABEL11", "CC_Engaged", "E_EMS11", "Cruise_Limit_Target"
if flags & HyundaiFlags.EV:
return "LABEL11", "CC_React", "EMS12", "ACC_ACT", "E_EMS11", "Cruise_Limit_Target"
if flags & HyundaiFlags.HYBRID:
return "E_CRUISE_CONTROL", "CRUISE_LAMP_M", "E_CRUISE_CONTROL", "CRUISE_LAMP_S", "ELECT_GEAR", "SLC_SET_SPEED"
return "EMS16", "CRUISE_LAMP_M", "EMS16", "CRUISE_LAMP_S", "LVR12", "CF_Lvr_CruiseSet"
class CarState(CarStateBase):
def __init__(self, CP):
super().__init__(CP)
self.forte_lkas_sources = NonSccLkasSources() if qualified_non_scc(CP) and CP.flags & HyundaiFlags.HAS_LDA_BUTTON else None
if (qualified_classic_scc(CP) or qualified_classic_long(CP)) and CP.flags & HyundaiFlags.HAS_LDA_BUTTON:
self.forte_lkas_sources = ClassicSccLkasSources(CP.carFingerprint)
if qualified_blended_stock(CP) or qualified_blended_alpha(CP):
self.forte_lkas_sources = MixedStockLkasSources(CP)
self.ev9_long = ev9_long_qualified(CP)
self.ev9_camera_lead = EV9CameraLead(CP) if self.ev9_long else None
self.ioniq6_camera_lead = (CANFDCameraLead(CP, CanBus(CP).ECAN)
if CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and CP.openpilotLongitudinalControl else None)
self.torque_ev_camera_lead = CANFDCameraLead(CP, CanBus(CP).ECAN) if torque_ev_scc_eligible(CP) else None
self.angle_steering_angle = 0.0
self.angle_steering_fault = False
self.hba_icon = 0
self.left_blindspot_from_radar = False
self.right_blindspot_from_radar = False
self.gv70_camera_lead = GV70CameraLead(CP) if gv70_lead_eligible(CP) else None
can_define = CANDefine(DBC[CP.carFingerprint][Bus.pt])
self.cruise_buttons: deque = deque([Buttons.NONE] * PREV_BUTTON_SAMPLES, maxlen=PREV_BUTTON_SAMPLES)
self.main_buttons: deque = deque([Buttons.NONE] * PREV_BUTTON_SAMPLES, maxlen=PREV_BUTTON_SAMPLES)
self.lda_button = 0
self.ev6_aol_samples = ()
self.ev6_aol_sample_stamp_ns = 0
self.ev6_aol_timeout_ns = 0
self.ev6_aol_source_healthy = False
self.left_paddle = 0
self.gear_msg_canfd = "ACCELERATOR" if CP.flags & HyundaiFlags.EV else \
"GEAR_ALT" if CP.flags & HyundaiFlags.CANFD_ALT_GEARS else \
@@ -54,14 +108,30 @@ class CarState(CarStateBase):
"CRUISE_BUTTONS"
self.is_metric = False
self.buttons_counter = 0
self.cruise_buttons_alt_msg = {}
self.cruise_buttons_alt_ts_ns = 0
self.cruise_info = {}
self.angle_lkas_status = {}
self.ccnc_161 = {}
self.ccnc_162 = {}
self.ccnc_1b5 = {}
self.ccnc_161_ts_ns = 0
self.ccnc_162_ts_ns = 0
self.ccnc_1b5_ts_ns = 0
self.dashboard_limit = LimitTracker()
self.ioniq6_bsm_sources = (Ioniq6BlindspotSources()
if CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and CP.openpilotLongitudinalControl else None)
self.ioniq6_bsm_now_ns = 0
# On some cars, CLU15->CF_Clu_VehicleSpeed can oscillate faster than the dash updates. Sample at 5 Hz
self.cluster_speed = 0
self.cluster_speed_counter = CLUSTER_SAMPLE_RATE
self.params = CarControllerParams(CP)
self.blended_camera_current = False
self.ray_pedal_valid = False
self.ray_pedal_state = 5
def recent_button_interaction(self) -> bool:
# On some newer model years, the CANCEL button acts as a pause/resume button based on the PCM state
@@ -69,8 +139,42 @@ class CarState(CarStateBase):
# Main button also can trigger an engagement on these cars
return any(btn in ENABLE_BUTTONS for btn in self.cruise_buttons) or any(self.main_buttons)
def blended_cancel_sources_current(self, cp, *, cancel=True):
if not (is_blended(self.CP) and self.CP.openpilotLongitudinalControl):
return False
now = cp._last_update_nanos
for name in ('TCS13', 'EMS16', 'CLU11'):
source = cp.message_states.get(cp.dbc.name_to_msg[name].address)
if source is None or not source.timestamps:
return False
stamp = source.timestamps[-1]
if not 0 < stamp <= now or now - stamp > source.timeout_threshold:
return False
signals = [('TCS13', 'ACCEnable'), ('TCS13', 'DriverOverride'), ('EMS16', 'CF_Ems_AclAct')]
if cancel:
signals.append(('TCS13', 'ACC_REQ'))
return not any(cp.vl[name][signal] != 0 or any(value != 0 for value in cp.vl_all[name][signal])
for name, signal in signals)
def create_cruise_button_events(self, cur_button, prev_button, samples=()):
# Preserve every real edge in an alpha parser batch, including a complete
# Cancel press/release while the host is already enabled.
if is_blended(self.CP) and self.CP.openpilotLongitudinalControl:
events = []
for button in samples:
events.extend(create_button_events(int(button), prev_button, BUTTONS_DICT))
prev_button = int(button)
return events
return create_button_events(cur_button, prev_button, BUTTONS_DICT)
def finalize_blended_cancel(self, ret):
if is_blended(self.CP) and self.CP.openpilotLongitudinalControl:
# Card's after-state owner can grant one later acknowledged enable credit.
ret.buttonEnable = False
def update(self, can_parsers) -> structs.CarState:
cp = can_parsers[Bus.pt]
cp_pedal = can_parsers.get(Bus.party)
cp_cam = can_parsers[Bus.cam]
if self.CP.flags & HyundaiFlags.CANFD:
@@ -117,25 +221,34 @@ class CarState(CarStateBase):
ret.steerFaultTemporary = cp.vl["MDPS12"]["CF_Mdps_ToiUnavail"] != 0 or cp.vl["MDPS12"]["CF_Mdps_ToiFlt"] != 0
# cruise state
if self.CP.openpilotLongitudinalControl:
non_scc = bool(self.CP.flags & HyundaiFlags.NON_SCC)
if non_scc:
available_msg, available_sig, enabled_msg, enabled_sig, speed_msg, speed_sig = get_non_scc_cruise_signals(self.CP.flags, self.CP.carFingerprint)
ret.cruiseState.available = cp.vl[available_msg][available_sig] != 0
ret.cruiseState.enabled = cp.vl[enabled_msg][enabled_sig] != 0
ret.cruiseState.standstill = False
ret.cruiseState.nonAdaptive = False
ret.cruiseState.speed = cp.vl[speed_msg][speed_sig] * speed_conv
elif self.CP.openpilotLongitudinalControl:
# These are not used for engage/disengage since openpilot keeps track of state using the buttons
ret.cruiseState.available = cp.vl["TCS13"]["ACCEnable"] == 0
ret.cruiseState.enabled = cp.vl["TCS13"]["ACC_REQ"] == 1
ret.cruiseState.standstill = False
ret.cruiseState.nonAdaptive = False
else:
ret.cruiseState.available = cp_cruise.vl["SCC11"]["MainMode_ACC"] == 1
scc_status = "SCC12" if is_blended(self.CP) else "SCC11"
ret.cruiseState.available = cp_cruise.vl[scc_status]["MainMode_ACC"] == 1
ret.cruiseState.enabled = cp_cruise.vl["SCC12"]["ACCMode"] != 0
ret.cruiseState.standstill = cp_cruise.vl["SCC11"]["SCCInfoDisplay"] == 4.
ret.cruiseState.nonAdaptive = cp_cruise.vl["SCC11"]["SCCInfoDisplay"] == 2. # Shows 'Cruise Control' on dash
ret.cruiseState.speed = cp_cruise.vl["SCC11"]["VSetDis"] * speed_conv
ret.cruiseState.standstill = cp_cruise.vl[scc_status]["SCCInfoDisplay"] == 4.
ret.cruiseState.nonAdaptive = cp_cruise.vl[scc_status]["SCCInfoDisplay"] == 2. # Shows 'Cruise Control' on dash
ret.cruiseState.speed = cp_cruise.vl[scc_status]["VSetDis"] * speed_conv
ret.brakePressed = cp.vl["TCS13"]["DriverOverride"] == 2 # 2 includes regen braking by user on HEV/EV
ret.brakeHoldActive = cp.vl["TCS15"]["AVH_LAMP"] == 2 # 0 OFF, 1 ERROR, 2 ACTIVE, 3 READY
ret.parkingBrake = cp.vl["TCS13"]["PBRAKE_ACT"] == 1
ret.espDisabled = cp.vl["TCS11"]["TCS_PAS"] == 1
ret.espActive = cp.vl["TCS11"]["ABS_ACT"] == 1
ret.accFaulted = cp.vl["TCS13"]["ACCEnable"] != 0 # 0 ACC CONTROL ENABLED, 1-3 ACC CONTROL DISABLED
ret.accFaulted = False if non_scc else cp.vl["TCS13"]["ACCEnable"] != 0
if self.CP.flags & (HyundaiFlags.HYBRID | HyundaiFlags.EV | HyundaiFlags.FCEV):
if self.CP.flags & HyundaiFlags.FCEV:
@@ -147,6 +260,17 @@ class CarState(CarStateBase):
else:
ret.gasPressed = bool(cp.vl["EMS16"]["CF_Ems_AclAct"])
if ray_pedal_enabled(self.CP):
self.ray_pedal_valid = bool(cp_pedal is not None and cp_pedal.can_valid and
cp_pedal.ts_nanos["GAS_SENSOR"]["STATE"] > 0)
self.ray_pedal_state = int(cp_pedal.vl["GAS_SENSOR"]["STATE"]) if cp_pedal is not None else 5
ret.accFaulted = not self.ray_pedal_valid or self.ray_pedal_state != 0
if self.ray_pedal_valid:
driver_pedal = cp_pedal.vl["GAS_SENSOR"]
track1 = round((driver_pedal["INTERCEPTOR_GAS"] + 177.408) / 0.672)
track2 = round((driver_pedal["INTERCEPTOR_GAS2"] + 165.004) / 0.332)
ret.gasPressed = track1 > 272 or track2 > 513
# Gear Selection via Cluster - For those Kia/Hyundai which are not fully discovered, we can use the Cluster Indicator for Gear Selection,
# as this seems to be standard over all cars, but is not the preferred method.
if self.CP.flags & (HyundaiFlags.HYBRID | HyundaiFlags.EV):
@@ -162,21 +286,40 @@ class CarState(CarStateBase):
ret.gearShifter = self.parse_gear_shifter(self.shifter_values.get(gear))
if not self.CP.openpilotLongitudinalControl or self.CP.flags & HyundaiFlags.CAMERA_SCC:
aeb_src = "FCA11" if self.CP.flags & HyundaiFlags.USE_FCA.value else "SCC12"
aeb_sig = "FCA_CmdAct" if self.CP.flags & HyundaiFlags.USE_FCA.value else "AEB_CmdAct"
aeb_warning = cp_cruise.vl[aeb_src]["CF_VSM_Warn"] != 0
scc_warning = cp_cruise.vl["SCC12"]["TakeOverReq"] == 1 # sometimes only SCC system shows an FCW
aeb_braking = cp_cruise.vl[aeb_src]["CF_VSM_DecCmdAct"] != 0 or cp_cruise.vl[aeb_src][aeb_sig] != 0
ret.stockFcw = (aeb_warning or scc_warning) and not aeb_braking
ret.stockAeb = aeb_warning and aeb_braking
if not is_blended(self.CP) and (not self.CP.openpilotLongitudinalControl or self.CP.flags & HyundaiFlags.CAMERA_SCC):
if non_scc:
if not self.CP.flags & HyundaiFlags.NON_SCC_NO_FCA:
fca_parser = cp if self.CP.flags & HyundaiFlags.NON_SCC_RADAR_FCA else cp_cam
fca = fca_parser.vl["FCA11"]
warning = fca["CF_VSM_Warn"] != 0
braking = fca["CF_VSM_DecCmdAct"] != 0 or fca["FCA_CmdAct"] != 0
ret.stockFcw = warning and not braking
ret.stockAeb = warning and braking
else:
aeb_src = "FCA11" if self.CP.flags & HyundaiFlags.USE_FCA.value else "SCC12"
aeb_sig = "FCA_CmdAct" if self.CP.flags & HyundaiFlags.USE_FCA.value else "AEB_CmdAct"
aeb_warning = cp_cruise.vl[aeb_src]["CF_VSM_Warn"] != 0
scc_warning = cp_cruise.vl["SCC12"]["TakeOverReq"] == 1
aeb_braking = cp_cruise.vl[aeb_src]["CF_VSM_DecCmdAct"] != 0 or cp_cruise.vl[aeb_src][aeb_sig] != 0
ret.stockFcw = (aeb_warning or scc_warning) and not aeb_braking
ret.stockAeb = aeb_warning and aeb_braking
if self.CP.flags & HyundaiFlags.HAS_BSM:
if self.CP.deprecated.enableBsm:
ret.leftBlindspot = cp.vl["LCA11"]["CF_Lca_IndLeft"] != 0
ret.rightBlindspot = cp.vl["LCA11"]["CF_Lca_IndRight"] != 0
# save the entire LKAS11 and CLU11
self.lkas11 = copy.copy(cp_cam.vl["LKAS11"])
if is_blended(self.CP):
if self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
self.lfa_block_msg = copy.copy(cp_cam.vl["CAM_0x2a4"])
from opendbc.car.hyundai.blended_stock_aol import camera_current
self.blended_camera_current = camera_current(cp_cam)
self.lkas11 = {}
else:
self.msg_364 = copy.copy(cp_cam.vl["ALERTS_364"])
self.lkas11 = copy.copy(cp_cam.vl["LKAS11"])
else:
self.lkas11 = copy.copy(cp_cam.vl["LKAS11"])
self.clu11 = copy.copy(cp.vl["CLU11"])
self.steer_state = cp.vl["MDPS12"]["CF_Mdps_ToiActive"] # 0 NOT ACTIVE, 1 ACTIVE
prev_cruise_buttons = self.cruise_buttons[-1]
@@ -184,13 +327,32 @@ class CarState(CarStateBase):
prev_lda_button = self.lda_button
self.cruise_buttons.extend(cp.vl_all["CLU11"]["CF_Clu_CruiseSwState"])
self.main_buttons.extend(cp.vl_all["CLU11"]["CF_Clu_CruiseSwMain"])
if self.CP.flags & HyundaiFlags.HAS_LDA_BUTTON:
if self.forte_lkas_sources is not None and getattr(self.forte_lkas_sources, 'active', True):
self.lda_button = int(self.forte_lkas_sources.held)
elif self.CP.carFingerprint == CAR.HYUNDAI_ELANTRA_HEV_2024:
# Both signals can pulse within one parser update; preserve a short press.
lda_samples = [*cp.vl_all["CLU13"]["CF_Clu_LdwsLkasSW"], *cp.vl_all["BCM_PO_11"]["LDA_BTN"]]
if lda_samples:
self.lda_button = int(any(lda_samples))
elif is_blended(self.CP):
self.lda_button = int(any((
cp.vl['CLU13']['CF_Clu_LdwsLkasSW'] if cp.ts_nanos['CLU13']['CF_Clu_LdwsLkasSW'] > 0 else 0,
cp.vl['BCM_PO_11']['LDA_BTN'] if cp.ts_nanos['BCM_PO_11']['LDA_BTN'] > 0 else 0)))
elif self.CP.flags & HyundaiFlags.HAS_LDA_BUTTON:
self.lda_button = cp.vl["BCM_PO_11"]["LDA_BTN"]
ret.buttonEvents = [*create_button_events(self.cruise_buttons[-1], prev_cruise_buttons, BUTTONS_DICT),
button_events = [*self.create_cruise_button_events(self.cruise_buttons[-1], prev_cruise_buttons,
cp.vl_all["CLU11"]["CF_Clu_CruiseSwState"]),
*create_button_events(self.main_buttons[-1], prev_main_buttons, {1: ButtonType.mainCruise}),
*create_button_events(self.lda_button, prev_lda_button, {1: ButtonType.lkas})]
if self.forte_lkas_sources is not None and getattr(self.forte_lkas_sources, 'active', True):
button_events = [event for event in button_events if event.type != ButtonType.lkas]
button_events.extend(structs.CarState.ButtonEvent(type=ButtonType.lkas, pressed=pressed)
for pressed in self.forte_lkas_sources.edges)
self.lda_button = int(self.forte_lkas_sources.held)
ret.buttonEvents = button_events
ret.blockPcmEnable = not self.recent_button_interaction()
# low speed steer alert hysteresis logic (only for cars with steer cut off above 10 m/s)
@@ -240,16 +402,63 @@ class CarState(CarStateBase):
ret.steeringTorqueEps = cp.vl["MDPS"]["MDPS_OutTqVal"]
ret.steeringPressed = self.update_steering_pressed(abs(ret.steeringTorque) > self.params.STEER_THRESHOLD, 5)
ret.steerFaultTemporary = cp.vl["MDPS"]["MDPS_LkaFailSta"] != 0
if self.CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING:
angle_fault = int(cp.vl["MDPS"]["MDPS_ADAS_AciFltSig_Lv2"])
if self.ev9_long:
self.angle_steering_angle = cp.vl["MDPS"]["MDPS_EstStrAnglVal"]
self.angle_steering_fault = bool(angle_fault & 2)
ret.steerFaultTemporary |= self.angle_steering_fault
else:
ret.steerFaultTemporary |= bool(angle_fault if self.CP.carFingerprint in (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9) else angle_fault & 2)
if self.CP.flags & HyundaiFlags.CCNC and not self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
self.ccnc_161 = copy.copy(cp_cam.vl["CCNC_0x161"])
self.ccnc_162 = copy.copy(cp_cam.vl["CCNC_0x162"])
self.ccnc_1b5 = copy.copy(cp_cam.vl["FR_CMR_03_50ms"])
self.ccnc_161_ts_ns = cp_cam.ts_nanos[0x161]["COUNTER"]
self.ccnc_162_ts_ns = cp_cam.ts_nanos[0x162]["COUNTER"]
self.ccnc_1b5_ts_ns = cp_cam.ts_nanos[0x1b5]["Info_LftLnPosVal"]
# TODO: alt signal usage may be described by cp.vl['BLINKERS']['USE_ALT_LAMP']
left_blinker_sig, right_blinker_sig = "LEFT_LAMP", "RIGHT_LAMP"
if self.CP.carFingerprint == CAR.HYUNDAI_KONA_EV_2ND_GEN:
if (self.CP.carFingerprint == CAR.HYUNDAI_KONA_EV_2ND_GEN or self.CP.flags & HyundaiFlags.CCNC or
cp.vl["BLINKERS"]["USE_ALT_LAMP"] == 1):
left_blinker_sig, right_blinker_sig = "LEFT_LAMP_ALT", "RIGHT_LAMP_ALT"
ret.leftBlinker, ret.rightBlinker = self.update_blinker_from_lamp(50, cp.vl["BLINKERS"][left_blinker_sig],
cp.vl["BLINKERS"][right_blinker_sig])
if self.CP.flags & HyundaiFlags.HAS_BSM:
ret.leftBlindspot = bool(cp.vl["ADAS_CMD_50_50ms"]["BCW_LtIndSta"])
ret.rightBlindspot = bool(cp.vl["ADAS_CMD_50_50ms"]["BCW_RtIndSta"])
if self.CP.deprecated.enableBsm:
rear = cp.vl["ADAS_CMD_50_50ms"]
ret.leftBlindspot = bool(rear["BCW_LtIndSta"])
ret.rightBlindspot = bool(rear["BCW_RtIndSta"])
if self.CP.carFingerprint == CAR.KIA_EV9:
corner_left, corner_right = decode_ioniq_6_corner_bsm(int(cp.vl["BLINDSPOTS_FRONT_CORNER_2"]["SIDE_DETECT_STATE"]))
if self.ev9_long:
self.left_blindspot_from_radar, self.right_blindspot_from_radar = corner_left, corner_right
ret.leftBlindspot |= corner_left
ret.rightBlindspot |= corner_right
if self.CP.carFingerprint == CAR.HYUNDAI_IONIQ_6:
now_ns = cp._last_update_nanos
rear_ts = cp.ts_nanos["ADAS_CMD_50_50ms"]["ADAS_CMD_Crc50Val"]
front_ts = cp.ts_nanos["BLINDSPOTS_FRONT_CORNER_2"]["CHECKSUM"]
rear_fresh = ioniq_6_bsm_source_fresh(now_ns, rear_ts)
front_fresh = ioniq_6_bsm_source_fresh(now_ns, front_ts)
corner_left, corner_right = decode_ioniq_6_corner_bsm(int(cp.vl["BLINDSPOTS_FRONT_CORNER_2"]["SIDE_DETECT_STATE"]))
ret.leftBlindspot = (rear_fresh and ret.leftBlindspot) or (front_fresh and corner_left)
ret.rightBlindspot = (rear_fresh and ret.rightBlindspot) or (front_fresh and corner_right)
if self.ioniq6_bsm_sources is not None:
# CANParser timestamps and this receive clock are both BOOTTIME.
# CarController's now_nanos is MONOTONIC and cannot age them.
self.ioniq6_bsm_now_ns = now_ns
self.ioniq6_bsm_sources.observe(
corner_source_ns=front_ts,
corner_state=int(cp.vl["BLINDSPOTS_FRONT_CORNER_2"]["SIDE_DETECT_STATE"]),
lamp_source_ns=cp.ts_nanos["BLINKERS"]["USE_ALT_LAMP"],
left_lamp=bool(cp.vl["BLINKERS"][left_blinker_sig]),
right_lamp=bool(cp.vl["BLINKERS"][right_blinker_sig]),
)
if self.ev9_long and cp.ts_nanos["FR_CMR_01_10ms"]["FR_CMR_Crc1Val"] > 0:
hba = int(cp.vl["FR_CMR_01_10ms"]["HBA_IndLmpReq"])
self.hba_icon = hba if hba in (1, 2) else 0
# cruise state
# CAN FD cars enable on main button press, set available if no TCS faults preventing engagement
@@ -272,45 +481,194 @@ class CarState(CarStateBase):
if self.CP.flags & HyundaiFlags.EV:
ret.cruiseState.nonAdaptive = cp.vl["MANUAL_SPEED_LIMIT_ASSIST"]["MSLA_ENABLED"] == 1
from opendbc.car.hyundai.ev6_aol import qualified as ev6_aol_qualified
self.ev6_aol_samples = ()
self.ev6_aol_source_healthy = False
if ev6_aol_qualified(self.CP):
from opendbc.can.parser import MAX_BAD_COUNTER
source = cp.message_states.get(cp.dbc.name_to_msg[self.cruise_btns_msg_canfd].address)
self.ev6_aol_sample_stamp_ns = int(source.timestamps[-1]) if source is not None and source.timestamps else 0
self.ev6_aol_timeout_ns = int(source.timeout_threshold) if source is not None else 0
now = cp._last_update_nanos
bus_timeout = cp.bus_timeout
self.ev6_aol_source_healthy = bool(
not bus_timeout and source is not None and source.timestamps
and source.valid(now, bus_timeout) and source.counter_fail < MAX_BAD_COUNTER
and 0 < self.ev6_aol_sample_stamp_ns <= now
and now - self.ev6_aol_sample_stamp_ns <= source.timeout_threshold)
values = cp.vl_all[self.cruise_btns_msg_canfd]
self.ev6_aol_samples = tuple(zip(values['ADAPTIVE_CRUISE_MAIN_BTN'], values['LDA_BTN'], values['CRUISE_BUTTONS'], strict=True))
prev_cruise_buttons = self.cruise_buttons[-1]
prev_main_buttons = self.main_buttons[-1]
prev_lda_button = self.lda_button
self.cruise_buttons.extend(cp.vl_all[self.cruise_btns_msg_canfd]["CRUISE_BUTTONS"])
self.main_buttons.extend(cp.vl_all[self.cruise_btns_msg_canfd]["ADAPTIVE_CRUISE_MAIN_BTN"])
self.lda_button = cp.vl[self.cruise_btns_msg_canfd]["LDA_BTN"]
prev_left_paddle = self.left_paddle
self.left_paddle = cp.vl["CRUISE_BUTTONS"]["LEFT_PADDLE"] if self.CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 else 0
self.buttons_counter = cp.vl[self.cruise_btns_msg_canfd]["COUNTER"]
if not self.CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING and self.CP.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CARNIVAL_ALT_RESUME:
self.cruise_buttons_alt_ts_ns = cp.ts_nanos["CRUISE_BUTTONS_ALT"]["COUNTER"]
if self.cruise_buttons_alt_ts_ns > 0:
self.cruise_buttons_alt_msg = copy.copy(cp.vl["CRUISE_BUTTONS_ALT"])
ret.accFaulted = cp.vl["TCS"]["ACCEnable"] != 0 # 0 ACC CONTROL ENABLED, 1-3 ACC CONTROL DISABLED
if self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
self.lfa_block_msg = copy.copy(cp_cam.vl["CAM_0x362"] if self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT
else cp_cam.vl["CAM_0x2a4"])
if self.CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING:
self.angle_lkas_status = copy.copy(cp_cam.vl["LKAS_ALT" if self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT else "LKAS"])
ret.buttonEvents = [*create_button_events(self.cruise_buttons[-1], prev_cruise_buttons, BUTTONS_DICT),
*create_button_events(self.main_buttons[-1], prev_main_buttons, {1: ButtonType.mainCruise}),
*create_button_events(self.lda_button, prev_lda_button, {1: ButtonType.lkas})]
*create_button_events(self.lda_button, prev_lda_button, {1: ButtonType.lkas}),
*create_button_events(self.left_paddle, prev_left_paddle, {1: ButtonType.altButton2})]
ret.blockPcmEnable = not self.recent_button_interaction()
# The front camera's posted sign is distinct from SCC set speed and from
# the CCNC cluster's forwarded display value. Only a received packet can
# establish valid or absent evidence; the subscription is non-gating.
sign_bus = cp if self.CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG else cp_cam
timestamp, expiry = parser_expiry(sign_bus, "FR_CMR_02_100ms", "ISLW_SpdCluMainDis")
if timestamp > self.dashboard_limit.observation.observed_ns:
speed = int(sign_bus.vl["FR_CMR_02_100ms"]["ISLW_SpdCluMainDis"])
system_status = int(sign_bus.vl["FR_CMR_02_100ms"]["ISLW_SysSta"])
status, value_mps = hyundai_canfd_sign(speed, system_status, self.is_metric)
self.dashboard_limit.update(timestamp, status, value_mps, valid_until_ns=expiry)
return ret
def get_can_parsers_canfd(self, CP):
msgs = []
if ev9_long_qualified(CP):
msgs.append(("FR_CMR_01_10ms", math.nan))
cam_msgs: list[tuple[str | int, int | float]] = []
if CP.carFingerprint in (CAR.HYUNDAI_IONIQ_6, CAR.KIA_EV9) and CP.deprecated.enableBsm:
# Receive-only corner sources do not poison stock SCC CAN validity.
# The separately gated exact-long controller may use a fresh source for
# paired dashboard status; parsing alone never authorizes TX.
msgs.extend((("ADAS_CMD_50_50ms", math.nan), ("BLINDSPOTS_FRONT_CORNER_2", math.nan)))
if CP.flags & HyundaiFlags.EV:
msgs.append(("MANUAL_SPEED_LIMIT_ASSIST", math.nan))
sign_subscription = ("FR_CMR_02_100ms", math.nan)
if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
msgs.append(sign_subscription)
if CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING:
cam_msgs.append(("LKAS_ALT" if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT else "LKAS", math.nan))
cam_msgs.append(("CAM_0x362" if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT else "CAM_0x2a4", math.nan))
else:
cam_msgs.append(sign_subscription)
if CP.flags & HyundaiFlags.CCNC and not CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
cam_msgs += [("CCNC_0x161", math.nan), ("CCNC_0x162", math.nan), ("FR_CMR_03_50ms", math.nan)]
if not (CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS):
# TODO: this can be removed once we add dynamic support to vl_all
msgs += [
# this message is 50Hz but the ECU frequently stops transmitting for ~0.5s
("CRUISE_BUTTONS", 1)
("CRUISE_BUTTONS", 50 if CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and CP.openpilotLongitudinalControl else
math.nan if CP.carFingerprint in (CAR.HYUNDAI_IONIQ_9, CAR.KIA_EV9) else
50 if CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING else 1)
]
elif not CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING and CP.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CARNIVAL_ALT_RESUME:
msgs.append(("CRUISE_BUTTONS_ALT", math.nan))
from opendbc.car.hyundai.canfd_stock_aol import qualified as stock_aol_qualified
if stock_aol_qualified(CP):
msgs = [(name, frequency) for name, frequency in msgs if name != "CRUISE_BUTTONS_ALT"]
msgs.append(("CRUISE_BUTTONS_ALT", 50 if CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS else math.nan))
if CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING:
# These are actual state and safety inputs, not DBC defaults. Keep this
# subscription expansion scoped to the new angle profile.
fuel = "ACCELERATOR" if CP.flags & HyundaiFlags.EV else \
"ACCELERATOR_ALT" if CP.flags & HyundaiFlags.HYBRID else "ACCELERATOR_BRAKE_ALT"
required = [(fuel, 100), ("TCS", 50), ("WHEEL_SPEEDS", 100), ("MDPS", 100),
("STEERING_SENSORS", 100), ("DOORS_SEATBELTS", 10), ("BLINKERS", 10),
(self.gear_msg_canfd, 100)]
existing = {name for name, _ in msgs}
for name, frequency in required:
if name not in existing:
msgs.append((name, frequency))
existing.add(name)
if CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS:
msgs.append(("CRUISE_BUTTONS_ALT", 50))
else:
# update_canfd reads DISTANCE_UNIT from this alternate definition even
# on standard-button cars; prevent the absent source gating CAN-valid.
msgs.append(("CRUISE_BUTTONS_ALT", math.nan))
if not ev9_long_qualified(CP):
(cam_msgs if CP.flags & HyundaiFlags.CANFD_CAMERA_SCC else msgs).append(("SCC_CONTROL", 50))
elif torque_ev_scc_eligible(CP):
msgs.extend((("ACCELERATOR", 100), ("TCS", 50), ("WHEEL_SPEEDS", 100), ("MDPS", 100)))
msgs.extend((("CRUISE_BUTTONS_ALT", math.nan), ("DOORS_SEATBELTS", math.nan),
("STEERING_SENSORS", math.nan), ("BLINKERS", math.nan)))
if self.gear_msg_canfd != "ACCELERATOR":
msgs.append((self.gear_msg_canfd, 100))
cam_msgs.append(("CAM_0x362" if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT else "CAM_0x2a4", 20))
elif CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and CP.openpilotLongitudinalControl:
# The acknowledged ADAS takeover removes stock SCC from E-CAN. Require
# every independent native input and the matching camera source instead.
msgs.extend((("ACCELERATOR", 100), ("TCS", 50), ("WHEEL_SPEEDS", 100), ("MDPS", 100)))
msgs.extend((("CRUISE_BUTTONS_ALT", math.nan), ("DOORS_SEATBELTS", math.nan),
("STEERING_SENSORS", math.nan), ("BLINKERS", math.nan)))
cam_msgs.append(("CAM_0x362" if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT else "CAM_0x2a4", 20))
elif CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and not CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS:
# Stock standard-button Ioniq 6 reads alternate DISTANCE_UNIT too, but
# that frame may be absent. Subscribe before the lazy lookup makes it
# required; existing native and CarState safety inputs remain required.
msgs.append(("CRUISE_BUTTONS_ALT", math.nan))
from opendbc.car.hyundai.ev6_startup import eligible as ev6_eligible
from opendbc.car.hyundai.gv70_startup import eligible as gv70_eligible
if ev6_eligible(CP) or gv70_eligible(CP):
# Standard 1CF supplies buttons; alternate 1AA only holds optional units.
if not any(name == "CRUISE_BUTTONS_ALT" for name, _ in msgs):
msgs.append(("CRUISE_BUTTONS_ALT", math.nan))
existing = {name for name, _ in msgs}
for name, frequency in (("ACCELERATOR", 100), ("TCS", 50), ("WHEEL_SPEEDS", 100), ("MDPS", 100),
(self.gear_msg_canfd, 100)):
if name not in existing:
msgs.append((name, frequency))
existing.add(name)
if not CP.openpilotLongitudinalControl:
msgs.append(("SCC_CONTROL", 50))
if ("CAM_0x2a4", 20) not in cam_msgs:
cam_msgs.append(("CAM_0x2a4", 20))
return {
Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], msgs, CanBus(CP).ECAN),
Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus(CP).CAM),
# Native CANParser accepts NaN to subscribe without a CAN-valid frequency gate.
Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], cam_msgs, CanBus(CP).CAM),
}
def get_can_parsers(self, CP):
if CP.flags & HyundaiFlags.CANFD:
return self.get_can_parsers_canfd(CP)
return {
Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], [], 0),
Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], [], 2),
if is_blended(CP):
msgs = [("MDPS12", 100), ("TCS11", 100), ("TCS13", 50), ("TCS15", 10),
("CLU11", 50), ("CLU15", 5), ("ESP12", 100), ("CGW1", 10), ("CGW2", 5),
("WHL_SPD11", 50), ("SAS11", 100),
("EMS12", 100), ("EMS16", 100), ("LVR12", 100), ("BCM_PO_11", math.nan), ("CLU13", math.nan)]
if not CP.openpilotLongitudinalControl:
msgs.extend((("SCC11", 50), ("SCC12", 50)))
elif CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
msgs.append(("SCC12", math.nan))
if CP.deprecated.enableBsm:
msgs.append(("LCA11", 20))
cam_msgs = [("CAM_0x2a4", 20)] if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG else [
("LKAS11", 100), ("ALERTS_364", math.nan)]
return {Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], msgs, CanBus(CP).ECAN),
Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], cam_msgs, CanBus(CP).CAM)}
msgs = [("CLU13", math.nan), ("BCM_PO_11", math.nan)] if CP.carFingerprint == CAR.HYUNDAI_ELANTRA_HEV_2024 else []
cam_msgs = []
if CP.flags & HyundaiFlags.NON_SCC:
available_msg, _, enabled_msg, _, speed_msg, _ = get_non_scc_cruise_signals(CP.flags, CP.carFingerprint)
msgs.extend((name, 0) for name in {available_msg, enabled_msg, speed_msg})
if not CP.flags & HyundaiFlags.NON_SCC_NO_FCA:
(msgs if CP.flags & HyundaiFlags.NON_SCC_RADAR_FCA else cam_msgs).append(("FCA11", math.nan))
parsers = {
Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], msgs, 0),
Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], cam_msgs, 2),
}
if ray_pedal_enabled(CP):
parsers[Bus.party] = CANParser("hyundai_kia_ray_pedal", [("GAS_SENSOR", 50)], 0)
return parsers
@@ -0,0 +1,33 @@
"""Exact stock-SCC CCNC EV ordinary steering transport contract."""
from opendbc.car import structs
from opendbc.car.hyundai.values import CAR, HyundaiFlags
STOCK_SAFETY_PARAMS = {CAR.HYUNDAI_IONIQ_5_PE: 0x5491, CAR.KIA_EV9: 0x5c91}
_REQUIRED = (HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CCNC | HyundaiFlags.CANFD_ANGLE_STEERING |
HyundaiFlags.CANFD_LKA_STEER_MSG | HyundaiFlags.CANFD_LKA_STEER_MSG_ALT)
# These generic metadata flags never select the EV gear decoder.
_IGNORED_EV_GEAR_FLAGS = HyundaiFlags.CANFD_ALT_GEARS | HyundaiFlags.CANFD_ALT_GEARS_2
def qualified(cp):
return (cp.carFingerprint in STOCK_SAFETY_PARAMS and cp.brand == "hyundai" and
not (cp.notCar or cp.passive or cp.dashcamOnly or cp.openpilotLongitudinalControl) and cp.pcmCruise and
cp.alternativeExperience == 0 and int(cp.flags) & ~int(_IGNORED_EV_GEAR_FLAGS) == int(_REQUIRED) and
len(cp.safetyConfigs) == 1 and cp.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.hyundaiCanfd and
cp.safetyConfigs[0].safetyParam == STOCK_SAFETY_PARAMS[cp.carFingerprint])
def request_allowed(cp, state):
return (qualified(cp) and state.canValid and not state.canTimeout and state.cruiseState.enabled and
state.gearShifter == structs.CarState.GearShifter.drive and not state.standstill and
not (state.brakePressed or state.gasPressed or state.steerFaultTemporary or state.steerFaultPermanent))
def replacement_requested(cp, control, state):
from opendbc.car.hyundai.canfd_angle_aol import qualified as angle_qualified, command_allowed
if angle_qualified(cp, marked_only=True):
return command_allowed(cp, state) and control.latActive
return request_allowed(cp, state) and control.latActive
@@ -0,0 +1,71 @@
from opendbc.car import structs
from opendbc.car.hyundai.values import CAR, HyundaiFlags, HyundaiSafetyFlags, is_blended
from opendbc.car.hyundai.classic_scc_aol import CLASSIC_SCC_IDS, stock_word
AOL_MARKER = 0x0400
AOL_EXPERIENCE = 32
_EXCLUDED = int(HyundaiFlags.LEGACY | HyundaiFlags.UNSUPPORTED_LONGITUDINAL | HyundaiFlags.ALT_LIMITS_2 | HyundaiFlags.CAMERA_SCC | HyundaiFlags.FCEV)
CLASSIC_LONG_IDS = frozenset(identity for identity in CLASSIC_SCC_IDS if not int(identity.config.flags) & _EXCLUDED)
LONG_AOL_WORDS = frozenset(AOL_MARKER | 4 | gas | limits | lda for gas in (0, 1, 2) for limits in (0, 64) for lda in (0, 2048))
def ordinary_word(cp):
from opendbc.car.hyundai.legacy_long_aol import CARS, ordinary_word as legacy_word
if cp.carFingerprint in CARS:
return legacy_word(cp)
return stock_word(cp) | int(HyundaiSafetyFlags.LONG)
def aol_word(cp):
from opendbc.car.hyundai.legacy_long_aol import CARS, aol_word as legacy_word
if cp.carFingerprint in CARS:
return legacy_word(cp)
return ordinary_word(cp) | AOL_MARKER | (int(HyundaiSafetyFlags.HAS_LDA_BUTTON) if int(cp.flags) & int(HyundaiFlags.HAS_LDA_BUTTON) else 0)
def qualified(cp, *, marked_only=False):
from opendbc.car.hyundai.legacy_long_aol import CARS, qualified as legacy_qualified
if cp.carFingerprint in CARS:
return legacy_qualified(cp, marked_only=marked_only)
if cp.brand != 'hyundai' or cp.carFingerprint not in CLASSIC_LONG_IDS:
return False
declared = int(CAR[cp.carFingerprint].config.flags)
allowed = declared | int(HyundaiFlags.HAS_LDA_BUTTON | HyundaiFlags.USE_FCA | HyundaiFlags.SEND_LFA)
identity_mask = int(
HyundaiFlags.CANFD
| HyundaiFlags.NON_SCC
| HyundaiFlags.FCEV
| HyundaiFlags.LEGACY
| HyundaiFlags.EV
| HyundaiFlags.HYBRID
| HyundaiFlags.ALT_LIMITS
| HyundaiFlags.ALT_LIMITS_2
| HyundaiFlags.CAMERA_SCC
)
if (
int(cp.flags) & identity_mask != declared & identity_mask
or int(cp.flags) & ~allowed
or is_blended(cp)
or cp.passive
or cp.notCar
or cp.dashcamOnly
or not cp.openpilotLongitudinalControl
or cp.pcmCruise
or cp.steerControlType != structs.CarParams.SteerControlType.torque
or not cp.alphaLongitudinalAvailable
or cp.alternativeExperience not in (0, AOL_EXPERIENCE)
or len(cp.safetyConfigs) != 1
):
return False
safety = cp.safetyConfigs[0]
accepted = (aol_word(cp),) if marked_only else (ordinary_word(cp), aol_word(cp))
return safety.safetyModel == structs.CarParams.SafetyModel.hyundai and int(safety.safetyParam) in accepted
def native_accepts(cp, model, param):
return (
qualified(cp, marked_only=True)
and cp.alternativeExperience == AOL_EXPERIENCE
and model == int(cp.safetyConfigs[0].safetyModel.raw)
and param == aol_word(cp)
)
@@ -0,0 +1,188 @@
"""Classic stock-SCC AOL identity and transport contract.
Gas, checksum, SCC routing and steering limits remain platform-owned.
Independent lateral authorization is a separate native profile, never LONG.
"""
import time
from opendbc.car import structs
from opendbc.car.hyundai.values import CAR, HyundaiFlags, HyundaiSafetyFlags, is_blended
AOL_MARKER = 0x0400
AOL_EXPERIENCE = 32
_EXCLUDED = int(HyundaiFlags.CANFD | HyundaiFlags.NON_SCC | HyundaiFlags.FCEV)
CLASSIC_SCC_IDS = frozenset(identity for identity in CAR if not int(identity.config.flags) & _EXCLUDED and identity != CAR.HYUNDAI_PALISADE_2023) | frozenset(
(CAR.HYUNDAI_NEXO_1ST_GEN,)
)
_MODELS = (structs.CarParams.SafetyModel.hyundai, structs.CarParams.SafetyModel.hyundaiLegacy)
FCEV_AOL_WORDS = frozenset((0x0500, 0x0D00))
NORMAL_AOL_WORDS = (
FCEV_AOL_WORDS
| frozenset(AOL_MARKER | gas | limit | camera | lda for gas in (0, 1, 2) for limit in (0, 64, 512) for camera in (0, 8) for lda in (0, 2048))
| frozenset((0x8408, 0x840A, 0x8C08, 0x8C0A))
)
LEGACY_AOL_WORDS = frozenset(word for word in NORMAL_AOL_WORDS if not word & (8 | 256 | 32768))
def native_profile_supported(model, param):
return (model == int(_MODELS[0]) and param in NORMAL_AOL_WORDS) or (model == int(_MODELS[1]) and param in LEGACY_AOL_WORDS)
def stock_word(cp):
word = int(HyundaiSafetyFlags.CAN_REFRESH_MSGS) if cp.carFingerprint in (CAR.HYUNDAI_ELANTRA_2024, CAR.HYUNDAI_ELANTRA_HEV_2024) else 0
for flag, safety in (
(HyundaiFlags.FCEV, HyundaiSafetyFlags.FCEV_GAS),
(HyundaiFlags.EV, HyundaiSafetyFlags.EV_GAS),
(HyundaiFlags.HYBRID, HyundaiSafetyFlags.HYBRID_GAS),
(HyundaiFlags.ALT_LIMITS, HyundaiSafetyFlags.ALT_LIMITS),
(HyundaiFlags.ALT_LIMITS_2, HyundaiSafetyFlags.ALT_LIMITS_2),
(HyundaiFlags.CAMERA_SCC, HyundaiSafetyFlags.CAMERA_SCC),
):
if int(cp.flags) & int(flag):
word |= int(safety)
return word
def aol_word(cp):
return stock_word(cp) | AOL_MARKER | (int(HyundaiSafetyFlags.HAS_LDA_BUTTON) if int(cp.flags) & int(HyundaiFlags.HAS_LDA_BUTTON) else 0)
def qualified(cp, *, marked_only=False):
if cp.brand != "hyundai" or cp.carFingerprint not in CLASSIC_SCC_IDS:
return False
declared = int(CAR[cp.carFingerprint].config.flags)
allowed = declared | int(HyundaiFlags.HAS_LDA_BUTTON | HyundaiFlags.USE_FCA | HyundaiFlags.SEND_LFA)
identity_mask = _EXCLUDED | int(
HyundaiFlags.EV | HyundaiFlags.HYBRID | HyundaiFlags.ALT_LIMITS | HyundaiFlags.ALT_LIMITS_2 | HyundaiFlags.CAMERA_SCC | HyundaiFlags.LEGACY
)
if (
int(cp.flags) & identity_mask != declared & identity_mask
or int(cp.flags) & ~allowed
or is_blended(cp)
or cp.passive
or cp.notCar
or cp.dashcamOnly
or cp.openpilotLongitudinalControl
or not cp.pcmCruise
or cp.alternativeExperience not in (0, AOL_EXPERIENCE)
or len(cp.safetyConfigs) != 1
):
return False
safety = cp.safetyConfigs[0]
model = _MODELS[bool(declared & int(HyundaiFlags.LEGACY))]
accepted = (aol_word(cp),) if marked_only else (stock_word(cp), aol_word(cp))
return safety.safetyModel == model and int(safety.safetyParam) in accepted
def native_accepts(cp, model, param):
return (
qualified(cp, marked_only=True)
and cp.alternativeExperience == AOL_EXPERIENCE
and model == int(cp.safetyConfigs[0].safetyModel.raw)
and param == aol_word(cp)
)
class ClassicSccLkasSources:
"""Original per-frame button selection over current-session raw observations.
The Sonata hybrid's SWL status can affect host intent, but cannot by itself
authorize native steering. Native authorization still observes physical
BCM/CLU button bits. Expiry and held-at-start protection remain fail closed.
"""
def __init__(self, identity):
self.identity = identity
self.sources = {}
self.selected = None
self.previous = {"bcm": 0, "clu13": 0, "swl_stat": 0}
self.held = False
self.neutral_seen = False
self.edges = []
self.neutral_sources = set()
self.blocked_sources = set()
def update(self, can_packets):
self.edges = []
try:
now = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
except (AttributeError, OSError):
self.sources.clear()
self.neutral_sources.clear()
self.blocked_sources.clear()
self.neutral_seen = False
self.held = False
self.previous = dict.fromkeys(self.previous, 0)
self.selected = None
return
age = 300_000_000
current = {address: value for address, value in self.sources.items() if 0 <= now - value[0] <= age}
if len(current) != len(self.sources):
for address, (_, data) in self.sources.items():
held_source = (
bool(data[0] & 0x10) if address == 0x391 else bool(data[7] & 1 or (self.identity == CAR.HYUNDAI_SONATA_HYBRID and ((data[4] >> 4) & 7) == 4))
)
if address not in current:
self.neutral_sources.discard(address)
if held_source and (self.identity != CAR.HYUNDAI_SONATA or address == 0x391):
self.blocked_sources.add(address)
self.neutral_seen = False
self.sources = current
samples = []
accepted = set()
for stamp, packets in can_packets:
if type(stamp) is not int or not 0 < stamp <= now or now - stamp > age:
continue
for address, data, bus in packets:
if bus != 0 or address not in (0x391, 0x50C) or len(data) != 8:
continue
previous = self.sources.get(address)
if previous is not None and stamp <= previous[0]:
continue
self.sources[address] = (stamp, bytes(data))
accepted.add(address)
source_held = (
bool(data[0] & 0x10) if address == 0x391 else bool(data[7] & 1 or (self.identity == CAR.HYUNDAI_SONATA_HYBRID and ((data[4] >> 4) & 7) == 4))
)
if self.identity == CAR.HYUNDAI_SONATA and address != 0x391:
continue
if source_held:
if address not in self.neutral_sources:
self.blocked_sources.add(address)
else:
self.neutral_sources.add(address)
self.blocked_sources.discard(address)
samples.append(bool(data[0] & 0x10) if address == 0x391 else bool(data[7] & 1))
bcm = self.sources.get(0x391)
clu = self.sources.get(0x50C)
states = {"bcm": int(bool(bcm and bcm[1][0] & 0x10)), "clu13": int(bool(clu and clu[1][7] & 1)), "swl_stat": int(bool(clu and ((clu[1][4] >> 4) & 7) == 4))}
if self.identity == CAR.HYUNDAI_SONATA:
held = bool(states["bcm"])
elif self.identity == CAR.HYUNDAI_SONATA_HYBRID:
changed = [source for source, state in states.items() if state != self.previous[source]]
active = [source for source, state in states.items() if state]
if self.selected in changed:
selected = self.selected
elif active:
selected = active[0]
elif changed:
selected = changed[0]
else:
selected = self.selected
self.previous = states
if selected is not None:
self.selected = selected
held = bool(states[selected]) if selected is not None else False
elif self.identity == CAR.HYUNDAI_ELANTRA_HEV_2024:
held = any(samples) if samples else bool(states["bcm"] or states["clu13"])
else:
held = bool(states["bcm"] or states["clu13"])
fresh_neutral = bool(accepted) and not self.blocked_sources and not held and (self.identity != CAR.HYUNDAI_SONATA or 0x391 in accepted)
if fresh_neutral:
if not self.neutral_seen and not self.held:
self.edges.append(False)
self.neutral_seen = True
if held != self.held and self.neutral_seen and not self.blocked_sources and (held or fresh_neutral):
self.edges.append(held)
self.held = held
@@ -0,0 +1,209 @@
"""Hyundai prepublication ECU transaction; sent disable is not confirmed ownership."""
from enum import Enum
from collections.abc import Callable
from typing import TypedDict, cast
from copy import deepcopy
import time
from opendbc.car import structs
from opendbc.car.disable_ecu import disable_ecu
from opendbc.car.hyundai.values import HyundaiSafetyFlags
from opendbc.car.hyundai.canfd_owner import config_index
from opendbc.car.isotp_parallel_query import IsoTpParallelQuery
class PreparedSafetyConfig(TypedDict):
safetyParam: int
class PreparedParams(TypedDict):
safetyConfigs: list[PreparedSafetyConfig]
alternativeExperience: int
class Outcome(Enum):
STOCK_UNTOUCHED = 'stock_untouched'
SENT_UNCONFIRMED = 'sent_unconfirmed'
STOCK_RESTORED = 'stock_restored'
ABORT_UNCERTAIN = 'abort_uncertain'
def stock_copy(cp):
with structs.CarParams.from_bytes(cp.to_bytes()) as reader:
stock = reader.as_builder()
stock.openpilotLongitudinalControl = False
stock.pcmCruise = True
for config in stock.safetyConfigs:
config.safetyParam &= ~HyundaiSafetyFlags.LONG.value
return stock
class HyundaiECUStartup:
def __init__(self, cp, callbacks, *, address, bus, label):
self.address, self.bus, self.label = address, bus, label
self.cp = cp
self.callbacks = callbacks
self.outcome = None
self.disable_attempted = False
self.restored = False
self.admission = None
self.floor_ns = 0
self.published = False
self.ci = None
self.closed = False
self.ready = False
self.prepared_cp = None
def _disable(self, *args, **kwargs):
return disable_ecu(*args, **kwargs)
def _query(self, *args, **kwargs):
return IsoTpParallelQuery(*args, **kwargs)
def _warm_stock(self, ci):
raise NotImplementedError
def _send(self, frames):
# The exact3-byte UDS request fits an ISO-TP single frame. Mark before
# invoking transport: a callback exception cannot prove no ECU mutation.
for address, data, bus in frames:
if address == self.address and bus == self.bus and len(data) >= 4 and data[:4] == b'\x03\x28\x83\x01':
self.disable_attempted = True
self.callbacks[1](frames)
def _restore(self):
admission = cast(Callable[[], bool], self.admission)
if not admission():
return False
recv, send = self.callbacks
session = self._query(send, recv, self.bus, [(self.address, None)], [b'\x10\x03'], [b'\x50\x03'])
if not session.get_data(.1):
return False
# Restore is deliberately unsuppressed; a sent288001 cannot prove stock.
restore = self._query(send, recv, self.bus, [(self.address, None)], [b'\x28\x00\x01'], [b'\x68\x00'])
if not restore.get_data(.1):
return False
self.restored = True
self.floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
return True
def prepare(self, *, admission):
self.admission = admission
self.floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
if not admission():
self.outcome = Outcome.STOCK_UNTOUCHED
self.cp = stock_copy(self.cp)
self.prepared_cp = deepcopy(self.cp.to_dict())
return self.cp
try:
success = self._disable(self.callbacks[0], self._send, bus=self.bus, addr=self.address, com_cont_req=b'\x28\x83\x01')
except Exception:
success = False
if success and self.disable_attempted:
self.outcome = Outcome.SENT_UNCONFIRMED
self.prepared_cp = deepcopy(self.cp.to_dict())
return self.cp
if not self.disable_attempted:
self.outcome = Outcome.STOCK_UNTOUCHED
else:
try:
restored = self._restore()
except Exception:
restored = False
if not restored:
self.outcome = Outcome.ABORT_UNCERTAIN
raise RuntimeError(f'{self.label} disable failed; stock ECU restoration unverified')
self.outcome = Outcome.STOCK_RESTORED
self.cp = stock_copy(self.cp)
self.prepared_cp = deepcopy(self.cp.to_dict())
return self.cp
def prepared_for(self, cp):
return (self.outcome is not None and self.outcome is not Outcome.ABORT_UNCERTAIN and
self.prepared_cp is not None and cp.to_dict() == self.prepared_cp)
def configure(self, ci):
if not self.prepared_for(ci.CP):
raise RuntimeError(f'{self.label} prepared startup does not match constructed CP')
self.ci = ci
if self.outcome is Outcome.SENT_UNCONFIRMED:
self.ready = True
return # Preserve ordinary controller commands and1Hz tester cadence.
self._warm_stock(ci)
def finalize_aol_configuration(self, ci):
"""Finalize only the supported stock AOL marker before publication."""
if self.published or self.closed or ci is not self.ci or not self.ready:
raise RuntimeError('Hyundai AOL finalization requires configured startup')
if self.prepared_for(ci.CP):
return
from opendbc.car.hyundai.ev6_aol import qualified as ev6_aol_qualified
if self.outcome is Outcome.SENT_UNCONFIRMED and ev6_aol_qualified(ci.CP, marked_only=True):
expected = deepcopy(cast(PreparedParams, self.prepared_cp))
configs = expected.get('safetyConfigs', [])
owner = config_index(ci.CP)
if owner is None or len(configs) != owner + 1 or configs[owner]['safetyParam'] != 0x15 or expected['alternativeExperience'] != 0:
raise RuntimeError('EV6 prepared LONG profile cannot be finalized')
configs[owner]['safetyParam'] |= 0x0800
expected['alternativeExperience'] = 32
if ci.CP.to_dict() != expected:
raise RuntimeError('EV6 AOL finalization changed unrelated CarParams')
self.prepared_cp = expected
return
from opendbc.car.hyundai.canfd_stock_aol import qualified as stock_aol_qualified
if (self.outcome not in (Outcome.STOCK_UNTOUCHED, Outcome.STOCK_RESTORED) or
not stock_aol_qualified(ci.CP, marked_only=True)):
raise RuntimeError('Hyundai startup does not admit this AOL configuration')
expected = deepcopy(cast(PreparedParams, self.prepared_cp))
configs = expected.get('safetyConfigs', [])
owner = config_index(ci.CP)
if owner is None or len(configs) != owner + 1 or configs[owner]['safetyParam'] not in (0x11, 0x91):
raise RuntimeError('Hyundai startup stock profile cannot be finalized')
configs[owner]['safetyParam'] |= 0x0800
if ci.CP.to_dict() != expected:
raise RuntimeError('Hyundai AOL finalization changed unrelated CarParams')
self.prepared_cp = expected
def seal_publication(self):
self.check()
if self.ci is None or not self.ready:
raise RuntimeError(f'{self.label} startup requires configure before publication')
if not self.prepared_for(self.ci.CP):
raise RuntimeError(f'{self.label} CP differs from its prepared startup decision')
self.published = True
def check(self):
if self.outcome is None or self.outcome is Outcome.ABORT_UNCERTAIN:
raise RuntimeError('Unresolved Hyundai startup')
def close(self):
if self.closed:
return
self.closed = True
if self.disable_attempted and not self.published and not self.restored:
try:
restored = self._restore()
except Exception:
restored = False
if not restored:
self.outcome = Outcome.ABORT_UNCERTAIN
raise RuntimeError(f'{self.label} unpublished startup cleanup could not confirm stock restoration')
def after_state(self, **context):
pass
def consume_cruise_resume(self):
return None
def before_control(self, **context):
self.check()
return True
def sources_current(self, ci, now_ns):
return True
def maintain(self, *, configured):
pass
def sent(self, frames, *, valid):
pass
@@ -0,0 +1,42 @@
"""First-generation EV6 physical authorization for the existing torque lease."""
from opendbc.car.hyundai.canfd_stock_aol import _base_word
from opendbc.car.hyundai.canfd_owner import config_index
from opendbc.car.hyundai.ev6_startup import eligible
from opendbc.car.hyundai.values import Buttons
def qualified(cp, *, marked_only=False):
if not eligible(cp) or not cp.openpilotLongitudinalControl or cp.pcmCruise:
return False
if _base_word(cp, experiences=(0, 32)) != 0x11:
return False
word = cp.safetyConfigs[config_index(cp)].safetyParam
return (word == 0x815 and cp.alternativeExperience == 32) or (
not marked_only and word == 0x15 and cp.alternativeExperience == 0)
class Ev6PhysicalAuthorization:
def __init__(self, *, lkas_on_engage):
self.lkas_on_engage = bool(lkas_on_engage)
self.main_on = self.lkas_on = False
self.previous_main = self.previous_lkas = False
self.previous_cruise = Buttons.NONE
def revoke(self):
# Retain actual held-button history: withdrawal is not a synthetic new edge.
self.main_on = self.lkas_on = False
def observe(self, main, lkas, cruise, *, main_available):
if main and not self.previous_main:
self.main_on = not self.main_on
if lkas and not self.previous_lkas:
self.lkas_on = not self.lkas_on
if self.lkas_on_engage and cruise != self.previous_cruise and self.previous_cruise in (Buttons.SET_DECEL, Buttons.RES_ACCEL):
self.lkas_on = True
self.previous_main, self.previous_lkas, self.previous_cruise = bool(main), bool(lkas), cruise
if not main_available:
self.main_on = False
@property
def authorized(self):
return self.main_on or self.lkas_on
@@ -0,0 +1,17 @@
"""Exact first-generation EV6 startup compatibility wrapper."""
from opendbc.car.hyundai.captured_adrv_startup import CapturedADRVStartup, first_generation
from opendbc.car.hyundai.ev6_template import EV6Template
from opendbc.car.hyundai.values import CAR
def eligible(cp):
return cp.carFingerprint == CAR.KIA_EV6 and first_generation(cp)
def required(cp):
return eligible(cp) and cp.openpilotLongitudinalControl
class EV6Startup(CapturedADRVStartup):
def __init__(self, cp, callbacks):
super().__init__(cp, callbacks, label='EV6', template_type=EV6Template)
@@ -0,0 +1,8 @@
"""Exact EV6 compatibility descriptor; speed never changes its captured body."""
from dataclasses import dataclass
from opendbc.car.hyundai.captured_adrv import CapturedADRVTemplate
@dataclass(frozen=True)
class EV6Template(CapturedADRVTemplate):
pass
@@ -0,0 +1,11 @@
"""Exact EV9 longitudinal owner for optional camera lead data."""
from opendbc.car.hyundai.canfd_camera_lead import CANFDCameraLead
from opendbc.car.hyundai.ev9_longitudinal import qualified
from opendbc.car.hyundai.hyundaicanfd import CanBus
class EV9CameraLead(CANFDCameraLead):
def __init__(self, cp):
if not qualified(cp):
raise ValueError('Camera lead owner requires the exact active EV9 longitudinal profile')
super().__init__(cp, CanBus(cp).ECAN)
@@ -0,0 +1,57 @@
"""Bounded EV9 startup tester sender; never receives CAN or restores ECUs."""
import threading
import time
STARTUP_DEADLINE_S = 60.0 # Shared software guard convention, not measured ECU timing.
TESTER_PERIOD_S = 1.0 # Original controller sends Tester Present every100 control frames.
class EV9Keeper:
def __init__(self, send, *, clock=time.monotonic, event=None, thread_factory=threading.Thread):
self.send = send
self.clock = clock
self.event = event if event is not None else threading.Event()
self.thread_factory = thread_factory
self.thread = None
self.started = None
self.next_send = None
self.abort_reason = None
def tick(self):
if self.event.is_set() or self.abort_reason is not None:
return False
now = self.clock()
if self.started is None:
self.started = now
self.next_send = now
if now < self.started or now - self.started >= STARTUP_DEADLINE_S:
self.abort_reason = 'startup software deadline or clock reversal'
self.event.set()
return False
if now >= self.next_send:
try:
self.send()
except Exception:
self.abort_reason = 'startup tester send failed'
self.event.set()
return False
self.next_send = now + TESTER_PERIOD_S
return True
def start(self):
if self.thread is not None:
raise RuntimeError('EV9 keeper already started')
self.started = self.clock()
self.next_send = self.started
def loop():
while self.tick():
self.event.wait(TESTER_PERIOD_S)
self.thread = self.thread_factory(target=loop, name='ev9-startup-tester', daemon=True)
self.thread.start()
def stop(self):
self.event.set()
# Called outside VehicleStartupOwner.send_lock; the sender may hold it.
if self.thread is not None:
self.thread.join()
self.thread = None
@@ -0,0 +1,234 @@
"""EV9 CCNC LONG wire recipes from Dom2efe, with explicit current DBC aliases."""
import numpy as np
from opendbc.car import CanData
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.hyundai.values import CAR
from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum
def _set_value(msg: bytearray, sig, ival: int) -> None:
i = sig.lsb // 8
bits = sig.size
if sig.size < 64:
ival &= (1 << sig.size) - 1
while 0 <= i < len(msg) and bits > 0:
shift = sig.lsb % 8 if (sig.lsb // 8) == i else 0
size = min(bits, 8 - shift)
mask = ((1 << size) - 1) << shift
msg[i] &= ~mask
msg[i] |= (ival & ((1 << size) - 1)) << shift
bits -= size
ival >>= size
i = i + 1 if sig.is_little_endian else i - 1
def _create_angle_adas_cmd_msg(packer, CAN, apply_angle: float, lat_active: bool, torque_reduction_gain: float):
values = {
"ADAS_ActvACISta": 0,
"ADAS_ActvACILvl2Sta": 2 if lat_active else 1,
"ADAS_StrAnglReqVal": apply_angle,
"ADAS_ACIAnglTqRedcGainVal": torque_reduction_gain if lat_active else 0.0,
"FCA_ESA_ActvSta": 0,
"FCA_ESA_TqBstGainVal": 0.0,
}
return packer.make_can_msg("LFA_ALT", CAN.ECAN, values)
def create_angle_adas_cmd(packer, CAN, apply_angle: float, lat_active: bool, torque_reduction_gain: float):
return _create_angle_adas_cmd_msg(packer, CAN, apply_angle, lat_active, torque_reduction_gain)
def create_inactive_angle_steering_messages(packer, CAN, steering_angle: float):
# Explicit nonzero original aliases: LKA_MODE/ICON/TORQUE_REQUEST/DAMP_FACTOR.
neutral = packer.make_can_msg("LFA", CAN.ECAN, {"LKA_OptUsmSta": 2, "LKA_SysIndReq": 1,
"StrTqReqVal": 0, "Damping_Gain": 100})
return [neutral, create_angle_adas_cmd(packer, CAN, steering_angle, False, 0.0)]
def create_ccnc_blindspot_status_messages(packer, CP, CAN, counter, left_blindspot=False, right_blindspot=False,
left_escalated=False, right_escalated=False, drive_gear=False,
left_warning_lamp=False, right_warning_lamp=False,
left_sound_active=False, right_sound_active=False):
left_state = 2 if left_blindspot and left_escalated else (1 if left_blindspot else 0)
right_state = 2 if right_blindspot and right_escalated else (1 if right_blindspot else 0)
left_osm_state = 2 if left_warning_lamp else 1 if left_state == 1 else 0
right_osm_state = 2 if right_warning_lamp else 1 if right_state == 1 else 0
desired_fields = {
"BCW_IndSta": 1,
"BCA_OnOffEquip2Sta": 2,
"BCA_Sta": int(drive_gear),
"BCW_LtIndSta": left_state,
"BCW_RtIndSta": right_state,
"BCW_LtSndWrngSta": int(left_sound_active),
"BCW_RtSndWrngSta": int(right_sound_active),
"OSMrrLamp_LtIndSta": left_osm_state,
"OSMrrLamp_RtIndSta": right_osm_state,
}
return [
_create_ccnc_adrv_message_with_signals(
packer, CP, CAN, 0x1BA, counter, "ADAS_CMD_50_50ms", desired_fields,
),
# No retained radar input reproduces the stock RCTA target decision across routes.
_create_ccnc_adrv_message(CP.carFingerprint, 0x1E5, CAN.ECAN, counter),
]
def create_ccnc_adrv_messages(packer, CP, CAN, frame, enabled, main_cruise_enabled, hud, out, is_metric,
steering_available, steering_active, left_blindspot, right_blindspot,
drive_gear=False,
hba_icon=0,
left_escalated=False, right_escalated=False,
left_warning_lamp=False, right_warning_lamp=False,
left_sound_active=False, right_sound_active=False):
ret = [
_create_ccnc_adrv_message(CP.carFingerprint, address, CAN.ECAN, frame // period)
for address, period in _CCNC_ADRV_PERIODS[CP.carFingerprint].items() if frame % period == 0
]
if frame % 5 == 0:
ret.extend(create_ccnc_angle_long_status_messages(
packer, CP, CAN, frame // 5, enabled, main_cruise_enabled, hud, out, is_metric,
steering_available, steering_active, hba_icon,
))
ret.extend(create_ccnc_blindspot_status_messages(
packer, CP, CAN, frame // 5, left_blindspot, right_blindspot, left_escalated, right_escalated,
drive_gear,
left_warning_lamp, right_warning_lamp, left_sound_active, right_sound_active,
))
return ret
_CCNC_ADRV_TEMPLATES = {CAR.KIA_EV9: {
0x160: bytes.fromhex("0000000100000000fffc0100a8001000"),
0x1DA: bytes.fromhex("0000002200110000000000000000000000000000000000000000000000000000"),
0x1EA: bytes.fromhex("000000080000000000000000000000ff000000000000000000000000000f0f00"),
0x200: bytes.fromhex("00000014801a0000"),
0x345: bytes.fromhex("0000001500560000"),
0x161: bytes.fromhex("0000000000000000c0fff0c003000040000000000000000000ff000000000000"),
0x162: bytes.fromhex("0000002700000000000000000000000000000000000000000000000000000000"),
0x1BA: bytes.fromhex("00000000000000880200000000000000000100000000000f"),
0x1E5: bytes.fromhex("00000000000000000000220300000080"),
0x1E0: bytes.fromhex("00000002000000000000000000000000"),
0x38C: bytes.fromhex("000000f71f000000000000000000000000000000000000000000000000000000"),
}}
_CCNC_ADRV_PERIODS = {CAR.KIA_EV9: {
0x160: 2,
0x1DA: 100,
0x1EA: 5,
0x200: 5,
0x345: 20,
0x1E0: 5,
0x38C: 20,
}}
def _create_ccnc_adrv_message(car_fingerprint, address: int, bus: int, counter: int) -> CanData:
d = bytearray(_CCNC_ADRV_TEMPLATES[car_fingerprint][address])
d[2] = counter & 0xFF
crc = hkg_can_fd_checksum(address, None, d)
d[0] = crc & 0xFF
d[1] = (crc >> 8) & 0xFF
return CanData(address, bytes(d), bus)
def _set_ccnc_message_signals(packer, message_name: str, dat: bytearray, values: dict) -> None:
dbc_msg = packer.dbc.name_to_msg[message_name]
for name, value in values.items():
sig = dbc_msg.sigs[name]
ival = int(np.floor((value - sig.offset) / sig.factor + 0.5))
if ival < 0:
ival = (1 << sig.size) + ival
_set_value(dat, sig, ival)
def _create_ccnc_adrv_message_with_signals(packer, CP, CAN, address: int, counter: int,
message_name: str, values: dict) -> CanData:
msg = _create_ccnc_adrv_message(CP.carFingerprint, address, CAN.ECAN, counter)
dat = bytearray(msg.dat)
# Update decoded fields in the verified neutral payload.
_set_ccnc_message_signals(packer, message_name, dat, values)
crc = hkg_can_fd_checksum(address, None, dat)
dat[0] = crc & 0xFF
dat[1] = (crc >> 8) & 0xFF
return CanData(address, bytes(dat), CAN.ECAN)
def create_ccnc_acc_control(packer, CAN, enabled: bool, accel: float,
stop_request: bool, cruise_standstill: bool, gas_override: bool, set_speed: float,
main_mode_acc: int, lead_distance: float, lead_rel_speed: float, lead_visible: bool,
v_ego: float, jerk_lower: float = 0.7, jerk_upper: float = 0.7):
if not enabled or gas_override or stop_request:
accel = 0.0
lead_visible = bool(enabled and lead_visible)
desired_headway = min(max(round(1.625 * max(v_ego, 0.0), 1), 3.5), 204.6) if enabled else 204.6
values = {
"ACCMode": 0 if not enabled else (2 if gas_override else 1),
"MainMode_ACC": int(bool(main_mode_acc)),
"StopReq": 1 if stop_request and enabled else 0,
"CRUISE_STANDSTILL": 1 if cruise_standstill and stop_request and enabled else 0,
"aReqValue": accel,
"aReqRaw": accel,
"VSetDis": set_speed,
"JerkLowerLimit": jerk_lower if enabled else 1.0,
"JerkUpperLimit": jerk_upper if enabled else 3.0,
"ACC_ObjDist": float(np.clip(lead_distance, 0.0, 204.7)) if lead_visible else 204.6,
"ACC_ObjRelSpd": float(np.clip(lead_rel_speed, -16.4, 34.7)) if lead_visible else 34.6,
"ObjValid": 0 if lead_visible else 1,
"OBJ_STATUS": 2 if enabled and lead_visible else 0,
"NEW_SIGNAL_15": desired_headway,
"SET_ME_2": 4,
"SET_ME_3": 3,
"SET_ME_TMP_64": 0x64,
# Stock CCNC LKA-long routes use raw 7. The DBC's physical range is stale.
"DISTANCE_SETTING": 7 if enabled else 0,
}
address, payload, bus = packer.make_can_msg("SCC_CONTROL", CAN.ECAN, values)
dat = bytearray(payload)
# Original NEW_SIGNAL_3 is bits108..109 (Motorola109|2); current SCC_ObjSta
# additionally names bit110. Preserve the literal two-bit value, leaving110 zero.
dat[13] = (dat[13] & ~0x30) | ((2 if lead_visible else 0) << 4)
dat[0:2] = hkg_can_fd_checksum(address, None, dat).to_bytes(2, "little")
return CanData(address, bytes(dat), bus)
def create_ccnc_angle_long_status_messages(packer, CP, CAN, counter: int, enabled: bool = False,
main_cruise_enabled: bool = False, hud=None, out=None,
is_metric: bool = True, steering_available: bool = False,
steering_active: bool = False, hba_icon: int = 0) -> list[CanData]:
cruise_speed = round(out.vCruiseCluster * (1 if is_metric else CV.KPH_TO_MPH)) if out is not None else 0
display_speed = (40 if is_metric else 25) if cruise_speed > (145 if is_metric else 90) else max(cruise_speed, 0)
main_standby = bool(main_cruise_enabled and not enabled)
values_161 = {
"FCA_ICON": 1, # orange: FCA unavailable
"FCA_ALT_ICON": 0,
"FCA_IMAGE": 0,
"ALERTS_1": 0,
"ALERTS_2": 0,
"ALERTS_3": 0,
"ALERTS_4": 0,
"ALERTS_5": 0,
"SOUNDS_1": 0,
"SOUNDS_2": 0,
"SOUNDS_3": 0,
"SOUNDS_4": 0,
"LFA_ICON": (2 if steering_active else 1) if steering_available else 0,
"HBA_ICON": hba_icon if hba_icon in (1, 2) else 0,
"HDA_ICON": 2 if enabled else 1 if main_standby else 0,
"TARGET": 3 if enabled else 0,
"SETSPEED": 3 if enabled else 1 if main_standby else 0,
"SETSPEED_HUD": 2 if enabled else 1 if main_standby else 0,
"SETSPEED_SPEED": display_speed if enabled or main_standby else 255,
"DISTANCE": hud.leadDistanceBars if enabled and hud is not None else 0,
"DISTANCE_SPACING": 3 if enabled or main_standby else 0,
"DISTANCE_CAR": 2 if enabled else 1 if main_standby else 0,
}
values_162 = {fault: 0 for fault in (
"FAULT_FSS", "FAULT_FCA", "FAULT_LSS", "FAULT_SLA", "FAULT_HDA", "FAULT_DAS", "FAULT_LFA", "FAULT_DAW",
"FAULT_HBA", "FAULT_ESS",
)}
values_162["VIBRATE"] = 0
return [
_create_ccnc_adrv_message_with_signals(packer, CP, CAN, 0x161, counter, "CCNC_0x161", values_161),
_create_ccnc_adrv_message_with_signals(packer, CP, CAN, 0x162, counter, "CCNC_0x162", values_162),
]
def create_radar_heartbeat(counter, brake_pressed, accelerator_pressed):
d = bytearray.fromhex("00000000ff006f00e80400001201030055ffff0000000000")
d[2] = counter & 0xff
d[4] = (d[4] & ~1) | int(brake_pressed)
d[22] = (d[22] & ~1) | int(accelerator_pressed)
crc = hkg_can_fd_checksum(0x100, None, d)
d[0], d[1] = crc & 0xff, crc >> 8
return CanData(0x100, bytes(d), 0)
@@ -0,0 +1,336 @@
# EV9 calibration extracted from the reached Dom controller; isolated from Ioniq6 policy.
# Preserve source calibration, 20Hz update and 50Hz stop latch separately.
from dataclasses import dataclass
import numpy as np
from opendbc.car import DT_CTRL, structs
from opendbc.car.hyundai.values import CAR
from opendbc.car.hyundai.ccnc_ev_stock import qualified as stock_qualified
class CarControllerParams:
ACCEL_MIN = -3.5
ACCEL_MAX = 3.5
LongCtrlState = structs.CarControl.Actuators.LongControlState
IONIQ_6_RESPONSE_MULTIPLIER = 1.2
IONIQ_6_LONG_MIN_JERK = 0.5 * IONIQ_6_RESPONSE_MULTIPLIER
IONIQ_6_LONG_JERK_LIMIT = 4.8 * IONIQ_6_RESPONSE_MULTIPLIER
EV9_LONG_DECEL_JERK = 2.0
IONIQ_6_LONG_LOOKAHEAD_JERK_BP = [2.0, 5.0, 20.0]
IONIQ_6_LONG_LOOKAHEAD_JERK_V = [0.3 / IONIQ_6_RESPONSE_MULTIPLIER,
0.45 / IONIQ_6_RESPONSE_MULTIPLIER,
0.6 / IONIQ_6_RESPONSE_MULTIPLIER]
IONIQ_6_DYNAMIC_LOWER_JERK_BP = [-2.0, -1.5, -1.0, -0.25, -0.1, -0.025, -0.01, -0.005]
IONIQ_6_DYNAMIC_LOWER_JERK_V = [3.3, 1.5, 1.0, 0.8, 0.7, 0.65, 0.55, 0.5]
IONIQ_6_LAUNCH_HOLD_SPEED_BP = [0.0, 0.6, 1.25, 2.5]
IONIQ_6_LAUNCH_HOLD_SPEED_V = [0.75, 0.6, 0.4, 0.0]
IONIQ_6_STOP_BRAKE_CAP_MAX_SPEED = 2.0
IONIQ_6_STOP_BRAKE_CAP_SPEED_BP = [0.0, 0.08, 0.25, 0.6, 1.2, 2.0, 3.0]
IONIQ_6_STOP_BRAKE_CAP_ACCEL_V = [-0.15, -0.16, -0.22, -0.42, -0.78, -1.15, -1.40]
EV6_GT_LINE_STOP_BRAKE_CAP_MAX_SPEED = 1.2
IONIQ_6_STOP_HOLD_JERK_BP = [0.0, 0.15, 0.6, 1.2, 2.0, 3.0]
IONIQ_6_STOP_HOLD_JERK_V = [0.35, 0.40, 0.48, 0.65, 0.85, 1.10]
IONIQ_6_STOP_RELEASE_JERK_BP = [0.0, 0.15, 0.5]
IONIQ_6_STOP_RELEASE_JERK_V = [3.6 * IONIQ_6_RESPONSE_MULTIPLIER,
4.2 * IONIQ_6_RESPONSE_MULTIPLIER,
4.8 * IONIQ_6_RESPONSE_MULTIPLIER]
EV9_STOP_REQUEST_SPEED = 0.47
EV9_STANDSTILL_DELAY_FRAMES = 178
EV9_STOP_RELEASE_DELAY_FRAMES = 6
@dataclass
class Ioniq6LongitudinalTuningState:
desired_accel: float = 0.0
actual_accel: float = 0.0
accel_last: float = 0.0
jerk_upper: float = 0.0
jerk_lower: float = 0.0
launch_active: bool = False
stopping: bool = False
stopping_count: int = 0
long_control_state_last: LongCtrlState = LongCtrlState.off
@dataclass(frozen=True)
class EV9LongitudinalTuningState:
stop_request: bool = False
cruise_standstill: bool = False
stop_request_frames: int = 0
release_frames: int = 0
def _jerk_limited_integrator(desired_accel: float, last_accel: float, jerk_upper: float, jerk_lower: float) -> float:
step = (jerk_upper if desired_accel >= last_accel else jerk_lower) * DT_CTRL * 5.0
return float(np.clip(desired_accel, last_accel - step, last_accel + step))
def _calculate_ioniq_6_dynamic_lower_jerk(accel_error: float) -> float:
if accel_error < 0.0:
scaled_values = np.array(IONIQ_6_DYNAMIC_LOWER_JERK_V) * (IONIQ_6_LONG_JERK_LIMIT / IONIQ_6_DYNAMIC_LOWER_JERK_V[0])
return float(np.interp(accel_error, IONIQ_6_DYNAMIC_LOWER_JERK_BP, scaled_values))
return IONIQ_6_LONG_MIN_JERK
def update_ev9_longitudinal_tuning(state: EV9LongitudinalTuningState, enabled: bool,
stopping: bool, v_ego: float) -> EV9LongitudinalTuningState:
if not enabled:
return EV9LongitudinalTuningState()
if stopping:
if not state.stop_request and v_ego > EV9_STOP_REQUEST_SPEED:
return EV9LongitudinalTuningState()
frames = state.stop_request_frames + 1 if state.stop_request else 0
return EV9LongitudinalTuningState(
stop_request=True,
cruise_standstill=frames >= EV9_STANDSTILL_DELAY_FRAMES,
stop_request_frames=frames,
)
if state.stop_request:
release_frames = state.release_frames + 1
if release_frames <= EV9_STOP_RELEASE_DELAY_FRAMES:
return EV9LongitudinalTuningState(
stop_request=True,
cruise_standstill=False,
stop_request_frames=state.stop_request_frames,
release_frames=release_frames,
)
return EV9LongitudinalTuningState()
def reset_egmp_longitudinal_tuning(state: Ioniq6LongitudinalTuningState) -> Ioniq6LongitudinalTuningState:
state.desired_accel = 0.0
state.actual_accel = 0.0
state.accel_last = 0.0
state.jerk_upper = 0.0
state.jerk_lower = 0.0
state.launch_active = False
return state
def update_ioniq_6_longitudinal_tuning(state: Ioniq6LongitudinalTuningState, accel_cmd: float, v_ego: float, a_ego: float,
long_control_state: LongCtrlState, long_active: bool,
ev6_gt_line: bool = False, low_speed_stop_brake_cap: bool = False,
ev9: bool = False) -> Ioniq6LongitudinalTuningState:
starting = long_control_state == LongCtrlState.starting
stopping = long_control_state == LongCtrlState.stopping
restart_from_stop = state.long_control_state_last in (LongCtrlState.stopping, LongCtrlState.starting) and \
long_control_state in (LongCtrlState.starting, LongCtrlState.pid) and accel_cmd > 0.0 and v_ego < 0.5
state.stopping = long_active and stopping
state.stopping_count = state.stopping_count + 1 if state.stopping else 0
if not long_active:
state.desired_accel = 0.0
state.actual_accel = 0.0
state.accel_last = 0.0
state.jerk_upper = 0.0
state.jerk_lower = 0.0
state.launch_active = False
state.long_control_state_last = long_control_state
return state
if accel_cmd <= 0.0 or v_ego >= IONIQ_6_LAUNCH_HOLD_SPEED_BP[-1]:
state.launch_active = False
elif starting or (state.launch_active and v_ego < IONIQ_6_LAUNCH_HOLD_SPEED_BP[-1]) or \
(state.long_control_state_last == LongCtrlState.starting and long_control_state == LongCtrlState.pid and v_ego < IONIQ_6_LAUNCH_HOLD_SPEED_BP[-1]):
state.launch_active = True
upper_speed_limit = float(np.interp(v_ego, [0.0, 5.0, 20.0], [2.0, 3.0, 2.0])) * IONIQ_6_RESPONSE_MULTIPLIER if long_control_state == LongCtrlState.pid else IONIQ_6_LONG_MIN_JERK
lower_speed_limit = float(np.interp(v_ego, [0.0, 5.0, 20.0], [5.0, 3.5, 3.0])) * IONIQ_6_RESPONSE_MULTIPLIER
future_t_upper = float(np.interp(v_ego, IONIQ_6_LONG_LOOKAHEAD_JERK_BP, IONIQ_6_LONG_LOOKAHEAD_JERK_V))
accel_error = accel_cmd - state.accel_last
j_ego_upper = float(np.clip(accel_error / future_t_upper, -IONIQ_6_LONG_JERK_LIMIT, IONIQ_6_LONG_JERK_LIMIT))
desired_jerk_upper = min(max(j_ego_upper, IONIQ_6_LONG_MIN_JERK), upper_speed_limit)
dynamic_accel_error = a_ego - state.accel_last
dynamic_lower_jerk = _calculate_ioniq_6_dynamic_lower_jerk(dynamic_accel_error)
state.jerk_upper = desired_jerk_upper
state.jerk_lower = min(dynamic_lower_jerk, lower_speed_limit)
if ev9:
state.jerk_lower = min(state.jerk_lower, EV9_LONG_DECEL_JERK)
if state.stopping:
stop_brake_cap_max_speed = EV6_GT_LINE_STOP_BRAKE_CAP_MAX_SPEED if ev6_gt_line or low_speed_stop_brake_cap else \
IONIQ_6_STOP_BRAKE_CAP_MAX_SPEED
if v_ego <= stop_brake_cap_max_speed:
stop_brake_cap = float(np.interp(v_ego, IONIQ_6_STOP_BRAKE_CAP_SPEED_BP, IONIQ_6_STOP_BRAKE_CAP_ACCEL_V))
state.desired_accel = min(0.0, max(accel_cmd, stop_brake_cap))
state.jerk_upper = min(state.jerk_upper, float(np.interp(v_ego, IONIQ_6_STOP_HOLD_JERK_BP, IONIQ_6_STOP_HOLD_JERK_V)) * IONIQ_6_RESPONSE_MULTIPLIER)
else:
state.desired_accel = float(np.clip(accel_cmd, CarControllerParams.ACCEL_MIN, 0.0))
else:
state.desired_accel = float(np.clip(accel_cmd, CarControllerParams.ACCEL_MIN, CarControllerParams.ACCEL_MAX))
if state.launch_active:
state.desired_accel = max(state.desired_accel, float(np.interp(v_ego, IONIQ_6_LAUNCH_HOLD_SPEED_BP, IONIQ_6_LAUNCH_HOLD_SPEED_V)))
state.jerk_upper = max(state.jerk_upper, float(np.interp(v_ego, [0.0, 2.5], [4.8, 3.2])) * IONIQ_6_RESPONSE_MULTIPLIER)
state.jerk_lower = max(state.jerk_lower, 1.0)
if restart_from_stop:
state.jerk_upper = min(state.jerk_upper, float(np.interp(v_ego, IONIQ_6_STOP_RELEASE_JERK_BP, IONIQ_6_STOP_RELEASE_JERK_V)))
state.actual_accel = _jerk_limited_integrator(state.desired_accel, state.accel_last, state.jerk_upper, state.jerk_lower)
state.accel_last = state.actual_accel
state.long_control_state_last = long_control_state
return state
BLINDSPOT_WARNING_FLASH_SAMPLES = 20
BLINDSPOT_WARNING_FLASH_ON_SAMPLES = 16
BLINDSPOT_WARNING_SOUND_SAMPLES = 36
@dataclass(frozen=True)
class BlindspotWarningOutput:
mirror_lamp_active: bool = False
sound_active: bool = False
@dataclass
class BlindspotWarningState:
flash_phase: int = 0
mirror_warning_active: bool = False
escalated_prev: bool = False
sound_remaining: int = 0
sound_armed: bool = True
def update_blindspot_warning(state: BlindspotWarningState, escalated: bool,
blinker: bool) -> BlindspotWarningOutput:
if not blinker:
state.flash_phase = 0
state.mirror_warning_active = False
state.escalated_prev = False
state.sound_remaining = 0
state.sound_armed = True
return BlindspotWarningOutput()
rising = escalated and not state.escalated_prev
if rising:
state.flash_phase = 0
state.mirror_warning_active = True
if state.sound_armed:
state.sound_remaining = BLINDSPOT_WARNING_SOUND_SAMPLES
state.sound_armed = False
elif escalated:
state.flash_phase = (state.flash_phase + 1) % BLINDSPOT_WARNING_FLASH_SAMPLES
state.mirror_warning_active = True
elif state.mirror_warning_active and state.flash_phase < BLINDSPOT_WARNING_FLASH_ON_SAMPLES - 1:
state.flash_phase += 1
else:
state.flash_phase = 0
state.mirror_warning_active = False
state.escalated_prev = escalated
sound_active = state.sound_remaining > 0
if state.sound_remaining > 0:
state.sound_remaining -= 1
return BlindspotWarningOutput(
mirror_lamp_active=state.mirror_warning_active and state.flash_phase < BLINDSPOT_WARNING_FLASH_ON_SAMPLES,
sound_active=sound_active,
)
class EV9LongitudinalPolicy:
def __init__(self):
self.tuning = Ioniq6LongitudinalTuningState()
self.stop = EV9LongitudinalTuningState()
self.left_warning = BlindspotWarningState()
self.right_warning = BlindspotWarningState()
def update(self, frame, accel, speed, measured_accel, control_state, enabled, override):
# Original reset runs at100Hz, calibration only every fifth controller frame.
if self.stop.stop_request or not enabled or override:
reset_egmp_longitudinal_tuning(self.tuning)
active = control_state in (LongCtrlState.starting, LongCtrlState.pid, LongCtrlState.stopping)
if active and frame % 5 == 0:
update_ioniq_6_longitudinal_tuning(self.tuning, accel, speed, measured_accel, control_state, True,
low_speed_stop_brake_cap=True, ev9=True)
return self.tuning.actual_accel if active else accel
def update_stop(self, enabled, override, stopping, speed):
self.stop = update_ev9_longitudinal_tuning(self.stop, enabled and not override, stopping, speed)
if self.stop.stop_request or not enabled or override:
reset_egmp_longitudinal_tuning(self.tuning)
return 0.0
return None
STOCK_PARAM = 0x5c91
LONG_PARAM = 0x5c95
def copy_cp(cp):
with structs.CarParams.from_bytes(structs.CarParams.new_message(**cp.to_dict()).to_bytes()) as reader:
return reader.as_builder()
def stock_copy(cp):
stock = copy_cp(cp)
stock.safetyConfigs[0].safetyParam = STOCK_PARAM
stock.openpilotLongitudinalControl = False
stock.pcmCruise = True
stock.longitudinalActuatorDelay = 0.5
return stock
def candidate(stock, *, enabled, is_release):
if not enabled or is_release or stock.carFingerprint != CAR.KIA_EV9 or not stock_qualified(stock):
return stock
result = copy_cp(stock)
result.alphaLongitudinalAvailable = True
result.openpilotLongitudinalControl = True
result.pcmCruise = False
result.safetyConfigs[0].safetyParam = LONG_PARAM
result.longitudinalActuatorDelay = 0.3
return result
def qualified(cp):
if (cp.carFingerprint != CAR.KIA_EV9 or not cp.openpilotLongitudinalControl or cp.pcmCruise or
not cp.alphaLongitudinalAvailable or len(cp.safetyConfigs) != 1 or cp.safetyConfigs[0].safetyParam != LONG_PARAM):
return False
return stock_qualified(stock_copy(cp))
class LegacyAngleEnvelope:
"""Original Sportage safety envelope, never vehicle identity or metadata.
Original source VM: wheelbase2.756, steerRatio13.7, zero rear steering,
slip factor -.0006085930193026732 (original native mode mirrors that CP).
Only the EV9 LONG owner's original host calibration consumes this model.
"""
def get_steer_from_curvature(self, curvature, speed, roll):
assert roll == 0
# Original CarParams Float32 stores precede VehicleModel construction.
return curvature * 2.75600004196167 * 13.699999809265137 * (1.0 - (-0.0006085930193026732) * speed ** 2)
def lateral_request_allowed(control, state, angle_fault):
"""Host half of the existing exact5c95 native actuation predicates.
Independent AOL cannot supply authority. Unlike the original reached caller,
stale latActive alone does not bypass engagement, pedals, standstill or health.
CAN validity is the host's observable health; native RX validation remains final.
"""
return (control.enabled and state.canValid and not state.canTimeout and not state.standstill and
state.gearShifter == structs.CarState.GearShifter.drive and
not (state.brakePressed or state.gasPressed or state.steerFaultTemporary or state.steerFaultPermanent or angle_fault))
@@ -0,0 +1,168 @@
"""EV9 source-selected communication-control startup, resolved before CarParams publication."""
from copy import deepcopy
import time
from opendbc.car import Bus, make_tester_present_msg
from opendbc.can.parser import MAX_BAD_COUNTER
from opendbc.car.hyundai.ecu_startup import HyundaiECUStartup, Outcome
from opendbc.car.hyundai.ev9_longitudinal import qualified, copy_cp
from opendbc.car.hyundai.ccnc_ev_stock import qualified as stock_qualified
from opendbc.car.hyundai.values import CAR
from opendbc.car.hyundai.ev9_keeper import EV9Keeper
def required(cp):
# A malformed LONG CP still requires the owner; it must never bypass it.
return cp.carFingerprint == CAR.KIA_EV9 and cp.openpilotLongitudinalControl
class EV9Startup(HyundaiECUStartup):
def __init__(self, cp, callbacks, *, stock_cp, clock=time.monotonic, keeper_factory=EV9Keeper):
super().__init__(cp, callbacks, address=0x730, bus=1, label='EV9')
if stock_cp.carFingerprint != CAR.KIA_EV9 or not stock_qualified(stock_cp):
raise ValueError('EV9 startup requires exact original stock CP')
self.clock = clock
self.stock_cp = copy_cp(stock_cp)
self.keeper_factory = keeper_factory
self.keeper = None
self.handed_off = False
self.disable_response = None
def _send(self, frames):
# Source-selected EV9 request is unsuppressed enable-RX/disable-TX.
for address, data, bus in frames:
if address == self.address and bus == self.bus and len(data) >= 4 and data[:4] == b"\x03\x28\x01\x01":
self.disable_attempted = True
self.callbacks[1](frames)
def _disable(self, can_recv, can_send, **kwargs):
# Keep the original outcome distinction: command silence is sent,
# unconfirmed; an explicit negative reply is failure. No SecurityAccess.
for _ in range(10):
try:
session = self._query(can_send, can_recv, self.bus, [(self.address, None)], [b"\x10\x03"], [b"\x50\x03"])
if not session.get_data(.1):
time.sleep(.1)
continue
time.sleep(.05)
command = self._query(can_send, can_recv, self.bus, [(self.address, None)], [b"\x28\x01\x01"], [b""])
replies = command.get_data(.1)
if not replies:
self.disable_response = 'no_reply_sent_unconfirmed'
return True
for payload in replies.values():
if payload == b"\x68\x01":
self.disable_response = 'positive_communication_control'
elif len(payload) == 3 and payload[:2] == b"\x7f\x28":
self.disable_response = 'negative_communication_control'
return False
else:
self.disable_response = 'unexpected_or_malformed_reply'
return False
return True
except Exception:
# Once the command was emitted, do not retry into uncertain ownership.
# The shared owner restores before attempting stock publication.
if self.disable_attempted:
raise
time.sleep(.1)
return False
def _ready(self):
deadline = self.clock() + 0.5
while self.clock() < deadline:
packets = self.callbacks[0](wait_for_one=False)
for packet in packets:
for message in packet:
if message.address == 0x35 and message.src == self.bus and len(message.dat) > 3 and message.dat[3] & 0x40:
return True
time.sleep(.005)
return False
def prepare(self, *, admission):
if not qualified(self.cp):
raise ValueError('Unqualified EV9 LONG startup profile')
# A READY vehicle retains stock SCC and never receives communication control.
skip = not admission() or self._ready()
super().prepare(admission=(lambda: False) if skip else admission)
if self.outcome in (Outcome.STOCK_UNTOUCHED, Outcome.STOCK_RESTORED):
self.cp = copy_cp(self.stock_cp)
self.prepared_cp = deepcopy(self.cp.to_dict())
elif self.outcome is Outcome.SENT_UNCONFIRMED:
self.keeper = self.keeper_factory(
lambda: self.callbacks[1]([make_tester_present_msg(self.address, self.bus, suppress_response=True)]), clock=self.clock)
self.keeper.start()
return self.cp
def check(self):
super().check()
if self.keeper is not None and self.keeper.abort_reason is not None:
self.keeper.stop()
# Main thread alone receives CAN/restores. Never mutate published CP.
if self.disable_attempted and not self.restored:
try:
self._restore()
except Exception:
pass
self.outcome = Outcome.ABORT_UNCERTAIN
raise RuntimeError('EV9 startup keeper aborted; session must stop')
def sources_current(self, ci, now_ns):
parser = ci.can_parsers[Bus.pt]
for name in ('MDPS', 'ACCELERATOR', 'WHEEL_SPEEDS', 'TCS'):
source = parser.message_states.get(parser.dbc.name_to_msg[name].address)
if source is None or not source.timestamps or source.counter_fail >= MAX_BAD_COUNTER:
return False
stamp = int(source.timestamps[-1])
if not self.floor_ns < stamp <= now_ns or now_ns - stamp > source.timeout_threshold:
return False
return True
def before_control(self, *, configured, sources_current, control_current):
self.check()
if self.outcome is not Outcome.SENT_UNCONFIRMED:
return True
if not (configured and sources_current and control_current):
return False
if not self.handed_off:
self.keeper.stop() # No sender lock held; join before controller handoff.
self.check()
self.handed_off = True
return True
def close(self):
if self.keeper is not None:
self.keeper.stop()
if self.keeper.abort_reason is not None and self.disable_attempted and not self.restored:
try:
self._restore()
except Exception:
pass
super().close()
def _warm_stock(self, ci):
deadline = self.clock() + 3.
while self.clock() < deadline:
packets = self.callbacks[0]()
stamped = [(int(getattr(packet, 'log_mono_time_ns', 0)), list(packet)) for packet in packets]
if any(stamp <= 0 for stamp, _ in stamped):
raise RuntimeError('EV9 stock warmup requires timestamped CAN')
state = ci.update(stamped)
now = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
parser = ci.can_parsers[Bus.pt]
ready = state.canValid and not state.canTimeout and parser.bus == 1
for name in ('SCC_CONTROL', 'MDPS', 'ACCELERATOR', 'WHEEL_SPEEDS', 'TCS'):
source = parser.message_states.get(parser.dbc.name_to_msg[name].address)
if source is None or not source.timestamps or source.counter_fail >= MAX_BAD_COUNTER:
ready = False
continue
stamp = int(source.timestamps[-1])
if not self.floor_ns < stamp <= now or now - stamp > source.timeout_threshold:
ready = False
if ready:
self.ready = True
return
time.sleep(.005)
self.outcome = Outcome.ABORT_UNCERTAIN
raise RuntimeError('EV9 stock sources did not resume before publication')
@@ -0,0 +1,73 @@
"""Original EV9 normal LongControl start/release policy in the modern extension."""
import numpy as np
from opendbc.car.structs import car
from opendbc.car.hyundai.ev9_longitudinal import qualified
from opendbc.car.hyundai.longitudinal_mode import HKGModePolicy
LongCtrlState = car.CarControl.Actuators.LongControlState
START_ACCEL = float(np.float32(.2))
START_SPEED = float(np.float32(.5))
STOP_SPEED = float(np.float32(.3))
def policy_for(cp, dt):
return EV9StoppingPolicy(dt) if qualified(cp) else None
class EV9StoppingPolicy(HKGModePolicy):
stopping_decel_rate = float(np.float32(.4))
def __init__(self, dt):
self.init_mode(dt)
self.release_frames = int(round(.35 / dt))
self.reset()
def reset(self):
self.release_counter = 0
def transition(self, native_state, previous_state, active, CS, a_target, should_stop, *, has_lead):
# Original Toyota stopped-lead hold branch is unreachable for this identity.
if previous_state != LongCtrlState.stopping:
self.release_counter = 0
ready = True
elif should_stop or CS.brakePressed:
self.release_counter = 0
ready = False
elif (CS.vEgo > START_SPEED or (has_lead and a_target > .15) or
(a_target >= .45 and not CS.cruiseState.standstill)):
self.release_counter = self.release_frames
ready = True
else:
self.release_counter = min(self.release_counter + 1, self.release_frames) if a_target > .15 else 0
ready = self.release_counter >= self.release_frames
if not active:
return LongCtrlState.off
if previous_state == LongCtrlState.off:
return LongCtrlState.starting if not (should_stop or CS.brakePressed or CS.cruiseState.standstill) else LongCtrlState.stopping
if previous_state == LongCtrlState.stopping:
return LongCtrlState.starting if not should_stop and not CS.brakePressed and ready else LongCtrlState.stopping
if should_stop:
return LongCtrlState.stopping
return LongCtrlState.pid if CS.vEgo > START_SPEED else previous_state
def starting_output(self, a_target, accel_limits, context):
# Missing carrier data cannot be interpreted as a request for a launch shove.
if (context.traffic_mode is None or context.custom_acceleration is None or context.has_lead is None or
context.profile_max_accel is None):
output = float(np.clip(a_target, 0., START_ACCEL))
elif context.traffic_mode or context.custom_acceleration or (context.has_lead and a_target <= .25):
output = float(np.clip(a_target, 0., START_ACCEL))
elif context.profile_max_accel > 0.:
output = min(START_ACCEL, context.profile_max_accel)
else:
output = START_ACCEL
self.reset() # Original starting branch resets the release hysteresis.
return float(np.clip(output, *accel_limits))
def stopping_output(self, output, a_target, should_stop, CS):
follow_min_speed = max(1.5, STOP_SPEED + 1.)
if not should_stop or CS.brakePressed or CS.vEgo <= follow_min_speed or a_target >= output - .25:
return output
step = np.interp(CS.vEgo, [follow_min_speed, 3., 6., 10.], [.02, .03, .05, .07])
return max(float(a_target), output - float(step))
@@ -9,6 +9,240 @@ Ecu = CarParams.Ecu
FW_VERSIONS = {
CAR.HYUNDAI_AZERA_HEV_7TH_GEN: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00GN7HMFC AT KOR LHD 1.00 1.01 99211-N1110 240423',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00GN7_ RDR ----- 1.00 1.00 99110-N1100 ',
],
},
CAR.KIA_SPORTAGE_HEV_2026: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00NQ51.011.021.012551000HKP_NQ524_50509099211P1110',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00NQ5__ 1.00 1.04 99110CH100 ',
],
},
CAR.HYUNDAI_SANTA_FE_HEV_5TH_GEN: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00MX5HMFC AT CAN LHD 1.00 1.07 99211-P6000 231218',
b'\xf1\x00MX5HMFC AT KOR LHD 1.00 1.07 99211-P6000 231218',
b'\xf1\x00MX5HMFC AT USA LHD 1.00 1.06 99211-R6000 231218',
b'\xf1\x00MX5HMFC AT USA LHD 1.00 1.09 99211-R6200 250519',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00MX5_ RDR ----- 1.00 1.01 99110-P6000 ',
b'\xf1\x00MX5_ RDR ----- 1.00 1.01 99110-R6000 ',
],
},
CAR.HYUNDAI_IONIQ_9: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00MEev RDR ----- 1.00 1.00 99110-GO000 ',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00ME MFC AT KOR LHD 1.00 1.00 99211-GO000 241007',
b'\xf1\x00ME MFC AT KOR LHD 1.00 1.01 99211-GO000 250103',
],
},
CAR.KIA_SPORTAGE_2026: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00NQ51.011.021.012551000HKP_NQ524_50509099211P1110',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00NQ5__ 1.00 1.04 99110P1100 ',
],
},
CAR.KIA_SORENTO_HEV_4TH_GEN_LFA2: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00MQ4HMFC AT USA LHD 1.00 1.00 99210-P2600 250617',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00MQ4_ RDR ----- 1.00 1.01 99110-P2500 ',
],
},
CAR.GENESIS_GV70_ELECTRIFIED_2ND_GEN: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00JK MFC AT USA LHD 1.00 1.03 99211-DS600 241125',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00JK__ RDR ----- 1.00 1.01 99110-DS500 ',
],
},
CAR.GENESIS_GV70_2026: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00JK MFC AT USA LHD 1.00 1.11 99211-IY600 241125',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00JK__ RDR ----- 1.00 1.01 99110-AR600 ',
],
},
CAR.GENESIS_GV80_2025: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00JX__ RDR ----- 1.00 1.03 99110-T6500 ',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00JX MFC AT USA LHD 1.00 1.03 99211-T6510 240124',
],
},
CAR.HYUNDAI_PALISADE_2023: {
(Ecu.eps, 0x7d4, None): [
b'\xf1\x00ON MDPS C 1.00 1.01 56300-S9500 2922',
b'\xf1\x00LXP MDPS C 1.00 1.00 56310-S8620 4LXPC100',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00ON MFC AT USA LHD 1.00 1.00 99211-S9170 240531',
b'\xf1\x00ON MFC AT USA LHD 1.00 1.01 99211-S9160 230802',
b'\xf1\x00LX2 MFC AT USA LHD 1.00 1.04 99211-S8150 220622',
b'\xf1\x00LX2 MFC AT RUS LHD 1.00 1.04 99211-S8150 220622',
b'\xf1\x00ON MFC AT USA LHD 1.00 1.01 99211-S9150 220708',
b'\xf1\x00ON MFC AT USA LHD 1.00 1.00 99211-S9160 230303',
b'\xf1\x00LX2 MFC AT USA LHD 1.00 1.01 99211-S8600 230817',
b'\xf1\x00LX2 MFC AT USA LHD 1.00 1.00 99211-S8700 240221',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00ON__ SCC FHCUP 1.00 1.00 99110-S9170 ',
b'\xf1\x00ON__ SCC FHCUP 1.00 1.00 99110-S9160 ',
b'\xf1\x00LX2_ SCC ----- 1.00 1.01 99110-S8150 ',
b'\xf1\x00ON__ SCC ----- 1.00 1.01 99110-S9150 ',
b'\xf1\x00LX2_ SCC FHCUP 1.00 1.01 99110-S8150 ',
b'\xf1\x00ON__ SCC FHCUP 1.00 1.01 99110-S9150 ',
b'\xf1\x00LX2 SCC FHCUP 1.00 1.00 99110-S8600 ',
b'\xf1\x00LX2_ SCC FHCUP 1.00 1.01 99110-S8700 ',
b'\xf1\x00LX2_ SCC F-CUP 1.00 1.01 99110-S8150 ',
],
},
CAR.HYUNDAI_IONIQ_5_N: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00NE1N RDR ----- 1.00 1.00 99110-NI000 ',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00NE1NMFC AT KOR LHD 1.00 1.04 99211-NI000 231219',
b'\xf1\x00NE1NMFC AT KOR LHD 1.00 1.00 99211-NI010 240712',
b'\xf1\x00NE1NMFC AT USA LHD 1.00 1.04 99211-NI000 231219',
],
},
CAR.HYUNDAI_TUCSON_PHEV_2025: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00NX4 FR_CMR AT CAN LHD 1.00 1.00 99211-N7030 C55',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00NX4__ 1.00 1.02 99110N7100 ',
],
},
CAR.KIA_K4_2025: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00CL4 MFC AT CAN LHD 1.00 1.02 99210-GG000 240708',
b'\xf1\x00CL4 MFC AT USA LHD 1.00 1.02 99210-GG000 240708',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00CL4_ RDR ----- 1.00 1.01 99110-GG000 ',
b'\xf1\x00CL4_ RDR ----- 1.00 1.01 99110-GG100 ',
],
},
CAR.KIA_SORENTO_2024: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00MQ4 MFC AT AUS RHD 1.01 1.04 99210-P2550 231127',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00MQ4_ RDR ----- 1.00 1.01 99110-P2500 ',
],
},
CAR.KIA_CARNIVAL_2025: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00KA4 MFC AT CAN LHD 1.00 1.00 99210-R0700 250324',
b'\xf1\x00KA4 MFC AT USA LHD 1.00 1.05 99210-R0500 240305',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00KA4_ SCC FHCUP 1.00 1.01 99110-R0510 ',
b'\xf1\x00KA4_ RDR ----- 1.00 1.01 99110-R0510 ',
],
},
CAR.KIA_CARNIVAL_HEV_4TH_GEN: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00KA4HMFC AT USA LHD 1.00 1.05 99210-R0500 240305',
b'\xf1\x00KA4HMFC AT KOR LHD 1.00 1.00 99210-R0600 240924',
b'\xf1\x00KA4HMFC AT USA LHD 1.00 1.00 99210-R0700 250324',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00KAhe RDR ----- 1.00 1.01 99110-ES500 ',
],
},
CAR.HYUNDAI_KONA_2ND_GEN: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00SX2 MFC AT USA LHD 1.00 1.03 99211-BE000 230517',
b'\xf1\x00SX2 MFC AT USA LHD 1.00 1.07 99211-BE000 240611',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00SX2_ RDR ----- 1.00 1.02 99110-BE000 ',
b'\xf1\x00SX2_ RDR ----- 1.00 1.02 99110-BE500 ',
],
},
CAR.HYUNDAI_KONA_HEV_2ND_GEN: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00SX2HMFC AT AUS RHD 1.00 1.00 99211-BE001 241015',
b'\xf1\x00SX2HMFC AT EUR RHD 1.00 1.04 99211-BE000 231010',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00SX2_ RDR ----- 1.00 1.02 99110-BE000 ',
b'\xf1\x00SX2_ RDR ----- 1.00 1.02 99110-BE500 ',
],
},
CAR.HYUNDAI_SONATA_2024: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00DN8 MFC AT KOR LHD 1.00 1.01 99211-L1800 230512',
b'\xf1\x00DN8 MFC AT USA LHD 1.00 1.01 99211-L1800 230512',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00DN8_ RDR ----- 1.00 1.00 99110-L1800 ',
],
},
CAR.HYUNDAI_SONATA_HEV_2024: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00DN8HMFC AT KOR LHD 1.00 1.01 99211-L1800 230512',
b'\xf1\x00DN8HMFC AT USA LHD 1.00 1.01 99211-L1800 230512',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00DN8_ RDR ----- 1.00 1.00 99110-L1800 ',
],
},
CAR.HYUNDAI_TUCSON_2025: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00NX4 FR_CMR AT USA LHD 1.00 1.01 99211-N7050 C5A',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00NX4__ 1.00 1.03 99110N7100 ',
],
},
CAR.HYUNDAI_TUCSON_HEV_2025: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00NX4 FR_CMR AT USA LHD 1.00 1.00 99211-N7030 C55',
b'\xf1\x00NX4 FR_CMR AT EUR LHD 1.00 1.00 99211-N7030 C55',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00NX4__ 1.00 1.02 99110N7000 ',
b'\xf1\x00NX4__ 1.00 1.02 99110N7100 ',
b'\xf1\x00NX4__ 1.00 1.03 99110N7100 ',
],
},
CAR.HYUNDAI_SANTA_CRUZ_2025: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00NX4 FR_CMR AT USA LHD 1.00 1.00 99211-N7030 C55',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00NX4__ 1.00 1.00 99110K5500 ',
],
},
CAR.KIA_K5_2025: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00DL3 MFC AT USA LHD 1.00 1.04 99210-L2500 240117',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00DL3_ RDR ----- 1.00 1.01 99110-L2500 ',
],
},
CAR.HYUNDAI_AZERA_6TH_GEN: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00IG__ SCC F-CU- 1.00 1.00 99110-G8100 ',
@@ -588,6 +822,17 @@ FW_VERSIONS = {
b'\xf1\x00CD ESC \x0b 101 \x10\x03 58910-J7AC0',
],
},
CAR.KIA_XCEED_PHEV: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00CDph SCC F-CUP 1.00 1.01 99110-CR100 ',
],
(Ecu.eps, 0x7d4, None): [
b'\xf1\x00CDe MDPS C 1.00 1.01 56310-XX000 4CDHC101',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00CD2 LKAS AT EUR LHD 1.00 1.01 99211-CR010 621',
],
},
CAR.KIA_CEED_PHEV: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00CDph SCC F-CUP 1.00 1.01 99110-CR100 ',
@@ -958,6 +1203,21 @@ FW_VERSIONS = {
b'\xf1\x00CN ESC \t 105 \x10\x03 58910-AA800',
],
},
CAR.HYUNDAI_ELANTRA_2024: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00CN7_ RDR ----- 1.00 1.01 99110-AA500 ',
],
(Ecu.eps, 0x7d4, None): [
b'\xf1\x00CN7 MDPS C 1.00 1.02 56300AA670\x00 4CSDC102',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00CN7 MFC AT USA LHD 1.00 1.02 99210-AA500 230420',
b'\xf1\x00CN7 MFC AT USA LHD 1.00 1.03 99210-AA500 230918',
],
(Ecu.abs, 0x7d1, None): [
b'\xf1\x00CN ESC \t 104#\x07\x03 58910-AA850',
],
},
CAR.HYUNDAI_ELANTRA_HEV_2021: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00CN7HMFC AT USA LHD 1.00 1.03 99210-AA000 200819',
@@ -977,6 +1237,22 @@ FW_VERSIONS = {
b'\xf1\x00CN7 MDPS C 1.00 1.04 56310BY050\x00 4CNHC104',
],
},
CAR.HYUNDAI_ELANTRA_HEV_2024: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00CN7HMFC AT AUS RHD 1.00 1.02 99210-AA500 230420',
b'\xf1\x00CN7HMFC AT CAN LHD 1.00 1.05 99210-AA510 240509',
b'\xf1\x00CN7HMFC AT USA LHD 1.00 1.03 99210-AA500 230918',
b'\xf1\x00CN7HMFC AT USA LHD 1.00 1.05 99210-AA510 240509',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00CN7_ RDR ----- 1.00 1.01 99110-AA500 ',
],
(Ecu.eps, 0x7d4, None): [
b'\xf1\x00CN7 MDPS C 1.00 1.00 56300BY670\x00 4CSHC100',
b'\xf1\x00CN7 MDPS C 1.00 1.00 56300BY680\x00 4CSHC100',
b'\xf1\x00CN7 MDPS C 1.00 1.03 56300BY670\x00 4CSHC103',
],
},
CAR.HYUNDAI_KONA_HEV: {
(Ecu.abs, 0x7d1, None): [
b'\xf1\x00OS IEB \x01 104 \x11 58520-CM000',
@@ -1083,6 +1359,18 @@ FW_VERSIONS = {
b'\xf1\x00NE1 MFC AT USA LHD 1.00 1.06 99211-GI010 230110',
],
},
CAR.HYUNDAI_IONIQ_5_PE: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00NE__ RDR ----- 1.00 1.00 99110-PI000 ',
b'\xf1\x00NE__ RDR ----- 1.00 1.01 99110-GI500 ',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00NE MFC AT USA LHD 1.00 1.01 99211-PI000 240905',
b'\xf1\x00NE MFC AT EUR LHD 1.00 1.03 99211-GI500 240809',
b'\xf1\x00NE MFC AT USA LHD 1.00 1.00 99211-PI010 250407',
b'\xf1\x00NE MFC AT EUR LHD 1.00 1.00 99211-GI510 250513',
],
},
CAR.HYUNDAI_IONIQ_6: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00CE__ RDR ----- 1.00 1.01 99110-KL000 ',
@@ -1238,6 +1526,32 @@ FW_VERSIONS = {
b'\xf1\x00JX1_ SCC FHCUP 1.00 1.01 99110-T6100 ',
],
},
CAR.KIA_EV6_2025: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00CV__ RDR ----- 1.00 1.00 99110-XG500 ',
b'\xf1\x00CV__ RDR ----- 1.00 1.01 99110-CV500 ',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00CV MFC AT KOR LHD 1.00 1.01 99210-CV500 240405',
b'\xf1\x00CV MFC AT USA LHD 1.00 1.02 99210-XG500 241223',
],
},
CAR.KIA_EV9: {
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00MV__ RDR ----- 1.00 1.02 99110-DO000 ',
b'\xf1\x00MV__ RDR ----- 1.00 1.03 99110-DO000 ',
b'\xf1\x00MV__ RDR ----- 1.00 1.04 99110-DO000 ',
b'\xf1\x00MV__ RDR ----- 1.00 1.02 99110-DO700 ',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00MV MFC AT KOR LHD 1.00 1.01 99211-DO000 230419',
b'\xf1\x00MV MFC AT USA LHD 1.00 1.02 99211-DO000 230616',
b'\xf1\x00MV MFC AT EUR LHD 1.00 1.02 99211-DO000 230616',
b'\xf1\x00MV MFC AT CAN LHD 1.00 1.00 99211-DO100 240403',
b'\xf1\x00MV MFC AT USA LHD 1.00 1.01 99211-XA000 241023',
b'\xf1\x00MV MFC AT CAN LHD 1.00 1.01 99211-DO100 241023',
],
},
CAR.KIA_CARNIVAL_4TH_GEN: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00KA4 MFC AT EUR LHD 1.00 1.06 99210-R0000 220221',
@@ -1316,4 +1630,114 @@ FW_VERSIONS = {
b'\xf1\x00T01G00BL T01I00A1 DOS2T16X4XI00NS0\x99L\xeeq',
],
},
CAR.GENESIS_G70_2021_NON_SCC: {
(Ecu.eps, 0x7d4, None): [
b'\xf1\x00IK MDPS R 1.00 1.08 57700-G9200 4I2CL108',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00IK__ SCC --CUP 1.00 1.02 96400-G9100 ',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00IK MFC MT USA LHD 1.00 1.01 95740-G9000 170920',
],
},
CAR.HYUNDAI_BAYON_1ST_GEN_NON_SCC: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00BC3 LKA AT EUR LHD 1.00 1.01 99211-Q0100 261',
],
},
CAR.HYUNDAI_ELANTRA_2022_NON_SCC: {
(Ecu.eps, 0x7d4, None): [
b'\xf1\x00CN7 MDPS R 1.00 1.04 57700-IB000 4CNNP104',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00CN7 MFC AT USA LHD 1.00 1.01 99210-AB000 210205',
b'\xf1\x00CN7 MFC AT USA LHD 1.00 1.00 99210-IB000 210531',
],
(Ecu.abs, 0x7d1, None): [
b'\xf1\x00CN ESC \t 100!\x05\x01 58910-IB000',
],
(Ecu.transmission, 0x7e1, None): [
b'\xf1\x00T02601BL T02900A1 WCN7T20XXX900NS4\xf7\xccz\xf6',
],
},
CAR.HYUNDAI_ELANTRA_HEV_2022_NON_SCC: {
(Ecu.eps, 0x7d4, None): [
b'\xf1\x00CN7 MDPS C 1.00 1.02 56310/BY050 4CNHC102',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00CN7HMFC AT USA LHD 1.00 1.04 99210-AA000 210205',
],
(Ecu.transmission, 0x7e1, None): [
b'\xf1\x006U3L0_C2\x00\x006U3K3051\x00\x00HCN0G16NS0\x00\x00\x00\x00',
],
},
CAR.HYUNDAI_KONA_NON_SCC: {
(Ecu.eps, 0x7d4, None): [
b'\xf1\x00OS MDPS C 1.00 1.05 56310J9030\x00 4OSDC105',
b'\xf1\x00OS MDPS C 1.00 1.04 56310J9030\x00 4OSDC104',
b'\xf1\x00OS MDPS C 1.00 1.05 56310/J9500 4OSDC105',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00OS9 LKAS AT USA LHD 1.00 1.00 95740-J9200 g30',
b'\xf1\x00OS9 LKAS AT AUS RHD 1.00 1.00 95740-J9200 g30',
],
(Ecu.fwdRadar, 0x7d0, None): [
b'\xf1\x00OS__ FCA --CUP 1.00 1.00 95655-J9100 ',
],
(Ecu.transmission, 0x7e1, None): [
b'\xf1\x006T6J0_C2\x00\x006T6K1051\x00\x00TOS4N20NS2\x00\x00\x00\x00',
b'\xf1\x006U2V0_C2\x00\x006U2V1051\x00\x00DOS4T16AS2\x00\x00\x00\x00',
],
},
CAR.KIA_CEED_PHEV_2022_NON_SCC: {
(Ecu.eps, 0x7D4, None): [
b'\xf1\x00CD MDPS C 1.00 1.01 56310-XX000 4CPHC101',
],
(Ecu.fwdCamera, 0x7C4, None): [
b'\xf1\x00CDH LKAS AT EUR LHD 1.00 1.01 99211-CR700 931',
],
},
CAR.KIA_FORTE_2019_NON_SCC: {
(Ecu.eps, 0x7D4, None): [
b'\xf1\x00BD MDPS C 1.00 1.04 56310/M6000 4BDDC104',
b'\xf1\x00BD MDPS C 1.00 1.05 56310/M6000 4BDDC105',
],
(Ecu.fwdCamera, 0x7C4, None): [
b'\xf1\x00BD LKAS AT USA LHD 1.00 1.02 95740-M6000 J31',
],
},
CAR.KIA_FORTE_2021_NON_SCC: {
(Ecu.eps, 0x7D4, None): [
b'\xf1\x00BD MDPS C 1.00 1.07 56310/M6300 4BDDC107',
b'\xf1\x00BD MDPS C 1.00 1.08 56310M6000\x00 4BDDC108',
],
(Ecu.fwdCamera, 0x7C4, None): [
b'\xf1\x00BD LKAS AT USA LHD 1.00 1.02 95740-M6000 J31',
b'\xf1\x00BD LKAS AT USA LHD 1.00 1.04 95740-M6000 J33',
],
},
CAR.KIA_SELTOS_2023_NON_SCC: {
(Ecu.abs, 0x7d1, None): [
b'\xf1\x00SP ESC \t 101\"\t\x01 58910-Q5510',
b'\xf1\x00SP ESC \r 100\"\x04\x01 58910-Q5510',
],
(Ecu.eps, 0x7d4, None): [
b'\xf1\x00SP2 MDPS C 1.00 1.04 56310Q5240 4SPSC104',
b'\xf1\x00SP2 MDPS C 1.00 1.01 56300Q5920 ',
],
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00SP2 MFC AT USA LHD 1.00 1.03 99210-Q5500 230208',
b'\xf1\x00SP2 MFC AT AUS RHD 1.00 1.02 99210-Q5500 220624',
],
(Ecu.transmission, 0x7e1, None): [
b'\xf1\x006V2B0_C2\x00\x006V2D5051\x00\x00CSP2N20NL0\x00\x00\x00\x00',
b'\xf1\x006V2B0_C2\x00\x006V2D4051\x00\x00CSP2N20KL1\x00\x00\x00\x00',
],
},
CAR.KIA_RAY_EV: {
(Ecu.fwdCamera, 0x7c4, None): [
b'\xf1\x00TAM MFC AT KOR LHD 1.00 1.02 99211-E2000 230901',
],
},
}
@@ -0,0 +1,26 @@
"""Exact Forte non-SCC AOL identity and physical-button observations."""
from opendbc.car.structs import CarParams
from opendbc.car.hyundai.values import CAR, HyundaiFlags
FORTE_IDS = (CAR.KIA_FORTE_2019_NON_SCC, CAR.KIA_FORTE_2021_NON_SCC)
FORTE_AOL_MARKER = 0x0400
FORTE_AOL_EXPERIENCE = 32
FORTE_AOL_WORDS = frozenset((0x1400, 0x1c00))
SOURCE_MAX_AGE_NS = 300_000_000
def qualified(cp):
physical = bool(cp.flags & HyundaiFlags.HAS_LDA_BUTTON)
words = (0x1000, 0x1800, 0x1c00) if physical else (0x1000, 0x1400)
allowed_flags = (HyundaiFlags.NON_SCC | HyundaiFlags.NON_SCC_NO_FCA | HyundaiFlags.NON_SCC_RADAR_FCA |
HyundaiFlags.HAS_LDA_BUTTON | HyundaiFlags.USE_FCA | HyundaiFlags.SEND_LFA)
return (cp.carFingerprint in FORTE_IDS and cp.brand == 'hyundai' and cp.alternativeExperience in (0, FORTE_AOL_EXPERIENCE) and
not cp.passive and not cp.dashcamOnly and
not cp.notCar and not cp.openpilotLongitudinalControl and cp.pcmCruise and
bool(cp.flags & HyundaiFlags.NON_SCC) and not int(cp.flags) & ~int(allowed_flags) and
len(cp.safetyConfigs) == 1 and cp.safetyConfigs[0].safetyModel == CarParams.SafetyModel.hyundai and
cp.safetyConfigs[0].safetyParam in words)
# Keep the public Forte fixture/provider API while sharing the same state machine.
from opendbc.car.hyundai.non_scc_aol import NonSccLkasSources as ForteLkasSources
@@ -0,0 +1,106 @@
"""Original G90 CAN lead hysteresis, fed by a scoped fresh radar provider."""
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class CanLeadData:
object_gap: int = 0
lead_distance: float = 0.0
lead_rel_speed: float = 0.0
lead_visible: bool = False
@property
def object_rel_gap(self) -> int:
return 0 if self.lead_distance == 0 else 2 if self.lead_rel_speed < -0.2 else 1
def _hysteresis_update(current, new_value, counter, threshold):
if new_value == current:
return current, 0
counter += 1
return (new_value, 0) if counter >= threshold else (current, counter)
class CanLeadDataState:
LEAD_HYSTERESIS_FRAMES = 50
def __init__(self):
self._lead_on_counter = 0
self._lead_off_counter = 0
self._gap_counter = 0
self._lead_visible = False
self._object_gap = 0
@staticmethod
def _get_object_gap(lead_distance: float) -> int:
if lead_distance == 0:
return 0
if lead_distance < 20:
return 2
if lead_distance < 25:
return 3
if lead_distance < 30:
return 4
return 5
def update(self, lead_distance: float, lead_rel_speed: float, lead_visible: bool) -> CanLeadData:
counter = self._lead_on_counter if lead_visible else self._lead_off_counter
self._lead_visible, counter = _hysteresis_update(
self._lead_visible, lead_visible, counter, self.LEAD_HYSTERESIS_FRAMES,
)
if lead_visible:
self._lead_on_counter = counter
self._lead_off_counter = 0
else:
self._lead_off_counter = counter
self._lead_on_counter = 0
object_gap = self._get_object_gap(lead_distance)
self._object_gap, self._gap_counter = _hysteresis_update(
self._object_gap, object_gap, self._gap_counter, self.LEAD_HYSTERESIS_FRAMES,
)
return CanLeadData(self._object_gap, lead_distance, lead_rel_speed, self._lead_visible)
@dataclass(frozen=True)
class LeadObservation:
drive_id: int
producer_ns: int
receipt_ns: int
observed_ns: int
present: bool
distance_m: float
relative_speed_mps: float
def eligible(cp):
from opendbc.car.hyundai.values import CAR
return (cp.carFingerprint == CAR.GENESIS_G90 and cp.openpilotLongitudinalControl and
not cp.passive and not cp.dashcamOnly and not cp.notCar)
class G90LeadState:
def __init__(self):
self.state = CanLeadDataState()
self.drive_id = None
def update(self, observation, hud_visible):
if observation is None:
self.state = CanLeadDataState()
self.drive_id = None
return CanLeadData()
if self.drive_id != observation.drive_id:
self.state = CanLeadDataState()
self.drive_id = observation.drive_id
visible = observation.present or bool(hud_visible)
distance = min(max(observation.distance_m, 0.), 204.7)
relative = min(max(observation.relative_speed_mps, -170.), 239.5)
if not math.isfinite(distance) or not math.isfinite(relative):
self.state = CanLeadDataState()
return CanLeadData()
if visible and distance <= .1:
distance, relative = 20., 0.
return self.state.update(distance, relative, visible)
@@ -0,0 +1,67 @@
import numpy as np
from opendbc.car.structs import CarControl
from opendbc.car.hyundai.values import CAR
LongCtrlState = CarControl.Actuators.LongControlState
HOLD_SPEED = (0.0, 0.03, 0.08, 0.16, 0.3, 0.5, 0.8, 1.2, 2.0, 3.0)
HOLD_ACCEL = (-0.10, -0.10, -0.12, -0.18, -0.30, -0.50, -0.75, -1.00, -1.40, -1.80)
RELAX_SPEED = (0.0, 0.08, 0.16, 0.3, 0.5, 0.8, 1.2, 2.0, 3.0)
RELAX_STEP = (0.10, 0.10, 0.08, 0.06, 0.04, 0.035, 0.03, 0.022, 0.018)
RELEASE_SPEED = (0.0, 0.3, 0.6)
RELEASE_ACCEL_STEP = (0.05, 0.07, 0.11)
RELEASE_DECEL_STEP = (0.16, 0.18, 0.18)
RELEASE_MAX_SPEED = 0.8
def stopping_decel_rate(cp):
return 0.55 if cp.carFingerprint == CAR.GENESIS_G90 and cp.openpilotLongitudinalControl else None
def forecast_should_stop(cp, speeds, time_indices, action_t):
"""G90's original two-horizon stopping decision; no acceleration shaping."""
if cp.carFingerprint != CAR.GENESIS_G90 or not cp.openpilotLongitudinalControl:
return None
if len(speeds) == len(time_indices):
target = np.interp(action_t, time_indices, speeds)
target_one_second = np.interp(action_t + 1.0, time_indices, speeds)
else:
target = target_one_second = 0.0
# The original CarParams field was Float32 before its planner toggle copy.
threshold = float(np.float32(0.8))
return bool(target < threshold and target_one_second < threshold)
class G90LongitudinalPolicy:
"""G90 stop hold and launch shaping at the controller's 100 Hz cadence."""
def __init__(self):
self.actual_accel = 0.0
self.release_active = False
self.last_state = LongCtrlState.off
def update(self, accel, speed, state, active):
if not active:
self.actual_accel = 0.0
self.release_active = False
elif state == LongCtrlState.stopping and speed <= HOLD_SPEED[-1]:
self.release_active = False
target = min(0.0, max(accel, float(np.interp(speed, HOLD_SPEED, HOLD_ACCEL))))
if self.actual_accel < target:
self.actual_accel = min(self.actual_accel + float(np.interp(speed, RELAX_SPEED, RELAX_STEP)), target)
else:
self.actual_accel = target
else:
if self.last_state == LongCtrlState.stopping and state == LongCtrlState.pid and accel > 0.0 and speed < RELEASE_MAX_SPEED:
self.release_active = True
if self.release_active:
accel_step = float(np.interp(speed, RELEASE_SPEED, RELEASE_ACCEL_STEP))
decel_step = float(np.interp(speed, RELEASE_SPEED, RELEASE_DECEL_STEP))
self.actual_accel = float(np.clip(accel, self.actual_accel - decel_step, self.actual_accel + accel_step))
if speed >= RELEASE_MAX_SPEED or accel <= 0.0 or self.actual_accel >= accel - 1e-3:
self.release_active = False
else:
self.actual_accel = accel
self.last_state = state
return self.actual_accel
@@ -0,0 +1,78 @@
"""Exact G90 prepublication failure fallback; sent disable is not confirmed ownership."""
from copy import deepcopy
import time
from opendbc.car import Bus
from opendbc.car.disable_ecu import disable_ecu
from opendbc.car.hyundai.values import CAR, HyundaiFlags
from opendbc.car.isotp_parallel_query import IsoTpParallelQuery
from opendbc.can.parser import MAX_BAD_COUNTER
from opendbc.car.hyundai.ecu_startup import HyundaiECUStartup, Outcome, stock_copy as stock_copy
def required(cp):
return (cp.carFingerprint == CAR.GENESIS_G90 and cp.openpilotLongitudinalControl and
not cp.passive and not cp.dashcamOnly and
not cp.flags & (HyundaiFlags.CANFD | HyundaiFlags.CAMERA_SCC | HyundaiFlags.CANFD_CAMERA_SCC))
class G90Startup(HyundaiECUStartup):
def __init__(self, cp, callbacks):
super().__init__(cp, callbacks, address=0x7d0, bus=0, label='G90')
def _disable(self, *args, **kwargs):
return disable_ecu(*args, **kwargs)
def _query(self, *args, **kwargs):
return IsoTpParallelQuery(*args, **kwargs)
def finalize_aol_configuration(self, ci):
if self.published or self.closed or ci is not self.ci or not self.ready:
raise RuntimeError('G90 AOL finalization requires configured startup')
if self.prepared_for(ci.CP):
return
from opendbc.car.hyundai.classic_long_aol import qualified as long_qualified, ordinary_word, aol_word as long_word
from opendbc.car.hyundai.classic_scc_aol import qualified as stock_qualified, stock_word, aol_word as stock_aol_word
if self.outcome is Outcome.SENT_UNCONFIRMED and long_qualified(ci.CP, marked_only=True):
before_word, after_word = ordinary_word(ci.CP), long_word(ci.CP)
elif self.outcome in (Outcome.STOCK_UNTOUCHED, Outcome.STOCK_RESTORED) and stock_qualified(ci.CP, marked_only=True):
before_word, after_word = stock_word(ci.CP), stock_aol_word(ci.CP)
else:
raise RuntimeError('G90 startup does not admit this AOL configuration')
if ci.CP.carFingerprint != CAR.GENESIS_G90 or ci.CP.alternativeExperience != 32:
raise RuntimeError('G90 AOL configuration identity does not match')
expected = deepcopy(self.prepared_cp)
configs = expected.get('safetyConfigs', [])
if len(configs) != 1 or configs[0]['safetyModel'] != 'hyundai' or configs[0]['safetyParam'] != before_word or expected['alternativeExperience'] != 0:
raise RuntimeError('G90 prepared safety profile cannot be finalized')
configs[0]['safetyParam'] = after_word
expected['alternativeExperience'] = 32
if ci.CP.to_dict() != expected:
raise RuntimeError('G90 AOL finalization changed unrelated CarParams')
self.prepared_cp = expected
def _warm_stock(self, ci):
deadline = time.monotonic() + 3.
while time.monotonic() < deadline:
packets = self.callbacks[0]()
stamped = [(int(getattr(packet, 'log_mono_time_ns', 0)), list(packet)) for packet in packets]
if any(stamp <= 0 for stamp, _ in stamped):
raise RuntimeError(f'{self.label} stock warmup requires actual timestamped CAN')
state = ci.update(stamped)
now = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
parser = ci.can_parsers[Bus.pt]
ready = state.canValid and not state.canTimeout and parser.bus == 0
for name in ('SCC11', 'SCC12'):
source = parser.message_states.get(parser.dbc.name_to_msg[name].address)
if source is None or not source.timestamps or source.counter_fail >= MAX_BAD_COUNTER:
ready = False
continue
stamp = int(source.timestamps[-1])
if not self.floor_ns < stamp <= now or now - stamp > source.timeout_threshold:
ready = False
if ready:
self.ready = True
return
time.sleep(.005)
self.outcome = Outcome.ABORT_UNCERTAIN
raise RuntimeError(f'{self.label} stock SCC sources did not become fresh before publication')
@@ -0,0 +1,18 @@
"""Exact GV70 owner for the optional camera lead decoder."""
from opendbc.car.hyundai.canfd_camera_lead import CANFDCameraLead
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import CAR, HyundaiFlags
def eligible(cp):
return (cp.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN and
cp.flags & HyundaiFlags.CANFD and cp.openpilotLongitudinalControl and
not cp.passive and not cp.dashcamOnly and not cp.notCar)
class GV70CameraLead(CANFDCameraLead):
def __init__(self, cp):
if not eligible(cp):
raise ValueError('Camera lead owner requires the exact active GV70 profile')
buses = CanBus(cp)
super().__init__(cp, buses.ECAN if cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG else buses.CAM)
@@ -0,0 +1,72 @@
"""Electrified GV70's absolute planner cost projection.
Input context belongs to the current-drive planner owner. This module does not
change the solver recipe, actuator limits, or other vehicles' default costs.
"""
from dataclasses import dataclass
import math
from opendbc.car.hyundai.values import CAR
@dataclass(frozen=True)
class CostContext:
speed_mps: float
lead_distance_m: float
uncertainty: float
mode: str
acceleration_factor: float
speed_factor: float
danger_factor: float
stop_approach: bool
tracked_lead: bool
previous_accel_constraint: bool
@dataclass(frozen=True)
class CostWeights:
stage: tuple[float, ...]
constraints: tuple[float, ...]
def eligible(cp):
return (cp.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN and
cp.openpilotLongitudinalControl and not cp.passive and
not cp.dashcamOnly and not cp.notCar)
def _interpolate(speed_mph, values):
points = (0., 35., 55., 70.)
if speed_mph <= points[0]:
return values[0]
for i in range(1, len(points)):
if speed_mph <= points[i]:
fraction = (speed_mph - points[i - 1]) / (points[i] - points[i - 1])
return values[i - 1] + fraction * (values[i] - values[i - 1])
return values[-1]
def project(context):
numeric = (context.speed_mps, context.lead_distance_m, context.uncertainty,
context.acceleration_factor, context.speed_factor, context.danger_factor)
if not all(type(v) in (int, float) and math.isfinite(v) for v in numeric):
raise ValueError('invalid GV70 cost context')
if context.speed_mps < 0 or min(context.acceleration_factor, context.speed_factor, context.danger_factor) <= 0:
raise ValueError('invalid GV70 cost context')
mph = context.speed_mps * 3.6 / 1.609344
distance_factor = 1. + _interpolate(mph, (0., .06, .06, .05)) * (20. / max(context.lead_distance_m, 5.))
# Original publisher gives stop/approach priority over tracked following.
scale = .20 if context.stop_approach else 1.75 if context.tracked_lead else 1.
acceleration = 250. * context.acceleration_factor * scale * distance_factor
speed = 5.5 * context.speed_factor * distance_factor
danger = 100. * context.danger_factor * distance_factor
if .45 <= context.uncertainty < .60:
speed *= 1.2 + (context.uncertainty - .45) / .15 * .3
if context.mode == 'acc':
stage = (_interpolate(mph, (3., 3., 2.5, 2.)), 0., 0., 0.,
acceleration if context.previous_accel_constraint else 0., speed)
elif context.mode == 'blended':
stage = (0., .1, .2, 5., 40. if context.previous_accel_constraint else 0., 1.)
else:
raise ValueError('unsupported GV70 planner mode')
return CostWeights(stage, (1e6, 1e6, 1e6, danger))
@@ -0,0 +1,41 @@
"""First-generation Electrified GV70 SCC demand and stock-cancel policy."""
from dataclasses import dataclass
import numpy as np
from opendbc.car.hyundai.values import CAR
JERK_UPPER = 1.5
JERK_LOWER = 2.0
URGENT_JERK_LOWER = 5.0
URGENT_ACCEL = -1.0
SCC_FREQUENCY = 50.0
def is_gv70(cp):
return cp.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN
@dataclass(frozen=True)
class SCCRequest:
raw_accel: float
accel: float
jerk_upper: float
jerk_lower: float
def scc_request(enabled, gas_override, stopping, accel, accel_last):
lower = URGENT_JERK_LOWER if stopping or accel <= URGENT_ACCEL else JERK_LOWER
shaped = float(np.clip(accel, accel_last - lower / SCC_FREQUENCY, accel_last + JERK_UPPER / SCC_FREQUENCY)) \
if enabled and not gas_override else 0.0
return SCCRequest(accel, shaped, JERK_UPPER, lower)
def suppress_stock_cancel(cp, brake_pressed, lat_active, *, stock_fallback=False):
return bool(is_gv70(cp) and not cp.openpilotLongitudinalControl and
(stock_fallback or (brake_pressed and lat_active)))
def tracked_lead_scale(cp):
eligible = (cp.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN and cp.openpilotLongitudinalControl and
not cp.passive and not cp.dashcamOnly and not cp.notCar)
return 1.75 if eligible else 1.0
@@ -0,0 +1,24 @@
"""Exact first-generation GV70 Electrified captured-ADRV startup."""
from opendbc.car.hyundai.captured_adrv_startup import CapturedADRVStartup, first_generation
from opendbc.car.hyundai.gv70_template import GV70Template
from opendbc.car.hyundai.ecu_startup import Outcome
from opendbc.car.hyundai.values import CAR
def eligible(cp):
return cp.carFingerprint == CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN and first_generation(cp)
def required(cp):
return eligible(cp) and cp.openpilotLongitudinalControl
class GV70Startup(CapturedADRVStartup):
def __init__(self, cp, callbacks):
super().__init__(cp, callbacks, label='GV70', template_type=GV70Template)
def configure(self, ci):
super().configure(ci)
# This owner exists only for a requested exact-model takeover. The original
# failed-disable controller avoided fighting stock cruise with Cancel.
ci.CC.gv70_stock_fallback = self.outcome is not Outcome.SENT_UNCONFIRMED
@@ -0,0 +1,16 @@
"""Exact first-generation Electrified GV70 captured ADRV speed recipe."""
from dataclasses import dataclass
import math
from opendbc.car.can_definitions import CanData
from opendbc.car.hyundai.captured_adrv import CapturedADRVTemplate
@dataclass(frozen=True)
class GV70Template(CapturedADRVTemplate):
def frame(self, frame: int, drive_gear: bool, bus: int = 0, *, speed: float | None = None) -> CanData:
data = self._frame_data(frame, drive_gear, bus)
if speed is not None and math.isfinite(speed):
raw = min(max(round(speed * 100.0), 0), 65534)
data[8:10] = raw.to_bytes(2, 'little')
return self._finish(data, bus)
+84 -8
View File
@@ -1,5 +1,7 @@
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.g90_lead import eligible as g90_lead_eligible
from opendbc.car.crc import CRC8J1850, mk_crc8_fun
from opendbc.car.hyundai.values import CAR, HyundaiFlags
from opendbc.car.hyundai.values import is_blended, CAR, HyundaiFlags
hyundai_checksum = mk_crc8_fun(CRC8J1850, init_crc=0xFD, xor_out=0xDF)
@@ -7,7 +9,7 @@ hyundai_checksum = mk_crc8_fun(CRC8J1850, init_crc=0xFD, xor_out=0xDF)
def create_lkas11(packer, frame, CP, apply_torque, steer_req,
torque_fault, lkas11, sys_warning, sys_state, enabled,
left_lane, right_lane,
left_lane_depart, right_lane_depart):
left_lane_depart, right_lane_depart, ray_lka_icon=None):
values = {s: lkas11[s] for s in [
"CF_Lkas_LdwsActivemode",
"CF_Lkas_LdwsSysState",
@@ -38,8 +40,9 @@ def create_lkas11(packer, frame, CP, apply_torque, steer_req,
CAR.HYUNDAI_IONIQ_EV_2020, CAR.HYUNDAI_IONIQ_PHEV, CAR.KIA_SELTOS, CAR.HYUNDAI_ELANTRA_2021, CAR.GENESIS_G70_2020,
CAR.HYUNDAI_ELANTRA_HEV_2021, CAR.HYUNDAI_SONATA_HYBRID, CAR.HYUNDAI_KONA_EV, CAR.HYUNDAI_KONA_HEV, CAR.HYUNDAI_KONA_EV_2022,
CAR.HYUNDAI_SANTA_FE_2022, CAR.KIA_K5_2021, CAR.HYUNDAI_IONIQ_HEV_2022, CAR.HYUNDAI_SANTA_FE_HEV_2022,
CAR.HYUNDAI_SANTA_FE_PHEV_2022, CAR.KIA_STINGER_2022, CAR.KIA_K5_HEV_2020, CAR.KIA_CEED, CAR.KIA_CEED_PHEV,
CAR.HYUNDAI_AZERA_6TH_GEN, CAR.HYUNDAI_AZERA_HEV_6TH_GEN, CAR.HYUNDAI_CUSTIN_1ST_GEN, CAR.HYUNDAI_KONA_2022):
CAR.HYUNDAI_SANTA_FE_PHEV_2022, CAR.KIA_STINGER_2022, CAR.KIA_K5_HEV_2020, CAR.KIA_CEED, CAR.KIA_XCEED_PHEV, CAR.KIA_CEED_PHEV,
CAR.HYUNDAI_AZERA_6TH_GEN, CAR.HYUNDAI_AZERA_HEV_6TH_GEN, CAR.HYUNDAI_CUSTIN_1ST_GEN, CAR.HYUNDAI_KONA_2022,
CAR.HYUNDAI_ELANTRA_2024, CAR.HYUNDAI_ELANTRA_HEV_2024, CAR.KIA_RAY_EV):
values["CF_Lkas_LdwsActivemode"] = int(left_lane) + (int(right_lane) << 1)
values["CF_Lkas_LdwsOpt_USM"] = 2
@@ -50,6 +53,8 @@ def create_lkas11(packer, frame, CP, apply_torque, steer_req,
# FcwOpt_USM 1 = White car + lanes
# FcwOpt_USM 0 = No car + lanes
values["CF_Lkas_FcwOpt_USM"] = 2 if enabled else 1
if CP.carFingerprint == CAR.KIA_RAY_EV and ray_lka_icon is not None:
values["CF_Lkas_FcwOpt_USM"] = ray_lka_icon
# SysWarning 4 = keep hands on wheel
# SysWarning 5 = keep hands on wheel (red)
@@ -58,7 +63,7 @@ def create_lkas11(packer, frame, CP, apply_torque, steer_req,
values["CF_Lkas_SysWarning"] = 4 if sys_warning else 0
# Likely cars lacking the ability to show individual lane lines in the dash
elif CP.carFingerprint in (CAR.KIA_OPTIMA_G4, CAR.KIA_OPTIMA_G4_FL):
elif CP.carFingerprint in (CAR.KIA_OPTIMA_G4, CAR.KIA_OPTIMA_G4_FL, CAR.HYUNDAI_KONA_NON_SCC):
# SysWarning 4 = keep hands on wheel + beep
values["CF_Lkas_SysWarning"] = 4 if sys_warning else 0
@@ -66,7 +71,7 @@ def create_lkas11(packer, frame, CP, apply_torque, steer_req,
# SysState 1-2 = white car + lanes
# SysState 3 = green car + lanes, green steering wheel
# SysState 4 = green car + lanes
values["CF_Lkas_LdwsSysState"] = 3 if enabled else 1
values["CF_Lkas_LdwsSysState"] = sys_state if CP.carFingerprint == CAR.HYUNDAI_KONA_NON_SCC else 3 if enabled else 1
values["CF_Lkas_LdwsOpt_USM"] = 2 # non-2 changes above SysState definition
# these have no effect
@@ -114,7 +119,7 @@ def create_clu11(packer, frame, clu11, button, CP):
values["CF_Clu_CruiseSwState"] = button
values["CF_Clu_AliveCnt1"] = frame % 0x10
# send buttons to camera on camera-scc based cars
bus = 2 if CP.flags & HyundaiFlags.CAMERA_SCC else 0
bus = CanBus(CP).ECAN if is_blended(CP) else 2 if CP.flags & HyundaiFlags.CAMERA_SCC else 0
return packer.make_can_msg("CLU11", bus, values)
@@ -125,8 +130,10 @@ def create_lfahda_mfc(packer, enabled):
return packer.make_can_msg("LFAHDA_MFC", 0, values)
def create_acc_commands(packer, enabled, accel, upper_jerk, idx, hud_control, set_speed, stopping, long_override, use_fca, CP):
def create_acc_commands(packer, enabled, accel, upper_jerk, idx, hud_control, set_speed, stopping, long_override, use_fca, CP, *, lead_data=None):
commands = []
if lead_data is not None and not g90_lead_eligible(CP):
lead_data = None
scc11_values = {
"MainMode_ACC": 1,
@@ -139,6 +146,9 @@ def create_acc_commands(packer, enabled, accel, upper_jerk, idx, hud_control, se
"ACC_ObjRelSpd": 0,
"ACC_ObjDist": 1, # close lead makes controls tighter
}
if lead_data is not None:
scc11_values.update(ObjValid=int(lead_data.lead_visible), ACC_ObjStatus=int(lead_data.lead_visible),
ACC_ObjRelSpd=lead_data.lead_rel_speed, ACC_ObjDist=int(lead_data.lead_distance))
commands.append(packer.make_can_msg("SCC11", 0, scc11_values))
scc12_values = {
@@ -168,6 +178,8 @@ def create_acc_commands(packer, enabled, accel, upper_jerk, idx, hud_control, se
"ACCMode": 2 if enabled and long_override else 1 if enabled else 4, # stock will always be 4 instead of 0 after first disengage
"ObjGap": 2 if hud_control.leadVisible else 0, # 5: >30, m, 4: 25-30 m, 3: 20-25 m, 2: < 20 m, 0: no lead
}
if lead_data is not None:
scc14_values.update(ObjGap=lead_data.object_gap, ObjDistStat=lead_data.object_rel_gap)
commands.append(packer.make_can_msg("SCC14", 0, scc14_values))
# Only send FCA11 on cars where it exists on the bus
@@ -215,3 +227,67 @@ def create_frt_radar_opt(packer):
"CF_FCA_Equip_Front_Radar": 1,
}
return packer.make_can_msg("FRT_RADAR11", 0, frt_radar11_values)
def create_ray_lfahda_mfc(packer, lat_active, lfa_icon):
values = {
"HDA_USM": 2,
"HDA_Icon_State": 2 if lfa_icon else 0,
"HDA_VSetReq": 0,
"HDA_Icon_Wheel": int(lat_active),
"LFA_Icon_State": lfa_icon,
}
return packer.make_can_msg("LFAHDA_MFC", 0, values)
def create_checksum_can_canfd_blended(packer, bus, addr, values):
dat = packer.make_can_msg(addr, bus, values)[1]
return hyundai_checksum(dat[1:8])
def create_lkas11_can_canfd_blended(packer, frame, CP, apply_steer, steer_req,
torque_fault, lkas11, sys_warning, sys_state, enabled,
left_lane, right_lane,
left_lane_depart, right_lane_depart, msg_364,
include_alerts=True, counter_mod=0x10, fcw_opt_usm=None):
bus = CanBus(CP).ECAN
values = {
"CF_Lkas_LdwsActivemode": int(left_lane) + (int(right_lane) << 1),
"CF_Lkas_LdwsLHWarning": left_lane_depart,
"CF_Lkas_LdwsRHWarning": right_lane_depart,
"CF_Lkas_FcwOpt_USM": (2 if enabled else 1) if fcw_opt_usm is None else fcw_opt_usm,
"CR_Lkas_StrToqReq": apply_steer,
"CF_Lkas_ActToi": steer_req,
"CF_Lkas_ToiFlt": torque_fault,
"CF_Lkas_MsgCount": frame % counter_mod,
"NEW_SIGNAL_1": 0,
"NEW_SIGNAL_5": 100,
}
values["CF_Lkas_Chksum"] = create_checksum_can_canfd_blended(packer, bus, "LKAS11", values)
alerts_364 = {k: v for k, v in msg_364.items() if k not in ("CHECKSUM", "COUNTER")} if msg_364 else {}
alerts_364.setdefault("BYTE2", 0)
alerts_364.setdefault("BYTE3", 0)
alerts_364.setdefault("DAW_Status", 0)
alerts_364["DAW_Warning"] = 0
alerts_364.setdefault("BYTE5", 0)
alerts_364.setdefault("BYTE6", 0)
alerts_364.setdefault("BYTE7", 0)
alerts_364["COUNTER"] = frame % counter_mod
alerts_364["CHECKSUM"] = create_checksum_can_canfd_blended(packer, bus, "ALERTS_364", alerts_364)
ret = [packer.make_can_msg("LKAS11", bus, values)]
if include_alerts:
ret.append(packer.make_can_msg("ALERTS_364", bus, alerts_364))
return ret
def create_blended_steering(packer, CAN, torque, requested, icon):
values = {"LKA_MODE": 2, "LKA_ICON": icon, "TORQUE_REQUEST": torque,
"LKA_ASSIST": 0, "STEER_REQ": int(requested), "STEER_MODE": 0, "LKA_AVAILABLE": 0}
return packer.make_can_msg("LKAS", CAN.ACAN, values)
def create_blended_lfahda(packer, CAN, frame, icon):
values = {"LFA_Icon_State": icon, "COUNTER": frame % 16}
values['CHECKSUM'] = create_checksum_can_canfd_blended(packer, CAN.ECAN, 'LFAHDA_MFC', values)
return packer.make_can_msg('LFAHDA_MFC', CAN.ECAN, values)
+221 -22
View File
@@ -1,7 +1,12 @@
import math
from binascii import crc_hqx
from typing import cast
import numpy as np
from opendbc.car import CanBusBase
from opendbc.car.crc import CRC16_XMODEM
from opendbc.car.hyundai.values import HyundaiFlags
from opendbc.car import CanBusBase, CanData
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.hyundai.ioniq6_bsm import BlindspotStatus, FRONT_NEUTRAL_BODY, rear_body
from opendbc.car.hyundai.values import CAR, HyundaiFlags
class CanBus(CanBusBase):
@@ -52,13 +57,72 @@ def create_steering_messages(packer, CP, CAN, enabled, lat_active, apply_torque)
lkas_msg = "LKAS_ALT" if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT else "LKAS"
if CP.openpilotLongitudinalControl:
ret.append(packer.make_can_msg("LFA", CAN.ECAN, values))
ret.append(packer.make_can_msg(lkas_msg, CAN.ACAN, values))
lkas_values = values
if (CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and CP.openpilotLongitudinalControl and
not CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING):
lkas_values = {**values, "Damping_Gain": 0}
ret.append(packer.make_can_msg(lkas_msg, CAN.ACAN, lkas_values))
else:
ret.append(packer.make_can_msg("LFA", CAN.ECAN, values))
return ret
def _create_angle_frame(packer, name, bus, enabled, lat_active, apply_angle, reduction_gain,
stock_values=None, original_ccnc_ev_wire=False):
# Frozen angle protocol stores a signed 0.1-degree request in bits 82..95
# on LKAS/LKAS_ALT/LFA, including the variants whose current DBC names only
# the torque fields. Keep the original status fields where they are observed.
# The original CCNC EV active command replaces stock warning/fault/UI fields.
# Other angle profiles retain their existing status-preservation behavior.
source_status = {} if original_ccnc_ev_wire and lat_active else (stock_values or {})
values = {key: value for key, value in source_status.items() if key not in ("CHECKSUM", "COUNTER")}
values.update({
"LKA_OptUsmSta": 0,
"LKA_SysIndReq": 2 if enabled else 1,
"StrTqReqVal": 0,
"ActToiSta": 0,
"LKA_SysWrn": 0,
"LKA_RcgSta": 3 if lat_active else 0,
"Damping_Gain": 100,
})
address, packed, _ = packer.make_can_msg(name, bus, values)
data = bytearray(packed)
angle = float(np.clip(apply_angle, -360.0, 360.0))
# Match the original DBC packer's signed 0.1-degree quantization for the exact CCNC EV stock identities.
angle_raw = (int(math.floor(angle / 0.1 + 0.5)) if original_ccnc_ev_wire else int(round(angle * 10.0))) & 0x3FFF
data[9] = (data[9] & ~0x30) | ((2 if lat_active else 1) << 4)
data[10] = (data[10] & 0x03) | ((angle_raw & 0x3F) << 2)
data[11] = (angle_raw >> 6) & 0xFF
data[12] = int(np.clip(round(reduction_gain / 0.004), 0, 250)) if lat_active else 0
data[0:2] = hkg_can_fd_checksum(address, None, data).to_bytes(2, "little")
return address, bytes(data), bus
def create_angle_steering_messages(packer, CP, CAN, enabled, lat_active, apply_angle, reduction_gain,
stock_lkas_status=None, measured_angle=0.0):
if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG:
lkas = "LKAS_ALT" if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT else "LKAS"
return [_create_angle_frame(packer, lkas, CAN.ACAN, enabled, lat_active, apply_angle,
reduction_gain, stock_lkas_status, CP.carFingerprint in (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9))]
if CP.flags & HyundaiFlags.SEND_LFA:
# Camera 0xCB topology retains a neutral LFA status frame; angle actuation
# uses the same 24-byte 0xCB payload that current DBC calls LFA_ALT.
status = _create_angle_frame(packer, "LFA", CAN.ECAN, enabled, False, measured_angle, 0.0)
command = packer.make_can_msg("LFA_ALT", CAN.ECAN, {
"ADAS_ActvACISta": 0,
"ADAS_ActvACILvl2Sta": 2 if lat_active else 1,
"ADAS_StrAnglReqVal": apply_angle,
"ADAS_ACIAnglTqRedcGainVal": reduction_gain if lat_active else 0.0,
"FCA_ESA_ActvSta": 0,
"FCA_ESA_TqBstGainVal": 0.0,
})
return [status, command]
return [_create_angle_frame(packer, "LFA", CAN.ECAN, enabled, lat_active, apply_angle, reduction_gain)]
def create_suppress_lfa(packer, CAN, lfa_block_msg, lka_steering_alt):
suppress_msg = "CAM_0x362" if lka_steering_alt else "CAM_0x2a4"
msg_bytes = 32 if lka_steering_alt else 24
@@ -83,6 +147,17 @@ def create_buttons(packer, CP, CAN, cnt, btn):
return packer.make_can_msg("CRUISE_BUTTONS", bus, values)
def create_carnival_alt_resume(packer, CP, CAN, source):
values = {name: value for name, value in source.items() if name not in ("CHECKSUM", "COUNTER")}
values.update({"COUNTER": (int(source["COUNTER"]) + 1) % 256, "SET_ME_1": 1, "CRUISE_BUTTONS": 1})
bus = CAN.ECAN if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG else CAN.CAM
address, data, bus = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, values)
data = bytearray(data)
checksum = hkg_can_fd_checksum(address, None, data)
data[0:2] = checksum.to_bytes(2, "little")
return address, bytes(data), bus
def create_acc_cancel(packer, CP, CAN, cruise_info_copy):
# CAN FD camera-based SCC requires additional signals to be preserved
# verbatim from the previous SCC_CONTROL frame to avoid checksum or
@@ -116,45 +191,164 @@ def create_acc_cancel(packer, CP, CAN, cruise_info_copy):
return packer.make_can_msg("SCC_CONTROL", CAN.ECAN, values)
def create_lfahda_cluster(packer, CAN, enabled):
def create_lfahda_cluster(packer, CAN, enabled, *, lfa_icon=None):
values = {
"HDA_ICON": 1 if enabled else 0,
"LFA_ICON": 2 if enabled else 0,
"LFA_ICON": (2 if enabled else 0) if lfa_icon is None else lfa_icon,
}
return packer.make_can_msg("LFAHDA_CLUSTER", CAN.ECAN, values)
def create_acc_control(packer, CAN, enabled, accel_last, accel, stopping, gas_override, set_speed, hud_control):
def create_acc_control(packer, CAN, enabled, accel_last, accel, stopping, gas_override, set_speed, hud_control,
*, direct_accel=False, main_mode_acc=1, jerk_upper=3.0, jerk_lower=None, raw_accel=None,
lead_distance=None, lead_rel_speed=None, lead_visible=None):
jerk = 5
jn = jerk / 50
if not enabled or gas_override:
a_val, a_raw = 0, 0
else:
a_raw = accel
a_val = np.clip(accel, accel_last - jn, accel_last + jn)
a_raw = accel if not direct_accel or raw_accel is None else raw_accel
a_val = accel if direct_accel else np.clip(accel, accel_last - jn, accel_last + jn)
if lead_distance is None and lead_rel_speed is None and lead_visible is None:
object_distance, object_relative, object_valid, object_status = 1., 0., 0, 2
else:
visible = bool(lead_visible)
object_distance = float(np.clip(cast(float, lead_distance) if visible else 0., 0., 204.7))
object_relative = float(np.clip(cast(float, lead_rel_speed) if visible else 0., -16.4, 34.7))
object_valid = int(not visible)
object_status = 0 if not (enabled and visible) else 1 if gas_override else 2
values = {
"ACCMode": 0 if not enabled else (2 if gas_override else 1),
"MainMode_ACC": 1,
"MainMode_ACC": main_mode_acc,
"StopReq": 1 if stopping else 0,
"aReqValue": a_val,
"aReqRaw": a_raw,
"VSetDis": set_speed,
"JerkLowerLimit": jerk if enabled else 1,
"JerkUpperLimit": 3.0,
"JerkLowerLimit": (jerk if enabled else 1) if jerk_lower is None else jerk_lower,
"JerkUpperLimit": jerk_upper,
"ACC_ObjDist": 1,
"ObjValid": 0,
"OBJ_STATUS": 2,
"ACC_ObjDist": object_distance,
"ObjValid": object_valid,
"OBJ_STATUS": object_status,
"SET_ME_2": 0x4,
"SET_ME_3": 0x3,
"SET_ME_TMP_64": 0x64,
"DISTANCE_SETTING": hud_control.leadDistanceBars,
}
if lead_distance is not None or lead_rel_speed is not None or lead_visible is not None:
values["ACC_ObjRelSpd"] = object_relative
return packer.make_can_msg("SCC_CONTROL", CAN.ECAN, values)
def create_ioniq6_radar_heartbeat(counter: int, brake_pressed: bool, gas_pressed: bool) -> CanData:
"""Recorded Ioniq 6 ADAS replacement heartbeat, 24 bytes on A-CAN.
Preserve the observed non-pedal body; this is not the 32-byte ICE message
sharing address 0x100. Only rolling integrity and actual pedal state vary.
"""
data = bytearray.fromhex("000000020000fcff000000000020000055ff000068000000")
data[2] = counter & 0xff
data[4] = int(brake_pressed)
data[22] = int(gas_pressed)
data[:2] = hkg_can_fd_checksum(0x100, None, data).to_bytes(2, 'little')
return CanData(0x100, bytes(data), 0)
def create_ioniq6_blindspot_status(counter: int, status: BlindspotStatus) -> list[CanData]:
"""Paired HDA-II dashboard status from the frozen Ioniq 6 indicator layout."""
frames = []
for address, body in ((0x1BA, rear_body(status)), (0x1E5, FRONT_NEUTRAL_BODY)):
data = bytearray(body)
data[2] = counter & 0xff
data[:2] = hkg_can_fd_checksum(address, None, data).to_bytes(2, 'little')
frames.append(CanData(address, bytes(data), 1))
return frames
# Source-derived CCNC camera display transform; the repository MIT notice applies.
def create_ccnc(packer, CAN, openpilot_longitudinal, enabled, hud, left_blinker, right_blinker, msg_161, msg_162, msg_1b5,
is_metric, out, main_cruise_enabled, lfa_icon):
if lfa_icon:
lane_change_speed_min = 8.9408
any_blinker = left_blinker or right_blinker
curvature = {i: (31 if i == -1 else 13 - abs(i + 15)) if i < 0 else 15 + i for i in range(-15, 16)}
msg_161.update({
"LKA_ICON": 0,
"LFA_ICON": 2 if lfa_icon else 0,
"CENTERLINE": 1 if lfa_icon else 0,
"LANELINE_CURVATURE": curvature.get(max(-15, min(int(out.steeringAngleDeg / 4.5), 15)), 14) if lfa_icon and not any_blinker else 15,
"LANELINE_LEFT": 0 if not lfa_icon else 1 if not hud.leftLaneVisible else 4 if hud.leftLaneDepart else 6 if any_blinker else 2,
"LANELINE_RIGHT": 0 if not lfa_icon else 1 if not hud.rightLaneVisible else 4 if hud.rightLaneDepart else 6 if any_blinker else 2,
"LCA_LEFT_ICON": 0 if not lfa_icon or out.vEgo < lane_change_speed_min else 1 if out.leftBlindspot else 2 if any_blinker else 4,
"LCA_RIGHT_ICON": 0 if not lfa_icon or out.vEgo < lane_change_speed_min else 1 if out.rightBlindspot else 2 if any_blinker else 4,
"LCA_LEFT_ARROW": 2 if left_blinker else 0,
"LCA_RIGHT_ARROW": 2 if right_blinker else 0,
})
if any_blinker and msg_1b5 is not None:
left_lane_raw = msg_1b5["Info_LftLnPosVal"]
right_lane_raw = msg_1b5["Info_RtLnPosVal"]
scale_per_m = 15 / 1.7
left_lane = abs(int(round(15 + (left_lane_raw - 1.7) * scale_per_m)))
right_lane = abs(int(round(15 + (right_lane_raw - 1.7) * scale_per_m)))
if msg_1b5["Info_LftLnQualSta"] not in (2, 3):
left_lane = 0
if msg_1b5["Info_RtLnQualSta"] not in (2, 3):
right_lane = 0
if left_lane_raw == -2.0248375:
left_lane = 30 - right_lane
if right_lane_raw == 2.0248375:
right_lane = 30 - left_lane
if left_lane_raw == right_lane_raw == 0:
left_lane = right_lane = 15
elif left_lane_raw == 0:
left_lane = 30 - right_lane
elif right_lane_raw == 0:
right_lane = 30 - left_lane
total = left_lane + right_lane
if total == 0:
left_lane = right_lane = 15
else:
left_lane = round((left_lane / total) * 30)
right_lane = 30 - left_lane
msg_161["LANELINE_LEFT_POSITION"] = left_lane
msg_161["LANELINE_RIGHT_POSITION"] = right_lane
if hud.leftLaneDepart or hud.rightLaneDepart:
msg_162["VIBRATE"] = 1
else:
# Stock steering commands are suppressed in this topology. Its active
# lane indicators cannot describe the host's inactive lateral command.
msg_161.update({"LKA_ICON": 0, "LFA_ICON": 0, "CENTERLINE": 0, "LANELINE_LEFT": 0, "LANELINE_RIGHT": 0,
"LCA_LEFT_ICON": 0, "LCA_RIGHT_ICON": 0, "LCA_LEFT_ARROW": 0, "LCA_RIGHT_ARROW": 0})
if openpilot_longitudinal:
cruise_speed = round(out.vCruiseCluster * (1 if is_metric else CV.KPH_TO_MPH))
msg_161.update({
"SETSPEED": 3 if enabled else 1,
"SETSPEED_HUD": 0 if not main_cruise_enabled else 2 if enabled else 1,
"SETSPEED_SPEED": 255 if not main_cruise_enabled else (40 if is_metric else 25) if cruise_speed > (145 if is_metric else 90) else cruise_speed,
"DISTANCE": hud.leadDistanceBars,
"DISTANCE_SPACING": 0 if not main_cruise_enabled else 1 if enabled else 3,
"DISTANCE_LEAD": 0 if not main_cruise_enabled else 2 if enabled and hud.leadVisible else 1 if hud.leadVisible else 0,
"DISTANCE_CAR": 0 if not main_cruise_enabled else 2 if enabled else 1,
})
msg_162["LEAD"] = 0 if not main_cruise_enabled else 2 if enabled else 1
if msg_1b5 is not None:
msg_162["LEAD_DISTANCE"] = msg_1b5["Longitudinal_Distance"]
return [packer.make_can_msg(msg, CAN.ECAN, values) for msg, values in (("CCNC_0x161", msg_161), ("CCNC_0x162", msg_162))]
def create_spas_messages(packer, CAN, left_blink, right_blink):
ret = []
@@ -190,7 +384,7 @@ def create_fca_warning_light(packer, CAN, frame):
return ret
def create_adrv_messages(packer, CAN, frame):
def create_adrv_messages(packer, CAN, frame, *, template=None, drive_gear=False, speed=None):
# messages needed to car happy after disabling
# the ADAS Driving ECU to do longitudinal control
@@ -198,7 +392,16 @@ def create_adrv_messages(packer, CAN, frame):
values = {
}
ret.append(packer.make_can_msg("ADRV_0x51", CAN.ACAN, values))
from opendbc.car.hyundai.gv70_template import GV70Template
if template is not None:
if type(CAN.ACAN) is not int or type(CAN.offset) is not int:
raise ValueError('Captured ADRV requires integer CAN routing')
local_bus = CAN.ACAN - CAN.offset
packet = (template.frame(frame, drive_gear, local_bus, speed=speed) if isinstance(template, GV70Template) else
template.frame(frame, drive_gear, local_bus))
ret.append(CanData(packet.address, packet.dat, CAN.ACAN))
else:
ret.append(packer.make_can_msg("ADRV_0x51", CAN.ACAN, values))
ret.extend(create_fca_warning_light(packer, CAN, frame))
@@ -234,11 +437,7 @@ def create_adrv_messages(packer, CAN, frame):
def hkg_can_fd_checksum(address: int, sig, d: bytearray) -> int:
crc = 0
for i in range(2, len(d)):
crc = ((crc << 8) ^ CRC16_XMODEM[(crc >> 8) ^ d[i]]) & 0xFFFF
crc = ((crc << 8) ^ CRC16_XMODEM[(crc >> 8) ^ ((address >> 0) & 0xFF)]) & 0xFFFF
crc = ((crc << 8) ^ CRC16_XMODEM[(crc >> 8) ^ ((address >> 8) & 0xFF)]) & 0xFFFF
crc = crc_hqx(d[2:] + (address & 0xFFFF).to_bytes(2, "little"), 0)
if len(d) == 8:
crc ^= 0x5F29
elif len(d) == 16:
+226 -16
View File
@@ -1,18 +1,30 @@
from opendbc.car.pedal import supported_pedal_detected
from opendbc.car.hyundai.classic_scc_aol import CLASSIC_SCC_IDS
from opendbc.car import Bus, get_safety_config, structs, uds
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import HyundaiFlags, CAR, DBC, HyundaiSafetyFlags
from opendbc.car.hyundai.radar_interface import RADAR_START_ADDR
from opendbc.car.hyundai.non_scc_aol import NON_SCC_IDS
from opendbc.car.hyundai.values import (is_blended, is_blended_alpha, HyundaiFlags, CAR, DBC, HyundaiSafetyFlags, CANFD_ANGLE_MODEL_BITS,
CANFD_ANGLE_OBSERVED_ADAS_BIT, HYUNDAI_MRR35_RADAR_DBC, HYUNDAI_MRR30_RADAR_DBC,
HYUNDAI_GV70_RADAR_DBC, HYUNDAI_G90_RADAR_DBC)
from opendbc.car.hyundai.radar_interface import RADAR_START_ADDR, MRR35_RADAR_START_ADDR, MRR30_RADAR_START_ADDR, radar_bus
from opendbc.car.interfaces import CarInterfaceBase
from opendbc.car.disable_ecu import disable_ecu
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.radar_interface import RadarInterface
from opendbc.car.hyundai.radar_interface import RadarInterface, blended_track_radar
ButtonType = structs.CarState.ButtonEvent.Type
Ecu = structs.CarParams.Ecu
# Cancel button can sometimes be ACC pause/resume button, main button can also enable on some cars
ENABLE_BUTTONS = (ButtonType.accelCruise, ButtonType.decelCruise, ButtonType.cancel, ButtonType.mainCruise)
KONA_NON_SCC_FCA_RADAR_ADDR = 0x602
def detect_kona_non_scc_radar_fca(candidate, fingerprint, car_fw) -> bool:
return candidate == CAR.HYUNDAI_KONA_NON_SCC and (
any(fw.ecu == Ecu.fwdRadar for fw in car_fw) or KONA_NON_SCC_FCA_RADAR_ADDR in fingerprint[1]
)
class CarInterface(CarInterfaceBase):
@@ -20,12 +32,66 @@ class CarInterface(CarInterfaceBase):
CarController = CarController
RadarInterface = RadarInterface
@staticmethod
def startup_required(cp):
from opendbc.car.hyundai.g90_startup import required
from opendbc.car.hyundai.ev6_startup import required as ev6_required
from opendbc.car.hyundai.gv70_startup import required as gv70_required
from opendbc.car.hyundai.ev9_startup import required as ev9_required
from opendbc.car.hyundai.torque_ev_startup import required as torque_ev_required
from opendbc.car.hyundai.blended_longitudinal import startup_required as blended_required
from opendbc.car.hyundai.legacy_long_startup import required as legacy_required
return legacy_required(cp) or blended_required(cp) or required(cp) or ev6_required(cp) or gv70_required(cp) or ev9_required(cp) or torque_ev_required(cp)
@staticmethod
def startup_owner(cp, callbacks, *, requested):
from opendbc.car.hyundai.legacy_long_aol import candidate as legacy_candidate
from opendbc.car.hyundai.legacy_long_startup import LegacyLongStartup
legacy_cp = legacy_candidate(cp, requested=requested)
if legacy_cp is not None:
return LegacyLongStartup(legacy_cp, callbacks, stock_cp=cp)
from opendbc.car.hyundai.g90_startup import G90Startup, required
if required(cp):
return G90Startup(cp, callbacks) if requested else None
from opendbc.car.hyundai.ev6_startup import EV6Startup, required as ev6_required
if ev6_required(cp):
return EV6Startup(cp, callbacks) if requested else None
from opendbc.car.hyundai.gv70_startup import GV70Startup, required as gv70_required
if gv70_required(cp):
return GV70Startup(cp, callbacks) if requested else None
from opendbc.car.hyundai.ev9_longitudinal import candidate as ev9_candidate, qualified as ev9_qualified
from opendbc.car.hyundai.ev9_startup import EV9Startup
ev9_cp = ev9_candidate(cp, enabled=requested, is_release=False)
if ev9_qualified(ev9_cp):
return EV9Startup(ev9_cp, callbacks, stock_cp=cp) if requested else None
from opendbc.car.hyundai.torque_ev_startup import candidate as torque_ev_candidate, TorqueEVStartup
torque_cp = torque_ev_candidate(cp, requested=requested, is_release=False)
if torque_cp is not None:
return TorqueEVStartup(torque_cp, callbacks, stock_cp=cp)
from opendbc.car.hyundai.blended_longitudinal import startup_owner
return startup_owner(cp, callbacks, requested=requested)
DRIVABLE_GEARS = (structs.CarState.GearShifter.sport, structs.CarState.GearShifter.manumatic)
@staticmethod
def get_pid_accel_limits(CP, current_speed, cruise_speed):
from opendbc.car.hyundai.ev9_longitudinal import qualified as ev9_long_qualified
if ev9_long_qualified(CP):
return -3.5, 2.2
if is_blended_alpha(CP):
return -3.5, 3.5
return CarInterfaceBase.get_pid_accel_limits(CP, current_speed, cruise_speed)
@staticmethod
def _get_params(ret: structs.CarParams, candidate, fingerprint, car_fw, alpha_long, is_release, docs) -> structs.CarParams:
ret.brand = "hyundai"
if is_blended(ret):
cam_can = CanBus(None, fingerprint).CAM
lka_steering = any(fw.ecu == Ecu.adas for fw in car_fw) or 0x50 in fingerprint[cam_can]
if lka_steering:
ret.flags |= HyundaiFlags.CANFD_LKA_STEER_MSG.value
if ret.flags & HyundaiFlags.CANFD:
# Shared configuration for CAN-FD cars
@@ -38,15 +104,20 @@ class CarInterface(CarInterfaceBase):
CAN = CanBus(None, fingerprint, lka_steering)
ret.alphaLongitudinalAvailable = not (ret.flags & HyundaiFlags.CANFD_NO_RADAR_DISABLE)
if ret.flags & HyundaiFlags.CANFD_ANGLE_STEERING:
# These manual angle profiles retain stock SCC in both builds.
ret.alphaLongitudinalAvailable = False
if lka_steering and Ecu.adas not in [fw.ecu for fw in car_fw]:
# this needs to be figured out for cars without an ADAS ECU
ret.alphaLongitudinalAvailable = False
if 0x1ba in fingerprint[CAN.ECAN]:
ret.flags |= HyundaiFlags.HAS_BSM.value
ret.deprecated.enableBsm = 0x1ba in fingerprint[CAN.ECAN] or candidate == CAR.KIA_EV9
if candidate == CAR.HYUNDAI_IONIQ_6:
ret.deprecated.enableBsm = fingerprint[CAN.ECAN].get(0x1ba) == 24
ret.steerAtStandstill = True
# Check if the car is hybrid. Only HEV/PHEV cars have 0xFA on E-CAN.
if 0xFA in fingerprint[CAN.ECAN]:
if 0xFA in fingerprint[CAN.ECAN] or candidate == CAR.KIA_CARNIVAL_HEV_4TH_GEN:
ret.flags |= HyundaiFlags.HYBRID.value
if lka_steering:
@@ -54,12 +125,22 @@ class CarInterface(CarInterfaceBase):
ret.flags |= HyundaiFlags.CANFD_LKA_STEER_MSG.value
if 0x110 in fingerprint[CAN.CAM]:
ret.flags |= HyundaiFlags.CANFD_LKA_STEER_MSG_ALT.value
if (candidate in (CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN) and
0x1aa in fingerprint[CAN.ECAN] and 0x1cf not in fingerprint[CAN.ECAN]):
ret.flags |= HyundaiFlags.CANFD_ALT_BUTTONS.value
else:
# no LKA steering
if 0x1cf not in fingerprint[CAN.ECAN]:
ret.flags |= HyundaiFlags.CANFD_ALT_BUTTONS.value
if not ret.flags & HyundaiFlags.CANFD_RADAR_SCC:
ret.flags |= HyundaiFlags.CANFD_CAMERA_SCC.value
if ret.flags & HyundaiFlags.CANFD_ANGLE_STEERING and candidate != CAR.KIA_EV6_2025 and 0xCB in fingerprint[CAN.CAM]:
ret.flags |= HyundaiFlags.SEND_LFA.value
# The Sportage 2026 identity is documented only without HDA II. A
# hybrid fuel source belongs to its separate HEV identity.
if candidate == CAR.KIA_SPORTAGE_2026 and (lka_steering or 0xFA in fingerprint[CAN.ECAN]):
ret.dashcamOnly = True
# Some LKA steering cars have alternative messages for gear checks
# ICE cars do not have 0x130; GEARS message on 0x40 or 0x70 instead
@@ -69,6 +150,8 @@ class CarInterface(CarInterfaceBase):
else:
ret.flags |= HyundaiFlags.CANFD_ALT_GEARS.value
if candidate in (CAR.KIA_EV6, CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN) and CAN.offset >= 8:
ret.dashcamOnly = True
cfgs = [get_safety_config(structs.CarParams.SafetyModel.hyundaiCanfd), ]
if CAN.ECAN >= 4:
cfgs.insert(0, get_safety_config(structs.CarParams.SafetyModel.noOutput))
@@ -82,20 +165,32 @@ class CarInterface(CarInterfaceBase):
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.CANFD_ALT_BUTTONS.value
if ret.flags & HyundaiFlags.CANFD_CAMERA_SCC:
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.CAMERA_SCC.value
if ret.flags & HyundaiFlags.CCNC and not lka_steering and not ret.flags & HyundaiFlags.CANFD_ANGLE_STEERING:
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.CCNC.value
if ret.flags & HyundaiFlags.CANFD_ANGLE_STEERING:
ret.steerControlType = structs.CarParams.SteerControlType.angle
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.CANFD_ANGLE_STEERING.value
ret.safetyConfigs[-1].safetyParam |= CANFD_ANGLE_MODEL_BITS[str(candidate)]
if ret.flags & HyundaiFlags.SEND_LFA:
ret.safetyConfigs[-1].safetyParam |= CANFD_ANGLE_OBSERVED_ADAS_BIT
else:
# Shared configuration for non CAN-FD cars
ret.alphaLongitudinalAvailable = not (ret.flags & (HyundaiFlags.LEGACY | HyundaiFlags.UNSUPPORTED_LONGITUDINAL))
if 0x58b in fingerprint[0]:
ret.flags |= HyundaiFlags.HAS_BSM.value
ret.alphaLongitudinalAvailable = not (ret.flags & (HyundaiFlags.LEGACY | HyundaiFlags.UNSUPPORTED_LONGITUDINAL | HyundaiFlags.NON_SCC))
if is_blended(ret):
# A prepublication owner alone may promote this exact stock CP.
ret.alphaLongitudinalAvailable = False
pt_bus = CanBus(None, fingerprint, bool(ret.flags & HyundaiFlags.CANFD_LKA_STEER_MSG)).ECAN if is_blended(ret) else 0
ret.deprecated.enableBsm = 0x58b in fingerprint[pt_bus]
# Send LFA message on cars with HDA
if 0x485 in fingerprint[2]:
ret.flags |= HyundaiFlags.SEND_LFA.value
# These cars use the FCA11 message for the AEB and FCW signals, all others use SCC12
if 0x38d in fingerprint[0] or 0x38d in fingerprint[2]:
if 0x38d in fingerprint[pt_bus] or 0x38d in fingerprint[2]:
ret.flags |= HyundaiFlags.USE_FCA.value
if detect_kona_non_scc_radar_fca(candidate, fingerprint, car_fw):
ret.flags |= HyundaiFlags.NON_SCC_RADAR_FCA.value
if ret.flags & HyundaiFlags.LEGACY:
# these cars require a special panda safety mode due to missing counters and checksums in the messages
@@ -103,19 +198,43 @@ class CarInterface(CarInterfaceBase):
else:
ret.safetyConfigs = [get_safety_config(structs.CarParams.SafetyModel.hyundai, 0)]
if is_blended(ret):
ret.safetyConfigs[0].safetyParam |= HyundaiSafetyFlags.CAN_CANFD_BLENDED.value
if lka_steering:
ret.safetyConfigs[0].safetyParam |= HyundaiSafetyFlags.CANFD_LKA_STEER_MSG.value
if ret.flags & HyundaiFlags.CAMERA_SCC:
ret.safetyConfigs[0].safetyParam |= HyundaiSafetyFlags.CAMERA_SCC.value
if candidate in (CAR.HYUNDAI_ELANTRA_2024, CAR.HYUNDAI_ELANTRA_HEV_2024):
ret.safetyConfigs[0].safetyParam |= HyundaiSafetyFlags.CAN_REFRESH_MSGS.value
# These cars have the LFA button on the steering wheel
if 0x391 in fingerprint[0]:
if 0x391 in fingerprint[0] or (candidate in NON_SCC_IDS | CLASSIC_SCC_IDS and
0x50c in fingerprint[0]):
ret.flags |= HyundaiFlags.HAS_LDA_BUTTON.value
if ret.flags & HyundaiFlags.NON_SCC:
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.NON_SCC.value
if ret.flags & HyundaiFlags.HAS_LDA_BUTTON and (candidate not in NON_SCC_IDS or
0x391 in fingerprint[0]):
# Ordinary safety still requires its existing BCM source. The AOL
# profile alone may select the observed alternative CLU source.
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.HAS_LDA_BUTTON.value
if candidate == CAR.KIA_RAY_EV and fingerprint[2].get(0x485) == 8:
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.CAN_REFRESH_MSGS.value
if is_blended(ret) and 0x2AA in fingerprint[0]:
ret.minSteerSpeed = 0.0
ret.flags &= ~HyundaiFlags.MIN_STEER_32_MPH.value
ret.steerAtStandstill = True
# Common lateral control setup
ret.centerToFront = ret.wheelbase * 0.4
ret.steerActuatorDelay = 0.1
ret.steerLimitTimer = 0.4
CarInterfaceBase.configure_torque_tune(candidate, ret.lateralTuning)
if not (ret.flags & HyundaiFlags.CANFD_ANGLE_STEERING):
CarInterfaceBase.configure_torque_tune(candidate, ret.lateralTuning)
if ret.flags & HyundaiFlags.ALT_LIMITS:
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.ALT_LIMITS.value
@@ -128,10 +247,40 @@ class CarInterface(CarInterfaceBase):
# Common longitudinal control setup
ret.radarUnavailable = RADAR_START_ADDR not in fingerprint[1] or Bus.radar not in DBC[ret.carFingerprint]
ret.openpilotLongitudinalControl = alpha_long and ret.alphaLongitudinalAvailable
radar_start = KONA_NON_SCC_FCA_RADAR_ADDR if candidate == CAR.HYUNDAI_KONA_NON_SCC else RADAR_START_ADDR
if DBC[ret.carFingerprint].get(Bus.radar) == HYUNDAI_G90_RADAR_DBC:
ret.radarUnavailable = not all(fingerprint[1].get(addr) == 8 for addr in range(0x500, 0x520))
elif blended_track_radar(ret) or DBC[ret.carFingerprint].get(Bus.radar) == HYUNDAI_GV70_RADAR_DBC:
ret.radarUnavailable = not all(fingerprint[0].get(addr) == 32 for addr in range(0x210, 0x220))
elif DBC[ret.carFingerprint].get(Bus.radar) == HYUNDAI_MRR30_RADAR_DBC:
ret.radarUnavailable = fingerprint[radar_bus(ret)].get(MRR30_RADAR_START_ADDR) != 32
elif DBC[ret.carFingerprint].get(Bus.radar) == HYUNDAI_MRR35_RADAR_DBC:
ret.radarUnavailable = fingerprint[radar_bus(ret)].get(MRR35_RADAR_START_ADDR) != 24
else:
ret.radarUnavailable = radar_start not in fingerprint[1] or Bus.radar not in DBC[ret.carFingerprint]
from opendbc.car.hyundai.torque_ev_startup import topology_owned as torque_ev_topology
if torque_ev_topology(ret):
# The prepared owner alone promotes the saved developer choice before CI construction.
ret.alphaLongitudinalAvailable = not is_release
alpha_long = False
from opendbc.car.hyundai.legacy_long_aol import CARS as legacy_long_cars
ret.openpilotLongitudinalControl = alpha_long and ret.alphaLongitudinalAvailable and candidate not in legacy_long_cars
if (candidate in (CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN) and
ret.flags & HyundaiFlags.CANFD_ALT_BUTTONS and 0x1aa in fingerprint[CAN.ECAN] and
not ret.openpilotLongitudinalControl):
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.CARNIVAL_ALT_RESUME.value
ret.pcmCruise = not ret.openpilotLongitudinalControl
ret.longitudinalActuatorDelay = 0.5
if candidate in (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9):
if candidate == CAR.HYUNDAI_IONIQ_5_PE:
ret.longitudinalActuatorDelay = 0.35
ret.steerAtStandstill = True
# Only the source-derived stock-SCC LKA_ALT topology is admitted.
if (CAN.ECAN != 1 or CAN.ACAN != 0 or CAN.CAM != 2 or fingerprint[CAN.CAM].get(0x110) != 32 or
fingerprint[CAN.CAM].get(0x362) != 32 or fingerprint[CAN.ECAN].get(0x1cf) != 8 or
fingerprint[CAN.ECAN].get(0x35) != 32 or fingerprint[CAN.ECAN].get(0x1a0) != 32 or
ret.flags & (HyundaiFlags.HYBRID | HyundaiFlags.CANFD_ALT_BUTTONS | HyundaiFlags.CANFD_CAMERA_SCC | HyundaiFlags.SEND_LFA)):
ret.dashcamOnly = True
if ret.openpilotLongitudinalControl:
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.LONG.value
@@ -142,8 +291,34 @@ class CarInterface(CarInterfaceBase):
elif ret.flags & HyundaiFlags.FCEV:
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.FCEV_GAS.value
if (supported_pedal_detected(fingerprint, 0, supported=candidate == CAR.KIA_RAY_EV) and
ret.safetyConfigs[-1].safetyParam == int(HyundaiSafetyFlags.NON_SCC | HyundaiSafetyFlags.EV_GAS |
HyundaiSafetyFlags.HAS_LDA_BUTTON | HyundaiSafetyFlags.CAN_REFRESH_MSGS)):
ret.alphaLongitudinalAvailable = True
ret.openpilotLongitudinalControl = True
ret.pcmCruise = False
ret.radarUnavailable = True
ret.autoResumeSng = False
ret.minEnableSpeed = -1.0
ret.safetyConfigs[-1].safetyParam |= HyundaiSafetyFlags.LONG.value
ret.longitudinalTuning.kiBP = [0.0, 3.0, 6.0, 35.0]
ret.longitudinalTuning.kiV = [0.07, 0.10, 0.15, 0.24]
if candidate == CAR.KIA_RAY_EV and not ret.openpilotLongitudinalControl:
ret.dashcamOnly = True
# Car specific configuration overrides
if candidate == CAR.HYUNDAI_ELANTRA_HEV_2024:
ret.longitudinalActuatorDelay = 0.22
if is_blended(ret):
ret.openpilotLongitudinalControl = False
ret.pcmCruise = True
ret.stopAccel = -0.85
from opendbc.car.hyundai.blended_longitudinal import mixed_startup_qualified
ret.alphaLongitudinalAvailable = mixed_startup_qualified(ret, is_release=is_release)
if candidate == CAR.KIA_OPTIMA_G4_FL:
ret.steerActuatorDelay = 0.2
@@ -152,6 +327,41 @@ class CarInterface(CarInterfaceBase):
if candidate in (CAR.KIA_OPTIMA_H,):
ret.dashcamOnly = True
if candidate == CAR.KIA_EV6_2025 and ret.flags & (HyundaiFlags.CANFD_LKA_STEER_MSG | HyundaiFlags.HYBRID):
ret.dashcamOnly = True
ret.safetyConfigs = [get_safety_config(structs.CarParams.SafetyModel.noOutput)]
# Advertise only the exact developer stock profile. Startup still owns the
# saved-choice promotion, so availability never selects LONG during fingerprinting.
if candidate == CAR.KIA_EV9:
from opendbc.car.hyundai.ccnc_ev_stock import qualified as ev9_stock_qualified
ret.alphaLongitudinalAvailable = not is_release and ev9_stock_qualified(ret)
from opendbc.car.hyundai.legacy_long_aol import CARS as legacy_long_cars, topology_owned
if candidate in legacy_long_cars:
ret.openpilotLongitudinalControl = False
ret.pcmCruise = True
ret.alphaLongitudinalAvailable = not is_release and topology_owned(ret)
return ret
def update(self, can_packets):
if (self.CS.gv70_camera_lead is not None or self.CS.ev9_camera_lead is not None or
self.CS.ioniq6_camera_lead is not None or self.CS.torque_ev_camera_lead is not None or self.CS.forte_lkas_sources is not None):
# This isolated optional parser must never join required controls health.
can_packets = list(can_packets)
if self.CS.gv70_camera_lead is not None:
self.CS.gv70_camera_lead.update(can_packets)
if self.CS.ev9_camera_lead is not None:
self.CS.ev9_camera_lead.update(can_packets)
if self.CS.ioniq6_camera_lead is not None:
self.CS.ioniq6_camera_lead.update(can_packets)
if self.CS.torque_ev_camera_lead is not None:
self.CS.torque_ev_camera_lead.update(can_packets)
if self.CS.forte_lkas_sources is not None:
self.CS.forte_lkas_sources.update(can_packets)
ret = super().update(can_packets)
self.CS.finalize_blended_cancel(ret)
return ret
@staticmethod
@@ -160,7 +370,7 @@ class CarInterface(CarInterfaceBase):
if communication_control is None:
communication_control = bytes([uds.SERVICE_TYPE.COMMUNICATION_CONTROL, 0x80 | uds.CONTROL_TYPE.DISABLE_RX_DISABLE_TX, uds.MESSAGE_TYPE.NORMAL])
if CP.openpilotLongitudinalControl and not (CP.flags & (HyundaiFlags.CANFD_CAMERA_SCC | HyundaiFlags.CAMERA_SCC)):
if CP.openpilotLongitudinalControl and CP.carFingerprint != CAR.KIA_RAY_EV and not (CP.flags & (HyundaiFlags.CANFD_CAMERA_SCC | HyundaiFlags.CAMERA_SCC)):
addr, bus = 0x7d0, CanBus(CP).ECAN if CP.flags & HyundaiFlags.CANFD else 0
if CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG.value:
addr, bus = 0x730, CanBus(CP).ECAN
@@ -0,0 +1,17 @@
"""Compatibility API retaining the exact Ioniq 5 PE stock contract."""
from opendbc.car.hyundai import ccnc_ev_stock
from opendbc.car.hyundai.values import CAR
STOCK_SAFETY_PARAM = 0x5491
def qualified(cp):
return cp.carFingerprint == CAR.HYUNDAI_IONIQ_5_PE and ccnc_ev_stock.qualified(cp)
def request_allowed(cp, state):
return qualified(cp) and ccnc_ev_stock.request_allowed(cp, state)
def replacement_requested(cp, control, state):
return qualified(cp) and ccnc_ev_stock.replacement_requested(cp, control, state)
@@ -0,0 +1,78 @@
"""Exact Ioniq 6 HDA-II blind-spot status from physical corner and lamp sources."""
from dataclasses import dataclass
CORNER_MAX_AGE_NS = 100_000_000
LAMP_MAX_AGE_NS = 1_500_000_000
LAMP_HOLD_NS = 500_000_000
REAR_NEUTRAL_BODY = bytes.fromhex("000000000000008000000000000000000000000000000000")
FRONT_NEUTRAL_BODY = bytes.fromhex("00000000000000000000000100000000")
@dataclass(frozen=True)
class BlindspotStatus:
left: int
right: int
def rear_body(status: BlindspotStatus) -> bytes:
"""Return the frozen Ioniq 6 indication body; integrity bytes remain zero."""
left, right = status.left, status.right
if left not in (0, 1, 2) or right not in (0, 1, 2):
raise ValueError("invalid blind-spot indication")
body = bytearray(REAR_NEUTRAL_BODY)
body[3] = int(bool(left or right)) | (left << 6)
body[4] = right
body[5] = left << 1
body[6] = right << 5
body[7] = 0x80 | (max(left, right) << 3)
body[16] = left | (right << 2)
return bytes(body)
class Ioniq6BlindspotSources:
"""One-source-clock status gate for paired dashboard frames.
CANParser only updates source timestamps for checksum-valid corner frames.
Repeated parser values do not extend a blinker transition's hold.
"""
def __init__(self) -> None:
self.lamp_source_ns = 0
self.left_lamp = False
self.right_lamp = False
self.left_off_ns = 0
self.right_off_ns = 0
self.corner_source_ns = 0
self.corner_state = 0
def observe(self, *, corner_source_ns: int, corner_state: int, lamp_source_ns: int,
left_lamp: bool, right_lamp: bool) -> None:
if corner_source_ns > self.corner_source_ns:
self.corner_source_ns = corner_source_ns
self.corner_state = corner_state
if lamp_source_ns > self.lamp_source_ns:
if self.lamp_source_ns > 0 and lamp_source_ns - self.lamp_source_ns > LAMP_MAX_AGE_NS:
# A pre-gap illuminated lamp cannot authorize a new post-gap hold.
self.left_lamp = self.right_lamp = False
self.left_off_ns = self.right_off_ns = 0
if self.left_lamp and not left_lamp:
self.left_off_ns = lamp_source_ns
if self.right_lamp and not right_lamp:
self.right_off_ns = lamp_source_ns
self.left_lamp = left_lamp
self.right_lamp = right_lamp
self.lamp_source_ns = lamp_source_ns
def status(self, now_ns: int) -> BlindspotStatus | None:
if not (self.corner_source_ns > 0 and 0 <= now_ns - self.corner_source_ns <= CORNER_MAX_AGE_NS and
self.lamp_source_ns > 0 and 0 <= now_ns - self.lamp_source_ns <= LAMP_MAX_AGE_NS):
return None
left_present = bool(self.corner_state & 0x10)
right_present = bool(self.corner_state & 0x08)
left_blinking = self.left_lamp or (self.left_off_ns > 0 and 0 <= now_ns - self.left_off_ns <= LAMP_HOLD_NS)
right_blinking = self.right_lamp or (self.right_off_ns > 0 and 0 <= now_ns - self.right_off_ns <= LAMP_HOLD_NS)
return BlindspotStatus(1 + int(left_blinking) if left_present else 0,
1 + int(right_blinking) if right_present else 0)
@@ -0,0 +1,366 @@
"""Ioniq 6 HDA-II candidate and ECU-ownership transaction.
Select the exact opt-in DEBUG CarParams before publication. Verify live sources
and run the reversible diagnostic transaction when Card is ready to replace TX.
"""
import statistics
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum, auto
from opendbc.car.can_definitions import CanData, CanRecvCallable, CanSendCallable
from opendbc.car import make_tester_present_msg
from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum
from opendbc.car.isotp_parallel_query import IsoTpParallelQuery
IONIQ6_ECAN_BUS = 1
IONIQ6_ADAS_ADDR = 0x730
IONIQ6_STOCK_SCC_ADDR = 0x1A0
IONIQ6_STOCK_HEARTBEAT_ADDR = 0x100
IONIQ6_STOCK_BSM = {0x1BA: 24, 0x1E5: 16}
IONIQ6_REQUIRED_RX = {0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24, 0x1CF: 8}
IONIQ6_LONG_PREARM_ENABLED = True
IONIQ6_DEBUG_LONG_PARAMS = frozenset((0x8015, 0x8095, 0x8815, 0x8895))
class HandoffOutcome(Enum):
NOT_READY = auto()
STOCK = auto()
CONFIRMED = auto()
UNAVAILABLE = auto()
@dataclass(frozen=True)
class HandoffResult:
outcome: HandoffOutcome
reason: str
source_period: float | None = None
disable_acknowledged: bool = False
restore_acknowledged: bool = False
def build_ioniq6_hda2_long_candidate(stock_cp, fingerprint):
"""Prepare a separate CP only for the two observed HDA-II EV forms."""
from opendbc.car.hyundai.radar_interface import MRR35_RADAR_START_ADDR
from opendbc.car.hyundai.values import CAR
if stock_cp.carFingerprint != CAR.HYUNDAI_IONIQ_6 or stock_cp.radarUnavailable:
return None
camera = fingerprint[2]
direct = camera.get(0x50) == 16 and 0x110 not in camera
alternate = camera.get(0x110) == 32 and 0x50 not in camera
if not (direct or alternate):
return None
camera_support = (0x362, 32) if alternate else (0x2A4, 24)
if (camera.get(camera_support[0]) != camera_support[1] or
fingerprint[1].get(0x1CF) != 8 or
fingerprint[1].get(0x35) != 32 or fingerprint[1].get(0x175) != 24 or
fingerprint[1].get(0xA0) != 24 or fingerprint[1].get(0xEA) != 24 or
any(fingerprint[1].get(addr) != length for addr, length in {**IONIQ6_STOCK_BSM, 0x36A: 16}.items()) or
fingerprint[0].get(MRR35_RADAR_START_ADDR) != 24):
return None
if int(stock_cp.safetyConfigs[-1].safetyParam) not in (0x11, 0x91):
return None
long_cp = stock_cp.as_reader().as_builder()
long_cp.alphaLongitudinalAvailable = True
long_cp.openpilotLongitudinalControl = True
long_cp.pcmCruise = False
long_cp.safetyConfigs[-1].safetyParam = 0x8095 if alternate else 0x8015
return long_cp
DiagnosticExchange = Callable[[bytes, bytes], bool]
Clock = Callable[[], float]
class TimestampedCanPacket(list[CanData]):
"""CAN event frames with an optional producer timestamp for cadence only."""
def __init__(self, frames: list[CanData], log_mono_time_ns: int):
super().__init__(frames)
self.log_mono_time_ns = log_mono_time_ns
class ObservingCanRecv:
"""Keep SCC source evidence while ISO-TP consumes the shared CAN stream."""
def __init__(self, can_recv: CanRecvCallable, clock: Clock = time.monotonic):
self.can_recv = can_recv
self.clock = clock
self.source_seen_count = 0
self.latest_source_time: float | None = None
self.heartbeat_seen_count = 0
def __call__(self, wait_for_one: bool = False) -> list[list[CanData]]:
packets = self.can_recv(wait_for_one=wait_for_one)
for packet in packets:
for msg in packet:
if msg.src == IONIQ6_ECAN_BUS and msg.address in (IONIQ6_STOCK_SCC_ADDR, *IONIQ6_STOCK_BSM):
self.source_seen_count += 1
self.latest_source_time = self.clock()
elif msg.src == 0 and msg.address == IONIQ6_STOCK_HEARTBEAT_ADDR:
self.heartbeat_seen_count += 1
return packets
def query_ioniq6_adas(can_recv: CanRecvCallable, can_send: CanSendCallable, request: bytes, response: bytes) -> bool:
"""Validate the service-specific positive response from 0x738 on E-CAN.
DiagnosticSessionControl may include four P2/P2* timing bytes after 0x50 xx;
CommunicationControl has no corresponding response payload.
"""
try:
query = IsoTpParallelQuery(can_send, can_recv, IONIQ6_ECAN_BUS, [IONIQ6_ADAS_ADDR], [request], [response])
payload = query.get_data(0.15, total_timeout=0.25).get((IONIQ6_ADAS_ADDR, None))
if request in (b"\x10\x03", b"\x10\x01") and response == bytes((0x50, request[1])):
return payload is not None and len(payload) in (0, 4)
if request in (b"\x28\x01\x01", b"\x28\x00\x01") and response == bytes((0x68, request[1])):
return payload == b""
return False
except Exception:
return False
def _valid_canfd_crc(msg: CanData) -> bool:
return len(msg.dat) >= 2 and int.from_bytes(msg.dat[:2], "little") == hkg_can_fd_checksum(msg.address, None, bytearray(msg.dat))
def _read_window(can_recv: CanRecvCallable, clock: Clock, duration: float, *, wait_for_one: bool = True) -> tuple[list[tuple[float, int, float]], set[int], set[int], int, bool, set[int]]:
"""Collect both stock ECU TX sources and required E-CAN input health."""
source_samples: list[tuple[float, int, float]] = []
healthy: set[int] = set()
source_seen: set[int] = set()
status_valid: set[int] = set()
heartbeat_valid = 0
heartbeat_seen = False
deadline = clock() + duration
while clock() < deadline:
packets = can_recv(wait_for_one=wait_for_one)
if not packets and not wait_for_one:
# Polling preflight must also finish when the bus produces no frames.
time.sleep(min(0.001, max(0.0, deadline - clock())))
for packet in packets:
received_at = clock()
producer_ns = getattr(packet, "log_mono_time_ns", 0)
sample_time = producer_ns * 1e-9 if producer_ns > 0 else received_at
for msg in packet:
if msg.src == 0 and msg.address == IONIQ6_STOCK_HEARTBEAT_ADDR:
heartbeat_seen = True
if len(msg.dat) == 24 and _valid_canfd_crc(msg):
heartbeat_valid += 1
continue
if msg.src != IONIQ6_ECAN_BUS:
continue
if msg.address == IONIQ6_STOCK_SCC_ADDR:
# Even a bad CRC proves the stock ECU is still transmitting.
source_seen.add(msg.address)
if len(msg.dat) == 32 and _valid_canfd_crc(msg):
source_samples.append((sample_time, msg.dat[2], received_at))
elif msg.address in IONIQ6_STOCK_BSM:
source_seen.add(msg.address)
if len(msg.dat) == IONIQ6_STOCK_BSM[msg.address] and _valid_canfd_crc(msg):
status_valid.add(msg.address)
elif msg.address in IONIQ6_REQUIRED_RX and len(msg.dat) == IONIQ6_REQUIRED_RX[msg.address]:
if msg.address == 0x1CF or _valid_canfd_crc(msg):
healthy.add(msg.address)
return source_samples, healthy, source_seen, heartbeat_valid, heartbeat_seen, status_valid
def _measured_period(source_samples: list[tuple[float, int, float]]) -> float | None:
if len(source_samples) < 4:
return None
# A queued old burst with valid producer spacing cannot establish current
# stock source health. Fresh packets must reach this process across time.
if source_samples[-1][2] - source_samples[0][2] < 0.08:
return None
if any((b[1] - a[1]) % 256 != 1 for a, b in zip(source_samples, source_samples[1:], strict=False)):
return None
periods = [b[0] - a[0] for a, b in zip(source_samples, source_samples[1:], strict=False)]
period = statistics.median(periods)
if not (0.012 <= period <= 0.030) or any(not (0.008 <= delta <= 0.050) for delta in periods):
return None
return period
def inspect_ioniq6_long_sources(can_recv: CanRecvCallable, clock: Clock = time.monotonic) -> HandoffResult:
"""Receive-only prerequisite check before selecting a longitudinal profile."""
# Each attempt stands alone: never combine health or cadence across windows.
# Drain fingerprint/retry backlog before measuring current SCC cadence.
# Post-disable windows deliberately do not drain: any stock source matters.
for _ in range(3):
can_recv(wait_for_one=False)
pre_times, pre_health, pre_sources, pre_heartbeat_valid, _, pre_status_valid = _read_window(
can_recv, clock, 0.25, wait_for_one=False)
period = _measured_period(pre_times)
missing = []
if period is None:
missing.append('scc_period')
if pre_heartbeat_valid < 4:
missing.append('radar_heartbeat')
if pre_health != IONIQ6_REQUIRED_RX.keys():
missing.append('required_rx')
if pre_sources != {IONIQ6_STOCK_SCC_ADDR, *IONIQ6_STOCK_BSM}:
missing.append('stock_sources')
if pre_status_valid != IONIQ6_STOCK_BSM.keys():
missing.append('bsm_status')
if not missing:
return HandoffResult(HandoffOutcome.NOT_READY, 'preflight_ready', period)
return HandoffResult(HandoffOutcome.NOT_READY, 'preflight_' + ','.join(missing))
def run_ioniq6_handoff(can_recv: CanRecvCallable, exchange: DiagnosticExchange, clock: Clock = time.monotonic) -> HandoffResult:
"""Resolve stock or long authority before constructing or publishing CarParams.
The 50 Hz SCC source yields a provisional five-period silence/resumption
window; these bounds require route calibration before feature activation.
"""
preflight = inspect_ioniq6_long_sources(can_recv, clock)
period = preflight.source_period
if period is None:
return preflight
silence_window = min(max(5.0 * period, 0.10), 0.20)
session_ack = exchange(b"\x10\x03", b"\x50\x03")
disable_ack = session_ack and exchange(b"\x28\x01\x01", b"\x68\x01")
if disable_ack:
_, post_health, post_source_seen, _, post_heartbeat_seen, _ = _read_window(can_recv, clock, silence_window)
if not post_source_seen and not post_heartbeat_seen and post_health == IONIQ6_REQUIRED_RX.keys():
return HandoffResult(HandoffOutcome.CONFIRMED, "positive UDS response and observed SCC/heartbeat/BSM silence", period, True)
# Even an unanswered 0x28 request might have taken effect. Restore both
# normal-message TX and the normal diagnostic session before stock fallback.
restore_tx_ack = exchange(b"\x28\x00\x01", b"\x68\x00")
restore_session_ack = exchange(b"\x10\x01", b"\x50\x01")
restored = restore_tx_ack and restore_session_ack
restored_times, restored_health, restored_sources, restored_heartbeat_valid, _, restored_status_valid = _read_window(
can_recv, clock, max(0.25, silence_window))
if (restored and _measured_period(restored_times) is not None and restored_heartbeat_valid >= 4 and
restored_health == IONIQ6_REQUIRED_RX.keys() and
restored_sources == {IONIQ6_STOCK_SCC_ADDR, *IONIQ6_STOCK_BSM} and restored_status_valid == IONIQ6_STOCK_BSM.keys()):
return HandoffResult(HandoffOutcome.STOCK, "restored ECU TX/session and verified stock SCC/heartbeat/BSM", period, disable_ack, True)
return HandoffResult(HandoffOutcome.UNAVAILABLE, "ECU restore or stock SCC/heartbeat/BSM resumption unverified", period, disable_ack, restored)
def restore_ioniq6_adas(can_recv: CanRecvCallable, can_send: CanSendCallable, clock: Clock = time.monotonic) -> bool:
"""Abort a post-publication startup without ever claiming stock CP ownership."""
observed_recv = ObservingCanRecv(can_recv, clock)
restore_tx = query_ioniq6_adas(observed_recv, can_send, b"\x28\x00\x01", b"\x68\x00")
restore_session = query_ioniq6_adas(observed_recv, can_send, b"\x10\x01", b"\x50\x01")
samples, healthy, sources, heartbeat_valid, _, status_valid = _read_window(observed_recv, clock, 0.25)
return (restore_tx and restore_session and _measured_period(samples) is not None and heartbeat_valid >= 4 and
healthy == IONIQ6_REQUIRED_RX.keys() and sources == {IONIQ6_STOCK_SCC_ADDR, *IONIQ6_STOCK_BSM} and
status_valid == IONIQ6_STOCK_BSM.keys())
class Ioniq6StartupKeepalive:
"""Maintain the acknowledged diagnostic session until Panda takes TX.
A conservative 0.5-second diagnostic interval keeps the prepublication
session alive before the controller's normal 1 Hz tester takes ownership.
The 60-second startup bound is a development fail-closed limit, not a measured
ECU session lifetime. Production enablement needs parked/device timing.
"""
def __init__(self, can_send: CanSendCallable, restore: Callable[[], bool], on_abort: Callable[[], None],
on_restore: Callable[[bool], None], clock: Clock = time.monotonic, max_startup_sec: float = 60.0):
self.can_send = can_send
self.restore = restore
self.on_abort = on_abort
self.on_restore = on_restore
self.clock = clock
self.max_startup_sec = max_startup_sec
self.started_at = clock()
self.next_tester_at = self.started_at
self.stop_event = threading.Event()
self.lock = threading.Lock()
self.thread: threading.Thread | None = None
self.completed = False
def tick(self):
with self.lock:
if self.completed or self.stop_event.is_set():
return
now = self.clock()
if now - self.started_at >= self.max_startup_sec:
self._abort_locked()
elif now >= self.next_tester_at:
try:
self.can_send([make_tester_present_msg(IONIQ6_ADAS_ADDR, IONIQ6_ECAN_BUS, suppress_response=True)])
self.next_tester_at = now + 0.5
except Exception:
self._abort_locked()
def _abort_locked(self):
self.completed = True
# The caller permanently suppresses controller TX before restoration.
self.on_abort()
try:
restored = self.restore()
except Exception:
restored = False
self.on_restore(restored)
def start(self):
def loop():
while not self.stop_event.wait(0.05) and not self.completed:
self.tick()
self.tick()
self.thread = threading.Thread(target=loop, name="ioniq6-prearm-tester", daemon=True)
self.thread.start()
def stop(self):
# This is the TX ownership boundary: no keepalive or restore is in flight
# when the caller is allowed to submit its first controller frame.
self.stop_event.set()
if self.thread is not None and self.thread is not threading.current_thread():
self.thread.join()
with self.lock:
self.completed = True
def finalize_ioniq6_prepublication(stock_cp, long_cp, can_recv: CanRecvCallable, can_send: CanSendCallable,
*, enabled: bool, is_release: bool, clock: Clock = time.monotonic):
"""Choose a prepared CP before CarInterface exists; never mutate it after arm.
The DEBUG candidate must already be built and validated before any UDS request.
"""
if not enabled or is_release or long_cp is None:
return stock_cp, False, None
raw = int(long_cp.safetyConfigs[-1].safetyParam)
if not long_cp.openpilotLongitudinalControl or raw not in IONIQ6_DEBUG_LONG_PARAMS:
raise ValueError("unreviewed Ioniq 6 longitudinal CarParams profile")
observed_recv = ObservingCanRecv(can_recv, clock)
result = run_ioniq6_handoff(
observed_recv, lambda request, response: query_ioniq6_adas(observed_recv, can_send, request, response), clock)
if result.outcome is HandoffOutcome.CONFIRMED:
return long_cp, True, result
if result.outcome is HandoffOutcome.UNAVAILABLE:
# Card's usual passive setup will publish noOutput; do not advertise stock
# cruise ownership when the stock SCC ECU may still be silent.
stock_cp.dashcamOnly = True
return stock_cp, False, result
def prepare_ioniq6_long_candidate(stock_cp, long_cp, *, enabled: bool, is_release: bool):
"""Select the immutable long CP without changing ECU communication yet."""
if not enabled or is_release or long_cp is None:
return stock_cp, False
raw = int(long_cp.safetyConfigs[-1].safetyParam)
if not long_cp.openpilotLongitudinalControl or raw not in IONIQ6_DEBUG_LONG_PARAMS:
raise ValueError("unreviewed Ioniq 6 longitudinal CarParams profile")
return long_cp, True
def confirm_ioniq6_prepared_takeover(can_recv: CanRecvCallable, can_send: CanSendCallable,
clock: Clock = time.monotonic) -> HandoffResult:
"""Run the source/UDS transaction only when replacement TX can start."""
observed_recv = ObservingCanRecv(can_recv, clock)
return run_ioniq6_handoff(
observed_recv, lambda request, response: query_ioniq6_adas(observed_recv, can_send, request, response), clock)
@@ -0,0 +1,131 @@
"""Ioniq 6 acceleration calibration, independent of ECU ownership and CAN packing.
The active-drive calibration comes from StarPilot 678af783. Inactive control and
pedal override reset its history so a later engagement cannot reuse old demand.
"""
from dataclasses import dataclass
import numpy as np
from opendbc.car import DT_CTRL, structs
from opendbc.car.hyundai.values import CarControllerParams
LongState = structs.CarControl.Actuators.LongControlState
RESPONSE = 1.2
MIN_JERK = 0.5 * RESPONSE
MAX_JERK = 4.8 * RESPONSE
ACCEL_STEP = 6.0 / 50.0 * RESPONSE
DECEL_STEP = 15.0 / 50.0 * RESPONSE
LOOKAHEAD_SPEEDS = (2.0, 5.0, 20.0)
LOOKAHEAD_TIMES = (0.3 / RESPONSE, 0.45 / RESPONSE, 0.6 / RESPONSE)
LOWER_JERK_ERRORS = (-2.0, -1.5, -1.0, -0.25, -0.1, -0.025, -0.01, -0.005)
LOWER_JERK_VALUES = tuple(value * (MAX_JERK / 3.3) for value in (3.3, 1.5, 1.0, 0.8, 0.7, 0.65, 0.55, 0.5))
def launch_floor(speed: float) -> float:
return float(np.interp(speed, (0.0, 0.6, 1.25, 2.5), (0.75, 0.6, 0.4, 0.0)))
@dataclass
class Ioniq6LongitudinalState:
desired_accel: float = 0.0
actual_accel: float = 0.0
accel_last: float = 0.0
jerk_upper: float = 0.0
jerk_lower: float = 0.0
launch_active: bool = False
stopping: bool = False
long_control_state_last: int = LongState.off
@dataclass(frozen=True)
class AccelerationRequest:
accel: float = 0.0
stopping: bool = False
jerk_upper: float = 3.0
jerk_lower: float = 1.0
def update_calibration(state: Ioniq6LongitudinalState, accel: float, speed: float, measured_accel: float,
control_state: int) -> None:
"""Advance the original active-control calibration once per 50 ms."""
starting = control_state == LongState.starting
state.stopping = control_state == LongState.stopping
restarting = (state.long_control_state_last in (LongState.stopping, LongState.starting) and
control_state in (LongState.starting, LongState.pid) and accel > 0.0 and speed < 0.5)
if accel <= 0.0 or speed >= 2.5 or accel < launch_floor(speed):
state.launch_active = False
elif starting or state.launch_active or (state.long_control_state_last == LongState.starting and control_state == LongState.pid):
state.launch_active = True
upper_limit = float(np.interp(speed, (0.0, 5.0, 20.0), (2.0, 3.0, 2.0))) * RESPONSE if control_state == LongState.pid else MIN_JERK
lower_limit = float(np.interp(speed, (0.0, 5.0, 20.0), (5.0, 3.5, 3.0))) * RESPONSE
horizon = float(np.interp(speed, LOOKAHEAD_SPEEDS, LOOKAHEAD_TIMES))
desired_jerk = float(np.clip((accel - state.accel_last) / horizon, -MAX_JERK, MAX_JERK))
measured_error = measured_accel - state.accel_last
lower_jerk = float(np.interp(measured_error, LOWER_JERK_ERRORS, LOWER_JERK_VALUES)) if measured_error < 0.0 else MIN_JERK
state.jerk_upper = min(max(desired_jerk, MIN_JERK), upper_limit)
state.jerk_lower = min(lower_jerk, lower_limit)
if state.stopping:
if speed <= 2.0:
brake_cap = float(np.interp(speed, (0.0, 0.08, 0.25, 0.6, 1.2, 2.0, 3.0), (-0.15, -0.16, -0.22, -0.42, -0.78, -1.15, -1.40)))
state.desired_accel = min(0.0, max(accel, brake_cap))
hold_jerk = float(np.interp(speed, (0.0, 0.15, 0.6, 1.2, 2.0, 3.0), (0.35, 0.40, 0.48, 0.65, 0.85, 1.10))) * RESPONSE
state.jerk_upper = min(state.jerk_upper, hold_jerk)
else:
state.desired_accel = float(np.clip(accel, CarControllerParams.ACCEL_MIN, 0.0))
else:
state.desired_accel = float(np.clip(accel, CarControllerParams.ACCEL_MIN, CarControllerParams.ACCEL_MAX))
if state.launch_active:
state.desired_accel = max(state.desired_accel, launch_floor(speed))
state.jerk_upper = max(state.jerk_upper, float(np.interp(speed, (0.0, 2.5), (4.8, 3.2))) * RESPONSE)
state.jerk_lower = max(state.jerk_lower, 1.0)
if restarting:
release_jerk = float(np.interp(speed, (0.0, 0.15, 0.5), (3.6 * RESPONSE, 4.2 * RESPONSE, 4.8 * RESPONSE)))
state.jerk_upper = min(state.jerk_upper, release_jerk)
step = (state.jerk_upper if state.desired_accel >= state.accel_last else state.jerk_lower) * DT_CTRL * 5.0
state.actual_accel = float(np.clip(state.desired_accel, state.accel_last - step, state.accel_last + step))
state.accel_last = state.actual_accel
state.long_control_state_last = control_state
class Ioniq6LongitudinalPolicy:
def __init__(self):
self.state = Ioniq6LongitudinalState()
self.was_active = False
def update(self, frame: int, *, active: bool, override: bool, accel: float, speed: float, measured_accel: float,
control_state: int, last_sent_accel: float) -> AccelerationRequest:
enabled = active and not override and control_state in (LongState.starting, LongState.pid, LongState.stopping)
if not enabled:
self.state = Ioniq6LongitudinalState()
self.was_active = False
return AccelerationRequest()
accel = float(np.clip(accel, CarControllerParams.ACCEL_MIN, CarControllerParams.ACCEL_MAX))
# The 20 Hz calibration may not run on this 100 Hz call. Withdraw the
# launch floor immediately when a fresh host target becomes softer; the
# ordinary bounded decrease below still handles residual output.
if accel <= 0.0 or speed >= 2.5 or accel < launch_floor(speed):
self.state.launch_active = False
if frame % 5 == 0 or not self.was_active:
update_calibration(self.state, accel, speed, measured_accel, control_state)
self.was_active = True
smoothed = accel >= self.state.actual_accel or self.state.launch_active or self.state.stopping
if smoothed:
return AccelerationRequest(self.state.actual_accel, self.state.stopping, self.state.jerk_upper, self.state.jerk_lower)
# Preserve the faster response to a new braking request, using the last
# actual 50 Hz SCC output rather than integrating at the 100 Hz host rate.
output = float(np.clip(accel, last_sent_accel - DECEL_STEP, last_sent_accel + ACCEL_STEP))
self.state.desired_accel = accel
self.state.actual_accel = self.state.accel_last = output
self.state.jerk_upper, self.state.jerk_lower = 3.0, 5.0
self.state.launch_active = False
self.state.stopping = control_state == LongState.stopping
self.state.long_control_state_last = control_state
return AccelerationRequest(output, self.state.stopping, 3.0 if control_state == LongState.pid else 1.0, 5.0)
@@ -0,0 +1,79 @@
"""Receive-only Ioniq 6 wheel media evidence, independent of CAN validity.
Only exact physical-bus 0x448/8 packets establish a sample. Times come from
the CAN receive timeline; consumers must not compare them to MONOTONIC.
No gesture, saved assignment, effective mode or CAN transmission lives here.
"""
from dataclasses import dataclass
MEDIA_ADDRESS = 0x448
MEDIA_MAX_GAP_NS = 300_000_000
@dataclass(frozen=True)
class MediaSample:
mode_held: bool
custom_held: bool
source_boot_ns: int
@dataclass(frozen=True)
class MediaObservation:
samples: tuple[MediaSample, ...]
parser_boot_ns: int
source_epoch: int
valid: bool
class Ioniq6MediaButtons:
"""Preserve packet order without counting a cached held bit as new input.
The recorded Ioniq neutral source runs at about 5 Hz. A 300 ms bound allows
normal cadence but clears a held sequence across a missing source. Press
transitions and their physical cadence still require vehicle qualification.
The caller must restrict construction to the reviewed Ioniq configuration.
"""
def __init__(self, bus: int):
if type(bus) is not int or not 0 <= bus <= 3:
raise ValueError("physical CAN bus required")
self.bus = bus
self.epoch = 0
self.last_batch_ns = 0
self.last_source_ns = 0
def _invalid(self, batch_ns: int) -> MediaObservation:
self.epoch += 1
self.last_source_ns = 0
self.last_batch_ns = batch_ns
return MediaObservation((), batch_ns, self.epoch, False)
def update(self, packets) -> MediaObservation:
samples = []
batch_ns = self.last_batch_ns
source_ns = self.last_source_ns
if not packets:
return self._invalid(batch_ns)
for stamp, frames in packets:
if type(stamp) is not int or stamp <= 0:
return self._invalid(batch_ns)
if stamp < batch_ns:
return self._invalid(stamp)
batch_ns = stamp
for address, data, bus in frames:
if address != MEDIA_ADDRESS or bus != self.bus:
continue
if (type(address) is not int or type(bus) is not int or not isinstance(data, (bytes, bytearray)) or
len(data) != 8 or stamp <= source_ns or
source_ns and stamp - source_ns > MEDIA_MAX_GAP_NS):
return self._invalid(batch_ns)
# Motorola one-bit signals 22 and 44 in STEERING_WHEEL_MEDIA_BUTTONS.
samples.append(MediaSample(bool(data[2] & 0x40), bool(data[5] & 0x10), stamp))
source_ns = stamp
if source_ns <= 0 or batch_ns - source_ns > MEDIA_MAX_GAP_NS:
return self._invalid(batch_ns)
self.last_batch_ns = batch_ns
self.last_source_ns = source_ns
return MediaObservation(tuple(samples), batch_ns, self.epoch, True)
@@ -0,0 +1,27 @@
"""Exact main-only classic ICE Kona independent lateral identity."""
from opendbc.car.structs import CarParams
from opendbc.car.hyundai.values import CAR, HyundaiFlags
# Exact classic namespace, independently distinct from Forte1400/1c00.
KONA_AOL_MARKER = 0x0400
KONA_STOCK_WORD = 0x1040
KONA_AOL_WORD = 0x1440
KONA_AOL_EXPERIENCE = 32
def qualified(cp):
allowed = (HyundaiFlags.NON_SCC | HyundaiFlags.ALT_LIMITS | HyundaiFlags.NON_SCC_RADAR_FCA |
HyundaiFlags.USE_FCA | HyundaiFlags.SEND_LFA)
required = HyundaiFlags.NON_SCC | HyundaiFlags.ALT_LIMITS
return (cp.carFingerprint == CAR.HYUNDAI_KONA_NON_SCC and cp.brand == 'hyundai' and
cp.alternativeExperience in (0, KONA_AOL_EXPERIENCE) and
not cp.passive and not cp.dashcamOnly and not cp.notCar and
not cp.openpilotLongitudinalControl and cp.pcmCruise and
int(cp.flags) & int(required) == int(required) and not int(cp.flags) & ~int(allowed) and
len(cp.safetyConfigs) == 1 and cp.safetyConfigs[0].safetyModel == CarParams.SafetyModel.hyundai and
cp.safetyConfigs[0].safetyParam in (KONA_STOCK_WORD, KONA_AOL_WORD))
def allow_lateral_onset(*, permitted, normal_enabled, steering_pressed, previous_active):
"""Preserve original retry-on-grip-release, after actual native permission."""
return bool(permitted and not (not normal_enabled and not previous_active and steering_pressed))
@@ -0,0 +1,50 @@
"""Exact legacy radar takeover and typed-axis configuration."""
from opendbc.car import structs
from opendbc.car.hyundai.values import CAR, HyundaiFlags
from opendbc.car.hyundai.classic_scc_aol import qualified as stock_qualified
CARS = frozenset((CAR.GENESIS_G80, CAR.KIA_XCEED_PHEV))
LEGACY_LONG_AOL_WORDS = frozenset((0xE902, 0xE903, 0xE912, 0xE913))
def ordinary_word(cp):
return (0xE900 if cp.carFingerprint == CAR.GENESIS_G80 else 0xE901) + (0x10 if cp.flags & HyundaiFlags.HAS_LDA_BUTTON else 0)
def aol_word(cp):
return ordinary_word(cp) + 2
def topology_owned(cp):
if cp.carFingerprint not in CARS or cp.brand != 'hyundai':
return False
declared = int(CAR[cp.carFingerprint].config.flags)
dynamic = int(HyundaiFlags.HAS_LDA_BUTTON | HyundaiFlags.USE_FCA | HyundaiFlags.SEND_LFA)
return (int(cp.flags) & ~dynamic == declared and not cp.passive and not cp.notCar and not cp.dashcamOnly
and cp.steerControlType == structs.CarParams.SteerControlType.torque and len(cp.safetyConfigs) == 1
and cp.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.hyundaiLegacy)
def qualified(cp, *, marked_only=False):
if not topology_owned(cp) or not cp.alphaLongitudinalAvailable or not cp.openpilotLongitudinalControl or cp.pcmCruise:
return False
word = int(cp.safetyConfigs[0].safetyParam)
return ((cp.alternativeExperience == 32 and word == aol_word(cp)) or
(not marked_only and cp.alternativeExperience == 0 and word == ordinary_word(cp)))
def candidate(stock, *, requested):
if not requested or not stock.alphaLongitudinalAvailable or not topology_owned(stock) or not stock_qualified(stock):
return None
cp = stock.as_reader().as_builder()
cp.openpilotLongitudinalControl = True
cp.pcmCruise = False
if cp.carFingerprint == CAR.GENESIS_G80:
cp.radarUnavailable = True
cp.safetyConfigs[0].safetyParam = ordinary_word(cp)
cp.alternativeExperience = 0
return cp
def stopping_decel_rate(cp):
return 0.8 if qualified(cp) else None
@@ -0,0 +1,73 @@
"""Legacy radar transaction with exact stock fallback before publication."""
from copy import deepcopy
import time
from opendbc.car import Bus
from opendbc.can.parser import MAX_BAD_COUNTER
from opendbc.car.hyundai.values import HyundaiFlags
from opendbc.car.hyundai.ecu_startup import HyundaiECUStartup, Outcome
from opendbc.car.hyundai.g90_startup import G90Startup
from opendbc.car.hyundai.legacy_long_aol import candidate, qualified, CARS, ordinary_word, aol_word
from opendbc.car.hyundai.classic_scc_aol import qualified as stock_qualified, stock_word, aol_word as stock_aol_word
def required(cp):
return cp.carFingerprint in CARS and cp.openpilotLongitudinalControl
class LegacyLongStartup(G90Startup):
def __init__(self, cp, callbacks, *, stock_cp):
expected = candidate(stock_cp, requested=True)
if expected is None or expected.to_dict() != cp.to_dict():
raise ValueError('Legacy LONG candidate differs from exact stock configuration')
HyundaiECUStartup.__init__(self, cp, callbacks, address=0x7d0, bus=0, label='Legacy LONG')
self.stock_cp = stock_cp.as_reader().as_builder()
def prepare(self, *, admission):
super().prepare(admission=admission)
if self.outcome in (Outcome.STOCK_UNTOUCHED, Outcome.STOCK_RESTORED):
self.cp = self.stock_cp.as_reader().as_builder()
self.prepared_cp = deepcopy(self.cp.to_dict())
elif self.outcome is Outcome.SENT_UNCONFIRMED:
self.floor_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
return self.cp
def sources_current(self, ci, now_ns):
if self.outcome is not Outcome.SENT_UNCONFIRMED:
return True
if not ci.CS.out.canValid or ci.CS.out.canTimeout:
return False
parser = ci.can_parsers[Bus.pt]
gas = 'E_EMS11' if ci.CP.flags & HyundaiFlags.HYBRID else 'EMS16'
for name in (gas, 'WHL_SPD11', 'TCS13', 'MDPS12', 'CLU11'):
source = parser.message_states.get(parser.dbc.name_to_msg[name].address)
if (source is None or not source.timestamps or source.counter_fail >= MAX_BAD_COUNTER
or not self.floor_ns < int(source.timestamps[-1]) <= now_ns
or now_ns - int(source.timestamps[-1]) > source.timeout_threshold):
return False
return True
def before_control(self, *, configured, sources_current, control_current):
self.check()
return self.outcome is not Outcome.SENT_UNCONFIRMED or (configured and sources_current and control_current)
def finalize_aol_configuration(self, ci):
if self.published or self.closed or ci is not self.ci or not self.ready:
raise RuntimeError('Legacy LONG AOL finalization requires configured startup')
if self.prepared_for(ci.CP):
return
if self.outcome is Outcome.SENT_UNCONFIRMED and qualified(ci.CP, marked_only=True):
before_word, after_word = ordinary_word(ci.CP), aol_word(ci.CP)
elif self.outcome in (Outcome.STOCK_UNTOUCHED, Outcome.STOCK_RESTORED) and stock_qualified(ci.CP, marked_only=True):
before_word, after_word = stock_word(ci.CP), stock_aol_word(ci.CP)
else:
raise RuntimeError('Legacy startup does not admit this AOL configuration')
expected = deepcopy(self.prepared_cp)
config = expected['safetyConfigs'][0]
if config['safetyModel'] != 'hyundaiLegacy' or config['safetyParam'] != before_word or expected['alternativeExperience'] != 0:
raise RuntimeError('Legacy prepared profile cannot be finalized')
config['safetyParam'] = after_word
expected['alternativeExperience'] = 32
if ci.CP.to_dict() != expected:
raise RuntimeError('Legacy AOL finalization changed unrelated CarParams')
self.prepared_cp = expected
@@ -0,0 +1,43 @@
"""Reached HKG acceleration PID and ACC/blended transition shaping."""
import numpy as np
class HKGModePolicy:
def init_mode(self, dt):
self.dt = dt
self.prev_mode = 'acc'
self.current_mode = 'acc'
self.mode_timer = 0.0
self.transitioning = False
def prepare_pid(self, pid, a_target, error, CS, context):
if type(context.experimental_mode) is bool:
mode = 'blended' if context.experimental_mode else 'acc'
if mode != self.current_mode:
self.prev_mode = self.current_mode
self.current_mode = mode
self.mode_timer = 0.0
self.transitioning = True
if self.transitioning:
self.mode_timer += self.dt
if self.mode_timer >= 1.0:
self.transitioning = False
if (pid.i > 0.0 and a_target < -.05 and error < -.25 and
not (CS.vEgo <= .35 and a_target > -.40)):
pid.i *= float(np.interp(abs(error), [.25, .75, 1.5], [.55, .25, 0.]))
# Source CP KF defaults to zero, which original LoC interpreted as gain1.
return a_target, self.transitioning and self.prev_mode == 'blended' and self.current_mode == 'acc'
def shape_output(self, output, a_target, error, CS, last_output):
if (output > 0.0 and a_target < -.10 and error < -.35 and
not (CS.vEgo <= .35 and a_target > -.40)):
output = min(output, float(np.interp(a_target, [-1.5, -.6, -.1], [0., 0., .05])))
if self.transitioning and self.prev_mode == 'blended' and self.current_mode == 'acc':
if output > last_output:
output = last_output + (output - last_output) * min(1., self.mode_timer)
elif self.transitioning and self.prev_mode == 'acc' and self.current_mode == 'blended':
if output < 0.0 and output < last_output:
urgency = min(1., abs(output / -4.) ** .4)
blend = 1. - (1. - min(1., self.mode_timer)) * (1. - urgency)
output = last_output + (output - last_output) * blend
return output
@@ -0,0 +1,17 @@
"""Vehicle startup evidence for external model hardware."""
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import CAR
def model_power_ready_bus(CP) -> int | None:
# This car briefly interrupts accessory power when transitioning into READY.
return CanBus(CP).ECAN if CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 else None
def model_power_ready(messages, bus: int) -> bool | None:
ready = None
for message in messages:
if message.address == 0x35 and message.src == bus and len(message.dat) == 32:
ready = bool(message.dat[3] & 0x40)
return ready
@@ -0,0 +1,102 @@
"""Exact source-owned classic NON_SCC lateral profiles.
Gas and steering limits remain those of each declared platform. Ray's pedal
and refresh-message topology is deliberately handled by its separate owner.
"""
import time
from opendbc.car import structs
from opendbc.car.hyundai.values import CAR, HyundaiFlags, HyundaiSafetyFlags
NON_SCC_IDS = frozenset((CAR.HYUNDAI_BAYON_1ST_GEN_NON_SCC, CAR.HYUNDAI_ELANTRA_2022_NON_SCC,
CAR.HYUNDAI_ELANTRA_HEV_2022_NON_SCC, CAR.HYUNDAI_KONA_NON_SCC, CAR.HYUNDAI_KONA_EV_NON_SCC,
CAR.KIA_CEED_PHEV_2022_NON_SCC, CAR.KIA_FORTE_2019_NON_SCC, CAR.KIA_FORTE_2021_NON_SCC,
CAR.KIA_SELTOS_2023_NON_SCC, CAR.GENESIS_G70_2021_NON_SCC))
AOL_MARKER = 0x0400
AOL_EXPERIENCE = 32
AOL_WORDS = frozenset((0x1400, 0x1c00, 0x1440, 0x1c40, 0x1441, 0x1c41, 0x1402, 0x1c02))
def stock_word(cp):
word = int(HyundaiSafetyFlags.NON_SCC)
for flag, safety in ((HyundaiFlags.EV, HyundaiSafetyFlags.EV_GAS),
(HyundaiFlags.HYBRID, HyundaiSafetyFlags.HYBRID_GAS),
(HyundaiFlags.ALT_LIMITS, HyundaiSafetyFlags.ALT_LIMITS)):
if cp.flags & flag:
word |= int(safety)
if cp.flags & HyundaiFlags.HAS_LDA_BUTTON:
word |= int(HyundaiSafetyFlags.HAS_LDA_BUTTON)
return word
def qualified(cp):
if cp.carFingerprint not in NON_SCC_IDS or cp.brand != 'hyundai':
return False
declared = int(CAR[cp.carFingerprint].config.flags)
identity_mask = int(HyundaiFlags.NON_SCC | HyundaiFlags.EV | HyundaiFlags.HYBRID | HyundaiFlags.ALT_LIMITS |
HyundaiFlags.ALT_LIMITS_2 | HyundaiFlags.CANFD)
allowed = declared | int(HyundaiFlags.HAS_LDA_BUTTON | HyundaiFlags.USE_FCA | HyundaiFlags.SEND_LFA |
HyundaiFlags.NON_SCC_RADAR_FCA | HyundaiFlags.NON_SCC_NO_FCA)
if (int(cp.flags) & identity_mask != declared & identity_mask or int(cp.flags) & ~allowed or
cp.passive or cp.dashcamOnly or cp.notCar or cp.openpilotLongitudinalControl or not cp.pcmCruise or
cp.alternativeExperience not in (0, AOL_EXPERIENCE) or len(cp.safetyConfigs) != 1):
return False
safety = cp.safetyConfigs[0]
word = stock_word(cp)
# The observed alternative CLU source is marked only at the AOL boundary;
# ordinary safety may still use its original MAIN-only profile.
ordinary = word & ~int(HyundaiSafetyFlags.HAS_LDA_BUTTON)
return (safety.safetyModel == structs.CarParams.SafetyModel.hyundai and
safety.safetyParam in (word, ordinary, word | AOL_MARKER))
def aol_word(cp):
return stock_word(cp) | AOL_MARKER
SOURCE_MAX_AGE_NS = 300_000_000
class NonSccLkasSources:
"""Fuse observed alternative sources without replaying held startup input."""
def __init__(self):
self.sources = {}
self.neutral_seen = False
self.held = False
self.edges = []
def update(self, can_packets):
self.edges = []
try:
now = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
except (AttributeError, OSError):
self.sources.clear()
self.neutral_seen = False
self.held = False
return
current = {address: value for address, value in self.sources.items() if 0 <= now - value[0] <= SOURCE_MAX_AGE_NS}
if len(current) != len(self.sources):
# Expiry is loss of evidence, never a manufactured release or press.
self.neutral_seen = False
self.held = any(value[1] for value in current.values())
self.sources = current
for stamp, packets in can_packets:
if type(stamp) is not int or not 0 < stamp <= now or now - stamp > SOURCE_MAX_AGE_NS:
continue
for address, data, bus in packets:
if bus != 0 or address not in (0x391, 0x50c) or len(data) != 8:
continue
previous = self.sources.get(address)
if previous is not None and stamp <= previous[0]:
continue
pressed = bool(data[0] & 0x10) if address == 0x391 else bool(data[7] & 1)
source_neutral = (previous[2] if previous is not None else False) or not pressed
self.sources[address] = (stamp, pressed, source_neutral)
held = any(value[1] for value in self.sources.values())
if not held and not self.neutral_seen:
self.neutral_seen = True
if not self.held:
self.edges.append(False)
if held != self.held and self.neutral_seen and (not held or source_neutral):
self.edges.append(held)
self.held = held
@@ -3,27 +3,84 @@ import math
from opendbc.can import CANParser
from opendbc.car import Bus, structs
from opendbc.car.interfaces import RadarInterfaceBase
from opendbc.car.hyundai.values import DBC
from opendbc.car.hyundai.values import (CAR, DBC, HyundaiFlags, HYUNDAI_MRR35_RADAR_DBC, HYUNDAI_MRR30_RADAR_DBC,
HYUNDAI_GV70_RADAR_DBC, HYUNDAI_G90_RADAR_DBC, is_blended)
RADAR_START_ADDR = 0x500
RADAR_MSG_COUNT = 32
G90_RADAR_MSG_COUNT = 64
G90_RADAR_CYCLE_NS = 20_000_000
MRREVO14F_RADAR_START_ADDR = 0x602
MRREVO14F_RADAR_MSG_COUNT = 16
MRREVO14F_RADAR_CYCLE_NS = 20_000_000 # track messages are published at 50 Hz
MRR30_RADAR_START_ADDR = 0x210
MRR30_RADAR_MSG_COUNT = 16
MRR30_RADAR_CYCLE_NS = 20_000_000
GV70_RADAR_CYCLE_NS = 50_000_000
MRR35_RADAR_START_ADDR = 0x3A5
MRR35_RADAR_MSG_COUNT = 32
MRR35_RADAR_CYCLE_NS = 50_000_000 # 20 Hz
def blended_track_radar(CP) -> bool:
# Exact mixed classic PT / HDA-II steering identity, independently observed
# by the existing factory. Never relabel another classic or CANFD car.
return bool(CP.brand == 'hyundai' and is_blended(CP) and CP.flags & HyundaiFlags.CANFD_LKA_STEER_MSG)
def radar_dbc(CP):
return HYUNDAI_GV70_RADAR_DBC if blended_track_radar(CP) else DBC[CP.carFingerprint].get(Bus.radar)
def radar_bus(CP) -> int:
# Frozen Ioniq 6 camera-SCC wiring exposes the MRR35 object bank on bus 1;
# its LKA/HDA-II wiring and the other MRR35 cars expose it on bus 0.
if CP.carFingerprint == CAR.HYUNDAI_IONIQ_6 and CP.flags & HyundaiFlags.CANFD_CAMERA_SCC:
return 1
return 0
# POC for parsing corner radars: https://github.com/commaai/openpilot/pull/24221/
def get_radar_can_parser(CP):
if Bus.radar not in DBC[CP.carFingerprint]:
if radar_dbc(CP) is None:
return None
messages = [(f"RADAR_TRACK_{addr:x}", 50) for addr in range(RADAR_START_ADDR, RADAR_START_ADDR + RADAR_MSG_COUNT)]
return CANParser(DBC[CP.carFingerprint][Bus.radar], messages, 1)
start, count = radar_message_range(CP)
mrr35 = radar_dbc(CP) == HYUNDAI_MRR35_RADAR_DBC
g90 = radar_dbc(CP) == HYUNDAI_G90_RADAR_DBC
gv70 = radar_dbc(CP) == HYUNDAI_GV70_RADAR_DBC
messages = [(f"RADAR_TRACK_{addr:x}",
20 if mrr35 and addr == start + count - 1 else
float("nan") if mrr35 or g90 and addr >= start + RADAR_MSG_COUNT else 20 if gv70 else 50) for addr in range(start, start + count)]
mrr30 = radar_dbc(CP) == HYUNDAI_MRR30_RADAR_DBC
return CANParser(radar_dbc(CP), messages, radar_bus(CP) if mrr35 or mrr30 or gv70 else 1)
def radar_message_range(CP) -> tuple[int, int]:
if radar_dbc(CP) == HYUNDAI_G90_RADAR_DBC:
return RADAR_START_ADDR, G90_RADAR_MSG_COUNT
if radar_dbc(CP) in (HYUNDAI_MRR30_RADAR_DBC, HYUNDAI_GV70_RADAR_DBC):
return MRR30_RADAR_START_ADDR, MRR30_RADAR_MSG_COUNT
if radar_dbc(CP) == HYUNDAI_MRR35_RADAR_DBC:
return MRR35_RADAR_START_ADDR, MRR35_RADAR_MSG_COUNT
if radar_dbc(CP) == "hyundai_mrrevo14f_radar_generated":
return MRREVO14F_RADAR_START_ADDR, MRREVO14F_RADAR_MSG_COUNT
return RADAR_START_ADDR, RADAR_MSG_COUNT
class RadarInterface(RadarInterfaceBase):
def __init__(self, CP):
super().__init__(CP)
self.updated_messages = set()
self.trigger_msg = RADAR_START_ADDR + RADAR_MSG_COUNT - 1
self.start_addr, self.msg_count = radar_message_range(CP)
self.g90 = radar_dbc(CP) == HYUNDAI_G90_RADAR_DBC
self.mrrevo14f = self.start_addr == MRREVO14F_RADAR_START_ADDR
self.mrr35 = self.start_addr == MRR35_RADAR_START_ADDR
self.gv70 = radar_dbc(CP) == HYUNDAI_GV70_RADAR_DBC
self.mrr30 = self.start_addr == MRR30_RADAR_START_ADDR and not self.gv70
self.last_mrr35_trigger_ns = 0
self.trigger_msg = self.start_addr + (RADAR_MSG_COUNT if self.g90 else self.msg_count) - 1
self.radar_off_can = CP.radarUnavailable
self.rcp = get_radar_can_parser(CP)
@@ -32,12 +89,26 @@ class RadarInterface(RadarInterfaceBase):
if self.radar_off_can or (self.rcp is None):
return super().update(None)
if (self.g90 or self.mrr30 or self.gv70 or self.mrr35 and self.CP.carFingerprint == CAR.HYUNDAI_IONIQ_6) and can_strings:
expected_length = 8 if self.g90 else 32 if self.mrr30 or self.gv70 else 24
entries = [can_strings] if not isinstance(can_strings[0], list | tuple) else can_strings
can_strings = [(timestamp, [(addr, data, bus) for addr, data, bus in frames
if not (bus == self.rcp.bus and self.start_addr <= addr < self.start_addr + self.msg_count and len(data) != expected_length)])
for timestamp, frames in entries]
vls = self.rcp.update(can_strings)
self.updated_messages.update(vls)
if self.trigger_msg not in self.updated_messages:
return None
if self.mrr35:
trigger_ns = self.rcp.ts_nanos[self.trigger_msg]["LONG_DIST"]
if trigger_ns <= self.last_mrr35_trigger_ns:
self.updated_messages.clear()
return None
self.last_mrr35_trigger_ns = trigger_ns
rr = self._update(self.updated_messages)
self.updated_messages.clear()
@@ -51,9 +122,76 @@ class RadarInterface(RadarInterfaceBase):
if not self.rcp.can_valid:
ret.errors.canError = True
for addr in range(RADAR_START_ADDR, RADAR_START_ADDR + RADAR_MSG_COUNT):
for addr in range(self.start_addr, self.start_addr + self.msg_count):
msg = self.rcp.vl[f"RADAR_TRACK_{addr:x}"]
if self.mrr30 or self.gv70:
trigger_time = self.rcp.ts_nanos[self.trigger_msg]["1_LONG_DIST"]
source_time = self.rcp.ts_nanos[addr]["1_LONG_DIST"]
cycle_ns = GV70_RADAR_CYCLE_NS if self.gv70 else MRR30_RADAR_CYCLE_NS
fresh = addr in updated_messages and 0 <= trigger_time - source_time <= cycle_ns
for index in (1, 2):
track_key = addr * 2 + index - 1
if fresh and msg[f"{index}_STATE"] in (3, 4):
point = self.pts.get(track_key)
if point is None:
point = structs.RadarData.RadarPoint()
point.trackId = self.track_id
self.track_id += 1
self.pts[track_key] = point
point.dRel = msg[f"{index}_LONG_DIST"]
point.yRel = msg[f"{index}_LAT_DIST"]
point.vRel = msg[f"{index}_REL_SPEED"]
elif track_key in self.pts:
del self.pts[track_key]
continue
if self.mrr35:
trigger_ns = self.last_mrr35_trigger_ns
source_ns = self.rcp.ts_nanos[addr]["LONG_DIST"]
fresh = addr in updated_messages and 0 <= trigger_ns - source_ns <= MRR35_RADAR_CYCLE_NS
if fresh and msg["STATE"] in (3, 4):
point = self.pts.get(addr)
if point is None:
point = structs.RadarData.RadarPoint()
point.trackId = self.track_id
self.track_id += 1
self.pts[addr] = point
point.dRel = msg["LONG_DIST"]
point.yRel = msg["LAT_DIST"]
point.vRel = msg["REL_SPEED"]
elif addr in self.pts:
del self.pts[addr]
continue
if self.mrrevo14f:
trigger_time = self.rcp.ts_nanos[self.trigger_msg]["1_DISTANCE"]
source_time = self.rcp.ts_nanos[addr]["1_DISTANCE"]
fresh = addr in updated_messages and 0 <= trigger_time - source_time <= MRREVO14F_RADAR_CYCLE_NS
for index in (1, 2):
track_key = addr * 2 + index - 1
valid = fresh and msg[f"{index}_DISTANCE"] != 255.75
if valid:
point = self.pts.get(track_key)
if point is None:
point = structs.RadarData.RadarPoint()
point.trackId = self.track_id
self.track_id += 1
self.pts[track_key] = point
point.dRel = msg[f"{index}_DISTANCE"]
point.yRel = msg[f"{index}_LATERAL"]
point.vRel = msg[f"{index}_SPEED"]
elif track_key in self.pts:
del self.pts[track_key]
continue
if self.g90 and addr >= self.start_addr + RADAR_MSG_COUNT:
trigger_time = self.rcp.ts_nanos[self.trigger_msg]["LONG_DIST"]
source_time = self.rcp.ts_nanos[addr]["LONG_DIST"]
if addr not in updated_messages or not 0 <= trigger_time - source_time <= G90_RADAR_CYCLE_NS:
self.pts.pop(addr, None)
continue
if addr not in self.pts:
self.pts[addr] = structs.RadarData.RadarPoint()
self.pts[addr].trackId = self.track_id
@@ -0,0 +1,91 @@
import numpy as np
from opendbc.car import rate_limit
RAY_PEDAL_COMMAND_CAP = 0.70
RAY_PEDAL_CAP_SPEED_BP = [0.0, 12.0, 20.0, 25.0]
RAY_PEDAL_CAP_V = [0.55, 0.55, 0.65, RAY_PEDAL_COMMAND_CAP]
RAY_PEDAL_OFFSET_SPEED_BP = [0.0, 2.0, 4.0, 8.0, 12.0, 16.0, 20.0, 25.0]
RAY_PEDAL_OFFSET_V = [0.08, 0.13, 0.20, 0.32, 0.42, 0.51, 0.565, 0.60]
RAY_PEDAL_ACCEL_GAIN = 0.22
RAY_PEDAL_DECEL_GAIN = 0.55
RAY_PEDAL_RATE_UP = 0.02
RAY_PEDAL_RATE_DOWN = 0.06
RAY_PEDAL_OVERSPEED_CUTOFF = 0.5
RAY_PEDAL_TAPER_BELOW_TARGET = 0.75
def ray_pedal_enabled(CP):
from opendbc.car.hyundai.values import CAR, RAY_PEDAL_SAFETY_PARAM
from opendbc.car import structs
return (CP.brand == "hyundai" and CP.carFingerprint == CAR.KIA_RAY_EV and CP.openpilotLongitudinalControl and
not CP.passive and not CP.dashcamOnly and not CP.notCar and len(CP.safetyConfigs) == 1 and
CP.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.hyundai and
CP.safetyConfigs[0].safetyParam == RAY_PEDAL_SAFETY_PARAM)
def pedal_checksum(address, sig, data):
crc = 0xFF
for value in reversed(data[:-1]):
crc ^= value
for _ in range(8):
crc = ((crc << 1) ^ 0xD5) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
return crc
def create_ray_pedal_command(packer, gas_amount, idx):
enable = gas_amount > 0.001
values = {"ENABLE": enable, "COUNTER_PEDAL": idx & 0xF}
if enable:
values.update(GAS_COMMAND=gas_amount * 255., GAS_COMMAND2=gas_amount * 255.)
return packer.make_can_msg("GAS_COMMAND", 0, values)
def get_ray_pedal_target(accel, speed, set_speed):
if not all(np.isfinite(x) for x in (accel, speed, set_speed)) or not 1.0 <= set_speed <= 40.0:
return 0.0
speed_error = set_speed - speed
if speed_error <= -RAY_PEDAL_OVERSPEED_CUTOFF:
return 0.0
pedal_offset = float(np.interp(speed, RAY_PEDAL_OFFSET_SPEED_BP, RAY_PEDAL_OFFSET_V))
pedal_cap = float(np.interp(speed, RAY_PEDAL_CAP_SPEED_BP, RAY_PEDAL_CAP_V))
gain = RAY_PEDAL_DECEL_GAIN if accel < 0.0 else RAY_PEDAL_ACCEL_GAIN
target = float(np.clip(pedal_offset + accel * gain, 0.0, pedal_cap))
if speed_error < 0.0:
target *= float(np.clip(0.65 * (1.0 + speed_error / RAY_PEDAL_OVERSPEED_CUTOFF), 0.0, 1.0))
elif speed_error < RAY_PEDAL_TAPER_BELOW_TARGET:
target *= 0.65 + 0.35 * speed_error / RAY_PEDAL_TAPER_BELOW_TARGET
return target
def ray_pedal_gas(last, speed, accel, set_speed):
target = get_ray_pedal_target(accel, speed, set_speed)
if target <= 0.001:
return 0.0
return min(rate_limit(target, last, -RAY_PEDAL_RATE_DOWN, RAY_PEDAL_RATE_UP), target)
class RayPedalLongitudinalPolicy:
"""Own the selected Ray proportional gains and consume its supported factory integral gains."""
friction_variant = False
stopping_decel_rate = 1.0
def __init__(self, cp):
self.kp = ((0.0, 5.0, 15.0, 35.0), (0.095, 0.085, 0.065, 0.050))
self.ki = (tuple(cp.longitudinalTuning.kiBP), tuple(cp.longitudinalTuning.kiV))
def reset(self):
pass
def feedforward(self, target, _speed, _last_output):
return target
def prepare_pid(self, _pid, _target, _error, _speed, _last_output, _limits, *, should_stop, has_lead):
return False
def shape_output(self, output, _target, _error, _speed):
return output
def longitudinal_policy_for(cp):
return RayPedalLongitudinalPolicy(cp) if ray_pedal_enabled(cp) else None
@@ -0,0 +1,69 @@
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, structs
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.tests.test_palisade_2023 import params
from opendbc.car.hyundai.values import Buttons, DBC
def alpha_state(topology):
cp = params(topology)
cp.openpilotLongitudinalControl = True
cp.pcmCruise = False
cp.safetyConfigs[0].safetyParam |= 4
state = CarState(cp)
return state, state.get_can_parsers(cp), CANPacker(DBC[cp.carFingerprint][Bus.pt])
def feed(state, parsers, packer, tick, *, request=0, available=True, button=Buttons.NONE, omit=None):
frames = []
for parser in parsers.values():
for address in parser.addresses:
message = parser.dbc.addr_to_msg[address]
if message.name in ('SCC11', 'SCC12') or message.name == omit:
continue
values = {'COUNTER': tick % 16} if 'COUNTER' in message.sigs else {}
if message.name == 'TCS13':
values.update(ACCEnable=0 if available else 1, ACC_REQ=request)
elif message.name == 'CLU11':
values['CF_Clu_CruiseSwState'] = button
frames.append(packer.make_can_msg(message.name, parser.bus, values))
for parser in parsers.values():
parser.update([(1_000_000_000 + tick * 10_000_000, frames)])
result = state.update(parsers)
state.out = result
return result
class TestBlendedCarState(unittest.TestCase):
def test_alpha_disabled_scc_optional_and_independent_sources_required(self):
for topology in ('hdai', 'hdaii'):
state, parsers, packer = alpha_state(topology)
pt = parsers[Bus.pt]
self.assertNotIn(0x420, pt.message_states)
if topology == 'hdaii':
self.assertTrue(pt.message_states[0x421].ignore_alive)
else:
self.assertNotIn(0x421, pt.message_states)
for tick in range(12):
result = feed(state, parsers, packer, tick, request=1)
self.assertTrue(pt.can_valid)
self.assertTrue(result.cruiseState.available and result.cruiseState.enabled)
self.assertFalse(result.cruiseState.standstill or result.cruiseState.nonAdaptive)
for tick in range(12, 75):
feed(state, parsers, packer, tick, omit='TCS13')
_ = pt.can_valid # The interface samples health on every source update.
self.assertFalse(pt.can_valid)
def test_stock_parser_and_cancel_behavior_unchanged(self):
for topology in ('hdai', 'hdaii'):
cp = params(topology)
ci = CarInterface(cp)
for address in (0x420, 0x421):
self.assertEqual(ci.can_parsers[Bus.pt].message_states[address].frequency, 50)
ci.CS.out.cruiseState.enabled = False
events = ci.CS.create_cruise_button_events(Buttons.CANCEL, Buttons.NONE)
self.assertEqual(events[0].type, structs.CarState.ButtonEvent.Type.cancel)
@@ -0,0 +1,170 @@
"""Whole candidate sender contract under scripted diagnostic ACKs, never physical takeover."""
import unittest
from unittest.mock import patch
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.blended_disable_ecu import confirm_disable_ecu
from opendbc.car.hyundai.blended_longitudinal import BlendedLongitudinalOwner, BlendedLongitudinalController, candidate_from_stock
from opendbc.car.hyundai.hyundaican import hyundai_checksum
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.tests.test_palisade_2023 import feed
from opendbc.car.hyundai.values import CAR, HyundaiFlags
class PositiveQuery:
def __init__(self, *args, **kwargs):
self.address = args[3][0]
assert (args[4], args[5]) in (([b'\x11\x01'], [b'']), ([b'\x10\x03'], [b'\x50\x03']), ([b'\x28\x03\x01'], [b'\x68\x03']))
def get_data(self, timeout):
return {self.address: b''}
def selected_interface(hdaii, fca=False):
fingerprint = gen_empty_fingerprint()
if hdaii:
fingerprint[2][0x50] = 16
if fca:
fingerprint[2][0x38D] = 8
stock = CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, fingerprint, [], False, False, False)
cp = candidate_from_stock(stock, alpha_requested=True, native_qualified=True)
assert bool(cp.flags & HyundaiFlags.USE_FCA) == fca
ci = CarInterface(cp)
for tick in range(16):
feed(ci, tick)
owner = BlendedLongitudinalOwner(cp, CanBus(cp), confirm_disable_ecu)
with patch('opendbc.car.hyundai.blended_disable_ecu.IsoTpParallelQuery', PositiveQuery), patch('opendbc.car.hyundai.blended_disable_ecu.time.sleep'):
result = owner.begin(list, lambda frames: None, admission=True, unpublished=True)
assert result.admissible and owner.active()
ci.CC.blended_longitudinal = BlendedLongitudinalController(owner, ci.CC.packer, ci.CC.CAN)
# Original steady-loop cadence; startup handoff is independently qualified.
ci.CC.blended_longitudinal.normal_sent = True
return ci
def decode(ci, address, data, name):
signal = ci.CC.packer.dbc.addr_to_msg[address].sigs[name]
assert signal.is_little_endian
value = (int.from_bytes(data, 'little') >> signal.start_bit) & ((1 << signal.size) - 1)
if signal.is_signed and value & (1 << (signal.size - 1)):
value -= 1 << signal.size
return value * signal.factor + signal.offset
class TestBlendedSender(unittest.TestCase):
def test_whole_controller_order_cadence_crc_and_hda2_mirror(self):
for hdaii, fca in ((False, False), (True, False), (True, True)):
with self.subTest(hdaii=hdaii, fca=fca):
ci = selected_interface(hdaii, fca)
cp = ci.CP
self.assertEqual(ci.can_parsers[Bus.pt].bus, int(hdaii))
command = structs.CarControl()
command.enabled = command.latActive = command.longActive = True
command.actuators.torque = 0.5
command.actuators.accel = 3.5
command.actuators.longControlState = structs.CarControl.Actuators.LongControlState.pid
command.hudControl.setSpeed = 25.0
for frame in range(32):
actuators, messages = ci.apply(command.as_reader(), 2_000_000_000 + frame * 10_000_000)
wanted = []
if frame % 100 == 0:
wanted.append((0x730 if hdaii else 0x7D0, int(hdaii)))
wanted.extend([(0x50, 0), (0x340, 1)] if hdaii else [(0x340, 0), (0x364, 0)])
if hdaii and frame % 5 == 0:
wanted.append((0x2A4, 0))
if hdaii:
wanted.append((0x51, 0))
if frame % 2 == 0:
wanted.append((0x363, int(hdaii)))
if frame % 5 == 0:
wanted.extend((address, int(hdaii)) for address in ((0x398, 0x399, 0x39A, 0x39B, 0x39C) if hdaii else (0x398,)))
if hdaii and frame % 20 == 0:
wanted.append((0x43A, 1))
if frame % 2 == 0:
wanted.extend((address, int(hdaii)) for address in (0x420, 0x421, 0x389))
if fca:
wanted.append((0x38D, 1))
if hdaii and frame % 5 == 0:
wanted.append((0x485, 1))
self.assertEqual([(address, bus) for address, _, bus in messages], wanted)
packets = {address: data for address, data, _ in messages}
for address, data, _ in messages:
if address not in (0x730, 0x7D0, 0x50, 0x51, 0x2A4):
self.assertEqual(data[0], hyundai_checksum(data[1:8]), hex(address))
self.assertAlmostEqual(actuators.accel, 3.5)
if hdaii:
self.assertNotIn(0x364, packets)
self.assertEqual(decode(ci, 0x50, packets[0x50], 'TORQUE_REQUEST'), actuators.torqueOutputCan)
self.assertEqual(decode(ci, 0x340, packets[0x340], 'CR_Lkas_StrToqReq'), actuators.torqueOutputCan)
self.assertEqual(decode(ci, 0x50, packets[0x50], 'STEER_REQ'), decode(ci, 0x340, packets[0x340], 'CF_Lkas_ActToi'))
self.assertEqual(decode(ci, 0x340, packets[0x340], 'CF_Lkas_MsgCount'), frame % 15)
if frame % 2 == 0:
self.assertAlmostEqual(decode(ci, 0x420, packets[0x420], 'aReqRaw'), 3.5)
self.assertFalse(cp.passive)
def test_hda2_mirror_fault_suppression_and_disengage_icon_boundary(self):
ci = selected_interface(True)
command = structs.CarControl()
command.enabled = command.latActive = True
command.actuators.torque = 0.5
ci.CS.out = ci.CS.out.as_reader().as_builder()
ci.CS.out.steeringAngleDeg = 85.0
ci.CS.out = ci.CS.out.as_reader()
requests = []
for frame in range(94):
_, messages = ci.apply(command.as_reader(), 2_000_000_000 + frame * 10_000_000)
packets = {address: data for address, data, _ in messages}
primary = decode(ci, 0x50, packets[0x50], 'STEER_REQ')
mirror = decode(ci, 0x340, packets[0x340], 'CF_Lkas_ActToi')
self.assertEqual(primary, mirror)
requests.append(bool(primary))
self.assertEqual(requests[88:92], [True, False, False, True])
command.enabled = command.latActive = False
for frame in range(94, 195):
_, messages = ci.apply(command.as_reader(), 2_000_000_000 + frame * 10_000_000)
packets = {address: data for address, data, _ in messages}
expected = 3 if frame - 93 < 100 else 1
self.assertEqual(decode(ci, 0x50, packets[0x50], 'LKA_ICON'), expected)
self.assertEqual(decode(ci, 0x340, packets[0x340], 'CF_Lkas_FcwOpt_USM'), 2 if expected == 3 else 1)
def test_actual_mixed_topology_metadata_correct_bus_and_wrong_bus(self):
for hdaii in (False, True):
for observation_bus in (0, 1, 2):
with self.subTest(hdaii=hdaii, observation_bus=observation_bus):
fingerprint = gen_empty_fingerprint()
if hdaii:
fingerprint[2][0x50] = 16
fingerprint[observation_bus][0x38D] = 8
fingerprint[observation_bus][0x58B] = 8
stock = CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, fingerprint, [], False, False, False)
expected_fca = observation_bus in (int(hdaii), 2)
self.assertEqual(bool(stock.flags & HyundaiFlags.USE_FCA), expected_fca)
self.assertEqual(stock.deprecated.enableBsm, observation_bus == int(hdaii))
candidate = candidate_from_stock(stock, alpha_requested=True, native_qualified=True)
if not hdaii and expected_fca:
self.assertIsNone(candidate)
else:
self.assertIsNotNone(candidate)
self.assertEqual(bool(candidate.flags & HyundaiFlags.USE_FCA), expected_fca)
self.assertEqual(candidate.deprecated.enableBsm, stock.deprecated.enableBsm)
def test_mixed_standstill_metadata_retains_original_bus_zero(self):
for hdaii in (False, True):
for observation_bus in (0, 1):
with self.subTest(hdaii=hdaii, observation_bus=observation_bus):
fingerprint = gen_empty_fingerprint()
if hdaii:
fingerprint[2][0x50] = 16
fingerprint[observation_bus][0x2AA] = 8
stock = CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, fingerprint, [], False, False, False)
self.assertEqual(stock.steerAtStandstill, observation_bus == 0)
candidate = candidate_from_stock(stock, alpha_requested=True, native_qualified=True)
self.assertEqual(candidate.steerAtStandstill, stock.steerAtStandstill)
# Exercise the reached flag clear with a real mutable CP, not a fake field bag.
stock.flags |= HyundaiFlags.MIN_STEER_32_MPH.value
stock.minSteerSpeed = 10.0
CarInterface._get_params(stock, CAR.HYUNDAI_PALISADE_2023, fingerprint, [], False, False, False)
self.assertEqual(bool(stock.flags & HyundaiFlags.MIN_STEER_32_MPH), observation_bus != 0)
self.assertEqual(stock.minSteerSpeed, 0.0 if observation_bus == 0 else 10.0)
@@ -0,0 +1,67 @@
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
class TestCanfdBlinkers(unittest.TestCase):
def read_lamps(self, model, values, invalid_selector=None):
cp = CarInterface.get_params(model, gen_empty_fingerprint(), [], False, False, False)
self.assertTrue(cp.flags & HyundaiFlags.CANFD)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
state.update(parsers)
bus = CanBus(cp).ECAN
packer = CANPacker(DBC[model][Bus.pt])
parsers[Bus.pt].update((1_000_000_000, [packer.make_can_msg("BLINKERS", bus, values)]))
if invalid_selector is not None:
parsers[Bus.pt].vl["BLINKERS"]["USE_ALT_LAMP"] = invalid_selector
out = state.update(parsers)
return out.leftBlinker, out.rightBlinker
def test_selector_selects_packed_lamps_on_existing_canfd_cars(self):
for model in (CAR.KIA_EV6, CAR.HYUNDAI_IONIQ_6):
for selector in (0, 1):
for left, right in ((0, 0), (1, 0), (0, 1), (1, 1)):
with self.subTest(model=model, selector=selector, left=left, right=right):
values = {"USE_ALT_LAMP": selector, "LEFT_LAMP": left, "RIGHT_LAMP": right,
"LEFT_LAMP_ALT": 1 - left, "RIGHT_LAMP_ALT": 1 - right}
expected = (bool(left), bool(right)) if selector == 0 else (not left, not right)
self.assertEqual(self.read_lamps(model, values), expected)
def test_ccnc_and_kona_keep_alternate_lamps_without_selector(self):
for model in (CAR.HYUNDAI_KONA_EV_2ND_GEN, CAR.KIA_SPORTAGE_2026):
for selector in (0, 1):
with self.subTest(model=model, selector=selector):
self.assertEqual(self.read_lamps(model, {"USE_ALT_LAMP": selector,
"LEFT_LAMP": 0, "RIGHT_LAMP": 1,
"LEFT_LAMP_ALT": 1, "RIGHT_LAMP_ALT": 0}), (True, False))
def test_selector_change_retains_existing_lamp_hold_and_release(self):
cp = CarInterface.get_params(CAR.KIA_EV6, gen_empty_fingerprint(), [], False, False, False)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
state.update(parsers)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
bus = CanBus(cp).ECAN
for frame in range(52):
values = {"USE_ALT_LAMP": 1 if frame == 0 else 0, "LEFT_LAMP_ALT": int(frame == 0)}
parsers[Bus.pt].update(((frame + 1) * 10_000_000, [packer.make_can_msg("BLINKERS", bus, values)]))
out = state.update(parsers)
self.assertEqual(out.leftBlinker, frame < 50, frame)
self.assertFalse(out.rightBlinker)
def test_default_lamps_and_invalid_selector_keep_standard_defaults(self):
for model in (CAR.KIA_EV6, CAR.HYUNDAI_IONIQ_6):
with self.subTest(model=model):
self.assertEqual(self.read_lamps(model, {}), (False, False))
self.assertEqual(self.read_lamps(model, {"LEFT_LAMP": 1, "RIGHT_LAMP_ALT": 1},
invalid_selector=2), (True, False))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,50 @@
"""Pure source-policy vectors; accepted receipts are synthetic, not hardware evidence."""
from dataclasses import FrozenInstanceError
import math
import unittest
from opendbc.car.hyundai.canfd_lead import CANFDLeadObservation as Observation, select_lead
NOW = 1_000_000_000
FLOOR = 100_000_000
def selected(radar=None, camera=None, hud=False, now=NOW, floor=FLOOR):
return select_lead(radar, camera, hud_visible=hud, observed_ns=now, epoch_floor_ns=floor)
class TestCANFDLead(unittest.TestCase):
def test_precedence_minimum_distance_camera_and_hud_fallback(self):
radar = Observation(True, 15., -2., NOW)
camera = Observation(True, 10., 1., NOW)
self.assertEqual(selected(radar, camera).distance, 15.)
self.assertEqual(selected(Observation(True, .1, 2., NOW), camera).distance, 10.)
self.assertEqual(selected(Observation(True, .1, 2., NOW)).distance, 20.)
self.assertEqual(selected(hud=True).distance, 20.)
self.assertFalse(selected().visible)
self.assertFalse(selected(camera=Observation(True, .1, 0., NOW)).visible)
self.assertGreater(selected(Observation(True, math.nextafter(.1, math.inf), 0., NOW)).distance, .1)
def test_camera_age_future_epoch_and_nonfinite_boundaries(self):
for age, visible in ((300_000_000, True), (300_000_001, False), (-1, False)):
self.assertEqual(selected(camera=Observation(True, 10., 0., NOW-age)).visible, visible)
for stamp in (0, FLOOR, FLOOR-1, NOW+1):
self.assertFalse(selected(Observation(True, 10., 0., stamp)).visible)
self.assertFalse(selected(camera=Observation(True, 10., 0., stamp)).visible)
for value in (math.nan, math.inf, -math.inf):
for observation in (Observation(True, value, 0., NOW), Observation(True, 10., value, NOW)):
self.assertFalse(selected(observation).visible)
self.assertFalse(selected(camera=observation).visible)
self.assertFalse(selected(hud=True, floor=NOW).visible)
self.assertFalse(selected(hud=True, floor=-1).visible)
def test_clipping_stateless_recovery_and_immutable_values(self):
observation = Observation(True, 300., -100., NOW)
result = selected(observation)
self.assertEqual((result.distance, result.relative_speed), (204.7, -16.4))
self.assertEqual(selected(Observation(True, 10., 100., NOW)).relative_speed, 34.7)
self.assertFalse(selected(camera=Observation(True, 10., 0., NOW-300_000_001)).visible)
self.assertTrue(selected(camera=Observation(True, 10., 0., NOW)).visible)
for instance in (observation, result):
with self.assertRaises(FrozenInstanceError):
instance.extra = 1
@@ -0,0 +1,73 @@
"""Captured ADRV encoding keeps local validation and Panda routing separate."""
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, CanData, structs
from opendbc.car.hyundai.ev6_template import EV6Template
from opendbc.car.hyundai.gv70_template import GV70Template
from opendbc.car.hyundai.hyundaicanfd import CanBus, create_adrv_messages, hkg_can_fd_checksum
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
CAPTURE = bytes.fromhex('88ed2e091700ffff5e0d0000012006ff021c2200000000000800000010000000')
def buses(pandas: int, *, lka: bool = True) -> CanBus:
configs = [structs.CarParams.SafetyConfig(safetyModel=structs.CarParams.SafetyModel.noOutput) for _ in range(pandas - 1)]
configs.append(structs.CarParams.SafetyConfig(safetyModel=structs.CarParams.SafetyModel.hyundaiCanfd))
cp = structs.CarParams(safetyConfigs=configs, flags=int(HyundaiFlags.CANFD_LKA_STEER_MSG) if lka else 0)
return CanBus(cp)
class TestCapturedADRVRouting(unittest.TestCase):
def setUp(self):
self.packer = CANPacker(DBC[CAR.KIA_EV6][Bus.pt])
def test_ev6_and_gv70_use_local_encoding_and_logical_owner_bus(self):
for pandas in (1, 2):
can = buses(pandas)
self.assertEqual((can.offset, can.ACAN, can.ECAN), (4 * (pandas - 1), 4 * (pandas - 1), 4 * (pandas - 1) + 1))
for template in (EV6Template.capture(CAPTURE), GV70Template.capture(CAPTURE)):
for frame in (0, 7, 255, 256):
for drive in (False, True):
for speed in (None, 12.345):
with self.subTest(pandas=pandas, template=type(template).__name__, frame=frame, drive=drive, speed=speed):
local = (template.frame(frame, drive, 0, speed=speed) if isinstance(template, GV70Template) else
template.frame(frame, drive, 0))
sent = create_adrv_messages(self.packer, can, frame, template=template, drive_gear=drive, speed=speed)
self.assertEqual(sent[0], CanData(local.address, local.dat, can.ACAN))
self.assertEqual(int.from_bytes(sent[0].dat[:2], 'little'), hkg_can_fd_checksum(0x51, None, bytearray(sent[0].dat)))
self.assertEqual(template.data, CAPTURE)
self.assertTrue(all(packet[2] == can.ECAN for packet in sent[1:]))
if pandas == 2:
self.assertNotEqual(sent[0].src, 0)
def test_default_packer_sender_preserves_logical_bus_for_both_harnesses(self):
expected_packer = CANPacker(DBC[CAR.KIA_EV6][Bus.pt])
for pandas in (1, 2):
for lka in (False, True):
can = buses(pandas, lka=lka)
expected = expected_packer.make_can_msg('ADRV_0x51', can.ACAN, {})
self.assertEqual(create_adrv_messages(self.packer, can, 7)[0], expected)
def test_wrong_local_bus_and_noninteger_routing_remain_rejected(self):
for pandas in (1, 2):
for template in (EV6Template.capture(CAPTURE), GV70Template.capture(CAPTURE)):
with self.subTest(pandas=pandas, template=type(template).__name__, local_bus=1):
with self.assertRaises(ValueError):
create_adrv_messages(self.packer, buses(pandas, lka=False), 7, template=template, drive_gear=True)
for field, value in (('_a', False), ('_a', 0.0), ('offset', False), ('offset', 0.0)):
can = buses(pandas)
setattr(can, field, value)
with self.subTest(pandas=pandas, template=type(template).__name__, field=field, value=value):
with self.assertRaises(ValueError):
create_adrv_messages(self.packer, can, 7, template=template, drive_gear=True)
def test_template_physical_bus_and_drive_guards_remain_closed(self):
for pandas in (1, 2):
for template in (EV6Template.capture(CAPTURE), GV70Template.capture(CAPTURE)):
for drive in (0, 1):
with self.assertRaises(ValueError):
create_adrv_messages(self.packer, buses(pandas), 7, template=template, drive_gear=drive)
for bus in (False, 1, 4):
with self.assertRaises(ValueError):
template.frame(7, True, bus)
@@ -0,0 +1,84 @@
import unittest
from itertools import product
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
class TestCcncAngleLv2Fault(unittest.TestCase):
def test_packed_mdps_fault_inhibits_angle_request_and_recovers(self):
for car_model, lka_alt in ((CAR.HYUNDAI_SANTA_FE_HEV_5TH_GEN, True),
(CAR.KIA_SPORTAGE_2026, False)):
fingerprint = gen_empty_fingerprint()
if lka_alt:
fingerprint[2][0x110] = 32
cp = CarInterface.get_params(car_model, fingerprint, [], False, False, False)
self.assertTrue(cp.flags & HyundaiFlags.CANFD_ANGLE_STEERING)
self.assertEqual(bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG_ALT), lka_alt)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
packer = CANPacker(DBC[car_model][Bus.pt])
can = CanBus(cp)
controller = CarController(DBC[car_model], cp)
control = structs.CarControl()
control.enabled = control.latActive = True
control.actuators.steeringAngleDeg = 10.0
fuel = "ACCELERATOR_ALT" if cp.flags & HyundaiFlags.HYBRID else "ACCELERATOR_BRAKE_ALT"
setup = [(fuel, {}), (state.gear_msg_canfd, {"GEAR": 5}),
("TCS", {"ACCEnable": 0, "ACC_REQ": 1}), ("WHEEL_SPEEDS", {}),
("STEERING_SENSORS", {}), ("DOORS_SEATBELTS", {"DRIVER_SEATBELT": 1}),
("BLINKERS", {})]
cases = [*product(range(8), (0, 1)), (0, 0)]
for index, (lv2_fault, base_fault) in enumerate(cases):
with self.subTest(car_model=car_model, lv2_fault=lv2_fault, base_fault=base_fault):
now_ns = (index + 1) * 10_000_000
messages = [packer.make_can_msg(name, can.ECAN, values) for name, values in
[*setup, ("MDPS", {"MDPS_ADAS_AciFltSig_Lv2": lv2_fault,
"MDPS_LkaFailSta": base_fault})]]
mdps_bytes = messages[-1][1]
self.assertEqual((mdps_bytes[18] >> 4) & 7, lv2_fault)
self.assertEqual(bool(mdps_bytes[18] & 0x20), bool(lv2_fault & 2))
parsers[Bus.pt].update((now_ns, messages))
if cp.flags & HyundaiFlags.CANFD_CAMERA_SCC:
parsers[Bus.cam].update((now_ns, [packer.make_can_msg("SCC_CONTROL", can.CAM, {"ACCMode": 1})]))
else:
parsers[Bus.pt].update((now_ns, [packer.make_can_msg("SCC_CONTROL", can.ECAN, {"ACCMode": 1})]))
state.out = state.update(parsers)
expected_fault = bool((lv2_fault & 2) or base_fault)
self.assertEqual(state.out.steerFaultTemporary, expected_fault)
self.assertFalse(state.out.steerFaultPermanent)
_, sent = controller.update(control.as_reader(), state, now_ns)
angle_addresses = {0x110} if lka_alt else {0x12a}
angle_frames = [(address, data) for address, data, _ in sent if address in angle_addresses]
self.assertTrue(angle_frames)
self.assertEqual((angle_frames[0][1][9] >> 4) & 3, 1 if expected_fault else 2)
def test_torque_car_ignores_lv2_fault(self):
car_model = CAR.HYUNDAI_IONIQ_6
cp = CarInterface.get_params(car_model, gen_empty_fingerprint(), [], False, False, False)
self.assertFalse(cp.flags & HyundaiFlags.CANFD_ANGLE_STEERING)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
packer = CANPacker(DBC[car_model][Bus.pt])
can = CanBus(cp)
setup = [("ACCELERATOR", {"GEAR": 5}), ("TCS", {"ACCEnable": 0, "ACC_REQ": 1}),
("WHEEL_SPEEDS", {}), ("STEERING_SENSORS", {}),
("DOORS_SEATBELTS", {"DRIVER_SEATBELT": 1}), ("BLINKERS", {})]
for index, (lv2_fault, base_fault) in enumerate(product(range(8), (0, 1))):
with self.subTest(lv2_fault=lv2_fault, base_fault=base_fault):
messages = [packer.make_can_msg(name, can.ECAN, values) for name, values in
[*setup, ("MDPS", {"MDPS_ADAS_AciFltSig_Lv2": lv2_fault,
"MDPS_LkaFailSta": base_fault})]]
parsers[Bus.pt].update(((index + 1) * 10_000_000, messages))
state.out = state.update(parsers)
self.assertEqual(state.out.steerFaultTemporary, bool(base_fault))
self.assertFalse(state.out.steerFaultPermanent)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,228 @@
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.fw_versions import match_fw_to_car
from opendbc.car.hyundai.fingerprints import FW_VERSIONS
from opendbc.car.hyundai.hyundaicanfd import CanBus, create_carnival_alt_resume, hkg_can_fd_checksum
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags, HyundaiSafetyFlags
from opendbc.car.structs import CarParams
from opendbc.safety.tests.libsafety import libsafety_py
SIX = (CAR.HYUNDAI_IONIQ_5_N, CAR.HYUNDAI_TUCSON_PHEV_2025, CAR.KIA_K4_2025,
CAR.KIA_SORENTO_2024, CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_HEV_4TH_GEN)
CARNIVALS = SIX[-2:]
def params(car, hda2=False, alt_buttons=False, alpha=False, adas=False, alt_lka=False):
fp = gen_empty_fingerprint()
if hda2:
fp[2][0x110 if alt_lka else 0x50] = 32 if alt_lka else 16
if alt_buttons:
fp[1 if hda2 else 0][0x1aa] = 16
else:
fp[1 if hda2 else 0][0x1cf] = 8
fw = [CarParams.CarFw(ecu=CarParams.Ecu.adas)] if adas else []
return CarInterface.get_params(car, fp, fw, alpha, False, False)
class TestCcncSix(unittest.TestCase):
def test_exact_firmware_requires_both_distinguishing_ecus(self):
for car in SIX:
with self.subTest(car=car):
fw = [CarParams.CarFw(ecu=ecu, address=addr, subAddress=sub or 0, fwVersion=versions[0], brand="hyundai")
for (ecu, addr, sub), versions in FW_VERSIONS[car].items()]
exact, matches = match_fw_to_car(fw, "", log=False)
self.assertTrue(exact)
self.assertEqual(matches, {car})
for item in fw:
_, partial = match_fw_to_car([item], "", log=False)
self.assertNotIn(car, partial)
for sibling in (CAR.HYUNDAI_TUCSON_HEV_2025, CAR.HYUNDAI_TUCSON_2025, CAR.KIA_CARNIVAL_4TH_GEN,
CAR.KIA_SORENTO_4TH_GEN, CAR.HYUNDAI_IONIQ_5, CAR.KIA_EV6):
if sibling not in FW_VERSIONS:
continue
fw = [CarParams.CarFw(ecu=ecu, address=addr, subAddress=sub or 0, fwVersion=versions[0], brand="hyundai")
for (ecu, addr, sub), versions in FW_VERSIONS[sibling].items()]
_, matches = match_fw_to_car(fw, "", log=False)
self.assertFalse(set(SIX) & matches, sibling)
radar_key = (CarParams.Ecu.fwdRadar, 0x7d0, None)
camera_key = (CarParams.Ecu.fwdCamera, 0x7c4, None)
for car, sibling in ((CAR.HYUNDAI_IONIQ_5_N, CAR.HYUNDAI_IONIQ_5),
(CAR.HYUNDAI_TUCSON_PHEV_2025, CAR.HYUNDAI_TUCSON_HEV_2025),
(CAR.KIA_SORENTO_2024, CAR.KIA_SORENTO_4TH_GEN),
(CAR.KIA_CARNIVAL_2025, CAR.KIA_CARNIVAL_4TH_GEN)):
mixed = [CarParams.CarFw(ecu=key[0], address=key[1], fwVersion=FW_VERSIONS[owner][key][0], brand="hyundai")
for key, owner in ((camera_key, car), (radar_key, sibling))]
_, matches = match_fw_to_car(mixed, "", log=False)
self.assertNotIn(car, matches)
def test_six_topology_and_stock_ownership(self):
for car in SIX:
for hda2 in (False, True):
with self.subTest(car=car, hda2=hda2):
cp = params(car, hda2=hda2)
self.assertEqual(bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG), hda2)
self.assertTrue(cp.pcmCruise)
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertEqual(bool(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CCNC), not hda2)
unavailable = params(car, hda2=hda2, alpha=True)
self.assertEqual(unavailable.alphaLongitudinalAvailable, not hda2)
self.assertEqual(unavailable.openpilotLongitudinalControl, not hda2)
eligible = params(car, hda2=hda2, alpha=True, adas=True)
self.assertTrue(eligible.alphaLongitudinalAvailable)
self.assertTrue(eligible.openpilotLongitudinalControl)
self.assertTrue(eligible.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.LONG)
def test_carnival_alt_resume_only_with_observed_source_and_stock_long(self):
for car in CARNIVALS:
for hda2 in (False, True):
cp = params(car, hda2=hda2, alt_buttons=True)
self.assertTrue(cp.flags & HyundaiFlags.CANFD_ALT_BUTTONS)
self.assertTrue(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CARNIVAL_ALT_RESUME)
cp2 = params(car, hda2=hda2, alt_buttons=False)
self.assertFalse(cp2.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CARNIVAL_ALT_RESUME)
if not hda2:
cp_long = params(car, alt_buttons=True, alpha=True)
self.assertTrue(cp_long.openpilotLongitudinalControl)
self.assertFalse(cp_long.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CARNIVAL_ALT_RESUME)
def test_parser_and_controller_require_fresh_idle_carnival_button_source(self):
for hda2 in (False, True):
cp = params(CAR.KIA_CARNIVAL_2025, hda2=hda2, alt_buttons=True)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
bus = CanBus(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
source = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus.ECAN,
{"COUNTER": 23, "SET_ME_1": 1, "CRUISE_BUTTONS": 0})
parsers[Bus.pt].update((1_000_000_000, [source]))
state.out = state.update(parsers)
self.assertEqual(state.cruise_buttons_alt_msg["COUNTER"], 23)
self.assertEqual(state.cruise_buttons_alt_ts_ns, 1_000_000_000)
control = structs.CarControl()
control.cruiseControl.resume = True
controller = CarController(DBC[cp.carFingerprint], cp)
controller.frame = 26
_, sent = controller.update(control.as_reader(), state, 1_050_000_000)
self.assertEqual(len([msg for msg in sent if msg[0] == 0x1aa]), 1)
controller.frame = 52
_, sent = controller.update(control.as_reader(), state, 1_101_000_000)
self.assertFalse(any(msg[0] == 0x1aa for msg in sent))
state.cruise_buttons_alt_msg["ADAPTIVE_CRUISE_MAIN_BTN"] = 1
controller.frame = 78
_, sent = controller.update(control.as_reader(), state, 1_050_000_000)
self.assertFalse(any(msg[0] == 0x1aa for msg in sent))
def test_packed_carnival_resume_native_counter_crc_and_replay(self):
self._check_packed_carnival_resume(libsafety_py.libsafety)
def _check_packed_carnival_resume(self, safety):
packer = CANPacker(DBC[CAR.KIA_CARNIVAL_2025][Bus.pt])
for hda2 in (False, True):
cp = params(CAR.KIA_CARNIVAL_2025, hda2=hda2, alt_buttons=True)
can = CanBus(cp)
safety.set_safety_hooks(CarParams.SafetyModel.hyundaiCanfd, cp.safetyConfigs[-1].safetyParam)
safety.init_tests()
bus = can.ECAN
source_values = {"COUNTER": 17, "SET_ME_1": 1, "CRUISE_BUTTONS": 0, "BYTE6": 0x55}
addr, raw, _ = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, source_values)
data = bytearray(raw)
data[0:2] = hkg_can_fd_checksum(addr, None, data).to_bytes(2, "little")
source = dict(source_values)
resume = create_carnival_alt_resume(packer, cp, can, source)
self.assertEqual(resume[2], bus if hda2 else can.CAM)
tx = libsafety_py.make_CANPacket(resume[0], resume[2], resume[1])
self.assertFalse(safety.safety_tx_hook(tx))
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, bytes(data))))
safety.set_controls_allowed(True)
self.assertTrue(safety.safety_tx_hook(tx))
self.assertFalse(safety.safety_tx_hook(tx))
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, bytes(data))))
self.assertFalse(safety.safety_tx_hook(tx)) # replayed stock counter cannot reopen the gate
source["COUNTER"] = 18
source_values["COUNTER"] = 18
_, raw, _ = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, source_values)
data = bytearray(raw)
data[0:2] = hkg_can_fd_checksum(addr, None, data).to_bytes(2, "little")
resume = create_carnival_alt_resume(packer, cp, can, source)
tx = libsafety_py.make_CANPacket(resume[0], resume[2], resume[1])
bad_source = bytearray(data)
bad_source[0] ^= 1
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, bad_source)))
self.assertFalse(safety.safety_tx_hook(tx))
active_source = bytearray(data)
active_source[4] |= 0x20 # driver SET button
active_source[0:2] = hkg_can_fd_checksum(addr, None, active_source).to_bytes(2, "little")
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, active_source)))
self.assertFalse(safety.safety_tx_hook(tx))
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, bytes(data))))
wrong_counter = bytearray(resume[1])
wrong_counter[2] += 1
wrong_counter[0:2] = hkg_can_fd_checksum(addr, None, wrong_counter).to_bytes(2, "little")
self.assertFalse(safety.safety_tx_hook(libsafety_py.make_CANPacket(addr, resume[2], wrong_counter)))
wrong_field = bytearray(resume[1])
wrong_field[6] ^= 1
wrong_field[0:2] = hkg_can_fd_checksum(addr, None, wrong_field).to_bytes(2, "little")
self.assertFalse(safety.safety_tx_hook(libsafety_py.make_CANPacket(addr, resume[2], wrong_field)))
safety.set_timer(100_001)
self.assertFalse(safety.safety_tx_hook(tx))
safety.set_timer(0)
self.assertTrue(safety.safety_tx_hook(tx))
source["COUNTER"] = 19
source_values["COUNTER"] = 19
source_values["ADAPTIVE_CRUISE_MAIN_BTN"] = 1
_, raw, _ = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, source_values)
data = bytearray(raw)
data[0:2] = hkg_can_fd_checksum(addr, None, data).to_bytes(2, "little")
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, data)))
source["ADAPTIVE_CRUISE_MAIN_BTN"] = 1
active_resume = create_carnival_alt_resume(packer, cp, can, source)
self.assertFalse(safety.safety_tx_hook(libsafety_py.make_CANPacket(active_resume[0], active_resume[2], active_resume[1])))
source_values["ADAPTIVE_CRUISE_MAIN_BTN"] = 0
source_values["CRUISE_BUTTONS"] = 4 # driver cancel
_, raw, _ = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, source_values)
data = bytearray(raw)
data[0:2] = hkg_can_fd_checksum(addr, None, data).to_bytes(2, "little")
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, data)))
source["ADAPTIVE_CRUISE_MAIN_BTN"] = 0
source["CRUISE_BUTTONS"] = 4
active_resume = create_carnival_alt_resume(packer, cp, can, source)
self.assertFalse(safety.safety_tx_hook(libsafety_py.make_CANPacket(active_resume[0], active_resume[2], active_resume[1])))
source_values["CRUISE_BUTTONS"] = 0
_, raw, _ = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, source_values)
data = bytearray(raw)
data[0:2] = hkg_can_fd_checksum(addr, None, data).to_bytes(2, "little")
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, data)))
idle_resume = create_carnival_alt_resume(packer, cp, can, {**source, "CRUISE_BUTTONS": 0})
safety.set_timer(2_000_000)
safety.safety_tick()
self.assertFalse(safety.safety_tx_hook(libsafety_py.make_CANPacket(idle_resume[0], idle_resume[2], idle_resume[1])))
safety.set_safety_hooks(CarParams.SafetyModel.hyundaiCanfd,
cp.safetyConfigs[-1].safetyParam & ~HyundaiSafetyFlags.CARNIVAL_ALT_RESUME)
safety.init_tests()
safety.set_controls_allowed(True)
self.assertFalse(safety.safety_tx_hook(tx))
safety.set_safety_hooks(CarParams.SafetyModel.hyundaiCanfd, cp.safetyConfigs[-1].safetyParam)
safety.init_tests()
source_values["COUNTER"] = 255
_, raw, _ = packer.make_can_msg("CRUISE_BUTTONS_ALT", bus, source_values)
data = bytearray(raw)
data[0:2] = hkg_can_fd_checksum(addr, None, data).to_bytes(2, "little")
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, data)))
safety.set_controls_allowed(True)
wrap_resume = create_carnival_alt_resume(packer, cp, can, {**source, "COUNTER": 255, "CRUISE_BUTTONS": 0})
self.assertEqual(wrap_resume[1][2], 0)
self.assertTrue(safety.safety_tx_hook(libsafety_py.make_CANPacket(wrap_resume[0], wrap_resume[2], wrap_resume[1])))
@@ -0,0 +1,117 @@
import unittest
from unittest.mock import patch
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.fw_versions import match_fw_to_car
from opendbc.car.hyundai.fingerprints import FW_VERSIONS
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
from opendbc.car.hyundai.hyundaicanfd import CanBus
def params(*, alternate=False, topology='lfa', alpha=False, release=False, adas=False, radar_length=None, hybrid=False):
fp = gen_empty_fingerprint()
if topology == 'lka':
fp[2][0x50] = 16
elif topology == 'lka_alt':
fp[2][0x110] = 32
if hybrid:
fp[0][0xFA] = 8
fp[0][0x1AA if alternate else 0x1CF] = 16 if alternate else 8
if radar_length is not None:
fp[0][0x210] = radar_length
if adas:
fp[2][0xCB] = 24
return CarInterface.get_params(CAR.KIA_EV6_2025, fp, [], alpha, release, False)
class TestEv62025(unittest.TestCase):
def test_stock_profiles_preserve_identity_without_long_or_optional_hardware(self):
for alternate in (False, True):
for alpha in (False, True):
for release in (False, True):
for adas in (False, True):
cp = params(alternate=alternate, alpha=alpha, release=release, adas=adas)
self.assertEqual(cp.carFingerprint, CAR.KIA_EV6_2025)
self.assertEqual(cp.steerControlType, structs.CarParams.SteerControlType.angle)
self.assertEqual(cp.safetyConfigs[-1].safetyParam, 0x7829 if alternate else 0x7809)
self.assertFalse(cp.flags & HyundaiFlags.SEND_LFA)
self.assertFalse(cp.alphaLongitudinalAvailable)
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertTrue(cp.pcmCruise)
self.assertFalse(cp.dashcamOnly)
self.assertEqual((CanBus(cp).ECAN, CanBus(cp).CAM), (0, 2))
self.assertAlmostEqual(cp.wheelbase, 2.9, places=6)
self.assertAlmostEqual(cp.steerRatio, 14.26, places=5)
ci = CarInterface(cp)
self.assertEqual(ci.CS.accelerator_msg_canfd, 'ACCELERATOR')
self.assertEqual(ci.can_parsers[Bus.cam].message_states[0x1A0].frequency, 50)
self.assertTrue(ci.can_parsers[Bus.pt].message_states[0x2E0].ignore_alive)
with patch('opendbc.car.hyundai.interface.disable_ecu') as disable:
ci.init(cp, list, lambda _: None)
disable.assert_not_called()
def test_contradictory_lka_topology_has_no_output_or_diagnostic_transaction(self):
for topology, hybrid in (('lka', False), ('lka_alt', False), ('lfa', True)):
cp = params(topology=topology, hybrid=hybrid, alpha=True)
self.assertTrue(cp.dashcamOnly)
self.assertEqual(cp.safetyConfigs[-1].safetyModel, structs.CarParams.SafetyModel.noOutput)
self.assertFalse(cp.openpilotLongitudinalControl)
ci = CarInterface(cp)
control = structs.CarControl()
control.enabled = control.latActive = control.longActive = True
control.cruiseControl.cancel = control.cruiseControl.resume = True
_, frames = ci.CC.update(control.as_reader(), ci.CS, 1_000_000_000)
self.assertEqual(frames, [])
with patch('opendbc.car.hyundai.interface.disable_ecu') as disable:
ci.init(cp, list, lambda _: None)
disable.assert_not_called()
def test_mrr30_real_dbc_layout_units_and_cycle_freshness(self):
from opendbc.can import CANPacker
from opendbc.car.hyundai.radar_interface import RadarInterface
for length, available in ((None, False), (8, False), (24, False), (32, True)):
self.assertEqual(not params(radar_length=length).radarUnavailable, available)
cp = params(radar_length=32)
radar = RadarInterface(cp)
self.assertEqual(radar.rcp.bus, 0)
self.assertEqual((radar.start_addr, radar.msg_count), (0x210, 16))
packer = CANPacker(DBC[CAR.KIA_EV6_2025][Bus.radar])
frames = [packer.make_can_msg(f'RADAR_TRACK_{addr:x}', 0, {}) for addr in range(0x210, 0x220)]
frames[0] = packer.make_can_msg('RADAR_TRACK_210', 0, {
'1_STATE': 3, '1_LONG_DIST': 42.5, '1_LAT_DIST': -1.25, '1_REL_SPEED': -2.5,
'2_STATE': 4, '2_LONG_DIST': 18.0, '2_LAT_DIST': 1.5, '2_REL_SPEED': 3.25,
})
self.assertEqual(len(frames[0][1]), 32)
self.assertIsNone(radar.update((1_000_000_000, [(addr, data, 1) for addr, data, _ in frames])))
self.assertIsNone(radar.update((1_010_000_000, [(addr, data[:24], bus) for addr, data, bus in frames])))
result = radar.update((1_020_000_000, frames))
self.assertFalse(result.errors.canError)
self.assertEqual(len(result.points), 2)
self.assertAlmostEqual(result.points[0].dRel, 42.5)
self.assertAlmostEqual(result.points[0].yRel, -1.25)
self.assertAlmostEqual(result.points[0].vRel, -2.5)
self.assertAlmostEqual(result.points[1].dRel, 18.0)
self.assertAlmostEqual(result.points[1].yRel, 1.5)
self.assertAlmostEqual(result.points[1].vRel, 3.25)
self.assertEqual(len(radar.update((1_040_000_000, [frames[-1]])).points), 0)
self.assertIsNone(radar.update((1_050_000_000, [frames[0]])))
self.assertEqual(len(radar.update((1_100_000_000, [frames[-1]])).points), 0)
def test_original_camera_radar_versions_identify_distinct_vehicle(self):
entries = FW_VERSIONS[CAR.KIA_EV6_2025]
self.assertEqual(sum(map(len, entries.values())), 4)
for radar in entries[(structs.CarParams.Ecu.fwdRadar, 0x7d0, None)]:
for camera in entries[(structs.CarParams.Ecu.fwdCamera, 0x7c4, None)]:
fw = []
for ecu, address, version in ((structs.CarParams.Ecu.fwdRadar, 0x7d0, radar),
(structs.CarParams.Ecu.fwdCamera, 0x7c4, camera)):
fw.append(structs.CarParams.CarFw(ecu=ecu, address=address, fwVersion=version, brand='hyundai'))
_, matched = match_fw_to_car(fw, '', log=False)
self.assertEqual(matched, {CAR.KIA_EV6_2025})
self.assertEqual(DBC[CAR.KIA_EV6_2025][Bus.radar], 'hyundai_mrr30_radar_generated')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,77 @@
"""Captured EV6 status integrity and pinned-original counter/Drive recipe."""
from dataclasses import FrozenInstanceError
import unittest
from opendbc.car.hyundai.ev6_template import EV6Template
# Retained original EV6 factory fixture; no new physical capture claimed.
CAPTURE = bytes.fromhex('88ed2e091700ffff5e0d0000012006ff021c2200000000000800000010000000')
def independent_crc(data):
crc = 0
for byte in bytes(data)[2:] + bytes((0x51, 0)):
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ (0x1021 if crc & 0x8000 else 0)) & 0xFFFF
return crc ^ 0x9F5B
def valid_capture(counter, body=CAPTURE[3:]):
data = bytearray(bytes(2) + bytes((counter,)) + body)
data[:2] = independent_crc(data).to_bytes(2, 'little')
return bytes(data)
class TestEV6Template(unittest.TestCase):
def test_retained_capture_is_crc_valid_and_immutable(self):
self.assertEqual(independent_crc(CAPTURE), int.from_bytes(CAPTURE[:2], 'little'))
template = EV6Template.capture(CAPTURE)
self.assertEqual(template.counter, 46)
self.assertEqual(template.data, CAPTURE)
with self.assertRaises(FrozenInstanceError):
template.data = bytes(32)
def test_constructor_and_capture_reject_invalid_or_empty_body(self):
corrupt = bytearray(CAPTURE)
corrupt[12] ^= 1
for data in (b'', CAPTURE[:-1], CAPTURE + b'\x00', bytearray(CAPTURE), bytes(corrupt),
valid_capture(46, bytes(29))):
for constructor in (EV6Template, EV6Template.capture):
with self.subTest(size=len(data), constructor=constructor):
with self.assertRaises(ValueError):
constructor(data)
def test_counter_wrap_physical_drive_and_opaque_bytes_match_original(self):
for counter in (0, 1, 46, 254, 255):
source = valid_capture(counter)
template = EV6Template.capture(source)
for frame in (0, 1, 7, 255, 256, 511, 65535):
for drive in (False, True):
with self.subTest(counter=counter, frame=frame, drive=drive):
packet = template.frame(frame, drive)
expected = bytearray(source)
expected[2] = (counter + frame + 1) & 255
expected[3] = (source[3] & ~1) | int(drive)
expected[:2] = independent_crc(expected).to_bytes(2, 'little')
self.assertEqual(packet, (0x51, bytes(expected), 0))
self.assertEqual(packet.dat[4:], source[4:])
self.assertEqual(template.data, source)
def test_instances_and_repeated_calls_do_not_share_mutable_state(self):
first = EV6Template.capture(CAPTURE)
second = EV6Template.capture(valid_capture(255))
expected = first.frame(7, True)
second.frame(255, False)
first.frame(300, False)
self.assertEqual(first.frame(7, True), expected)
self.assertEqual(second.counter, 255)
self.assertEqual(first.counter, 46)
def test_invalid_frame_drive_and_bus_are_not_coerced(self):
template = EV6Template.capture(CAPTURE)
for frame, drive, bus in ((-1, False, 0), (True, False, 0), (.5, False, 0),
(0, 1, 0), (0, False, 1), (0, False, False)):
with self.subTest(frame=frame, drive=drive, bus=bus):
with self.assertRaises(ValueError):
template.frame(frame, drive, bus)
@@ -0,0 +1,50 @@
"""Startup clock/sender checks without threads, receive callbacks or sleeps."""
import unittest
from opendbc.car.hyundai.ev9_keeper import EV9Keeper
class TestEV9Keeper(unittest.TestCase):
def test_original_one_hz_cadence_and_software_deadline(self):
now, sent = [0.0], []
keeper = EV9Keeper(lambda: sent.append(now[0]), clock=lambda: now[0])
for value in (0.0, 0.2, 0.999, 1.0, 1.1, 2.0):
now[0] = value
self.assertTrue(keeper.tick())
self.assertEqual(sent, [0.0, 1.0, 2.0])
now[0] = 60.0
self.assertFalse(keeper.tick())
self.assertIsNotNone(keeper.abort_reason)
self.assertEqual(sent, [0.0, 1.0, 2.0])
def test_send_failure_only_marks_abort_no_receive_or_restore(self):
calls = []
def send():
calls.append('send')
raise OSError('transport')
keeper = EV9Keeper(send, clock=lambda: 0.0)
self.assertFalse(keeper.tick())
self.assertEqual(calls, ['send'])
self.assertEqual(keeper.abort_reason, 'startup tester send failed')
self.assertFalse(keeper.tick())
self.assertEqual(calls, ['send'])
def test_stop_sets_event_before_join_without_sender_lock(self):
operations = []
class Event:
stopped = False
def is_set(self):
return self.stopped
def set(self):
self.stopped = True
operations.append('stop')
event = Event()
keeper = EV9Keeper(lambda: None, clock=lambda: 0.0, event=event)
class Thread:
def join(self):
if not event.stopped:
raise AssertionError('join before event')
operations.append('join')
keeper.thread = Thread()
keeper.stop()
self.assertEqual(operations, ['stop', 'join'])
self.assertFalse(keeper.tick())
@@ -0,0 +1,194 @@
"""EV9 optional LONG actual parser/controller and reached-cadence boundaries."""
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, structs
from opendbc.car.hyundai.ev9_longitudinal import candidate, qualified, EV9LongitudinalPolicy, LongCtrlState
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.tests.test_ioniq5pe_stock import params
from opendbc.car.hyundai.values import CAR, DBC
def packets(packer, tick, *, fault=0, moving=True, gear=5, cruise=True, advance_counter=False, cruise_button=0):
values = {
'ACCELERATOR': (100, {'GEAR': gear, 'ACCELERATOR_PEDAL': 0}),
'TCS': (50, {'ACCEnable': 0, 'ACC_REQ': int(cruise), 'DriverBraking': 0}),
'WHEEL_SPEEDS': (100, dict.fromkeys(('WHL_SpdFLVal','WHL_SpdFRVal','WHL_SpdRLVal','WHL_SpdRRVal'), 36. if moving else 0.)),
'MDPS': (100, {'MDPS_ADAS_AciFltSig_Lv2': fault}),
'STEERING_SENSORS': (100, {}),
'DOORS_SEATBELTS': (10, {'DRIVER_SEATBELT': 1}),
'BLINKERS': (10, {}),
'CRUISE_BUTTONS': (50, {'CRUISE_BUTTONS': cruise_button}),
}
frames = [packer.make_can_msg(name, 1, {**fields, **(
{"COUNTER": tick // (100 // hz)} if advance_counter and name not in ("DOORS_SEATBELTS", "BLINKERS") else {})})
for name, (hz, fields) in values.items()
if tick % (100 // hz) == 0]
# Optional stock camera receive frames remain available on CAM2; no active
# steering source is inserted on ACAN0 where relay detection checks ownership.
frames.append(packer.make_can_msg('LKAS_ALT', 2, {}))
if tick % 5 == 0:
frames.append(packer.make_can_msg('CAM_0x362', 2, {}))
assert all(address != 0x1a0 and not 0x3a5 <= address <= 0x3c4 for address, _, _ in frames)
return frames
def update(ci, packer, tick, **kwargs):
return ci.update([(tick * 10_000_000, packets(packer, tick, **kwargs))])
class TestEV9LongController(unittest.TestCase):
def instance(self):
cp = candidate(params(candidate=CAR.KIA_EV9), enabled=True, is_release=False)
self.assertTrue(qualified(cp))
ci = CarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
ci.update([])
for tick in range(1, 13):
update(ci, packer, tick)
self.assertTrue(ci.CS.out.canValid)
return ci, packer
def test_original_calibration_20hz_and_stop_latch_50hz(self):
policy = EV9LongitudinalPolicy()
first = policy.update(0, 1.0, 0.0, 0.0, LongCtrlState.starting, True, False)
self.assertGreater(first, 0)
for frame in range(1, 5):
self.assertEqual(policy.update(frame, 1.0, 0.0, 0.0, LongCtrlState.starting, True, False), first)
self.assertGreater(policy.update(5, 1.0, 0.0, 0.0, LongCtrlState.starting, True, False), first)
policy.update_stop(True, False, True, 0.47)
for sample in range(1, 179):
policy.update_stop(True, False, True, 0.47)
self.assertEqual(policy.stop.cruise_standstill, sample >= 178)
for sample in range(1, 8):
policy.update_stop(True, False, False, 0.2)
self.assertEqual(policy.stop.stop_request, sample <= 6)
self.assertFalse(policy.stop.cruise_standstill)
def test_actual_mdps_byte16_and_original_mask2(self):
ci, packer = self.instance()
for tick, fault in enumerate((0, 1, 2, 4), 13):
update(ci, packer, tick, fault=fault)
ci.update([((tick * 10_000_000) + 1, [packer.make_can_msg("MDPS", 1,
{"MDPS_EstStrAnglVal": 12.3, "MDPS_PaStrAnglVal": -45.6, "MDPS_ADAS_AciFltSig_Lv2": fault})])])
self.assertAlmostEqual(ci.CS.angle_steering_angle, 12.3)
self.assertEqual(ci.CS.angle_steering_fault, bool(fault & 2))
self.assertEqual(ci.CS.out.steerFaultTemporary, bool(fault & 2))
def test_actual_controller_cb_only_and_adrv_cadence(self):
ci, _ = self.instance()
cc = structs.CarControl(enabled=True, latActive=True, longActive=True)
cc.actuators.steeringAngleDeg = 10.0
cc.actuators.accel = 1.0
cc.actuators.longControlState = LongCtrlState.pid
cc.hudControl.setSpeed = 20.0
for frame in range(101):
_, packets = ci.apply(cc.as_reader(), 1_000_000_000 + frame * 10_000_000)
ids = [p[0] for p in packets]
self.assertNotIn(0x110, ids)
self.assertNotIn(0x51, ids)
self.assertEqual(ids.count(0xcb), 1)
self.assertEqual(ids.count(0x100), 1)
for address, period in ((0x160, 2), (0x1da, 100), (0x1ea, 5), (0x200, 5), (0x345, 20),
(0x1e0, 5), (0x38c, 20), (0x161, 5), (0x162, 5), (0x1ba, 5), (0x1e5, 5),
(0x1a0, 2), (0x362, 5)):
self.assertEqual(ids.count(address), int(frame % period == 0), (frame, hex(address)))
cb = next(p for p in packets if p[0] == 0xcb)
self.assertEqual(cb[2], 1)
self.assertEqual((cb[1][3] >> 4) & 0xf, 2)
def test_nondrive_neutral_lfa_cb_and_emitted_acceleration_bound(self):
ci, _ = self.instance()
cc = structs.CarControl(enabled=True, latActive=True, longActive=True)
cc.actuators.longControlState = LongCtrlState.off
cc.actuators.accel = 3.5
ci.CS.out.gearShifter = structs.CarState.GearShifter.park
ci.CS.angle_steering_angle = 10.0
_, packets = ci.apply(cc.as_reader(), 1_000_000_000)
ids = [p[0] for p in packets]
self.assertEqual(ids[:2], [0x730, 0x12a])
self.assertNotIn(0x110, ids)
self.assertNotIn(0x362, ids)
cb = next(p for p in packets if p[0] == 0xcb)
self.assertEqual((cb[1][3] >> 4) & 0xf, 1)
self.assertEqual(cb[1][6], 0)
self.assertEqual(ci.CC.accel_last, 2.2)
def test_exact_ev9_pid_ceiling_and_sibling_limits_unchanged(self):
stock = params(candidate=CAR.KIA_EV9)
long_cp = candidate(stock, enabled=True, is_release=False)
for speed in (0., 10., 30.):
self.assertEqual(CarInterface.get_pid_accel_limits(long_cp, speed, 20.), (-3.5, 2.2))
self.assertEqual(CarInterface.get_pid_accel_limits(stock, speed, 20.), (-3.5, 2.0))
for sibling in (CAR.HYUNDAI_IONIQ_5_PE, CAR.HYUNDAI_IONIQ_6):
self.assertEqual(CarInterface.get_pid_accel_limits(params(candidate=sibling), speed, 20.), (-3.5, 2.0))
changed = long_cp.as_reader().as_builder()
changed.safetyConfigs[0].safetyParam = 0x5c91
self.assertEqual(CarInterface.get_pid_accel_limits(changed, 0., 20.), (-3.5, 2.0))
def test_stale_lateral_command_cannot_bypass_native_state_gates(self):
ci, _ = self.instance()
cc = structs.CarControl(enabled=True, latActive=True, longActive=True)
cc.actuators.steeringAngleDeg = 10.
cc.actuators.longControlState = LongCtrlState.pid
cc.actuators.accel = 0.
for index, (field, value) in enumerate((('standstill', True), ('gasPressed', True), ('brakePressed', True),
('steerFaultTemporary', True), ('steerFaultPermanent', True),
('canTimeout', True), ('canValid', False),
('gearShifter', structs.CarState.GearShifter.park))):
with self.subTest(field=field):
previous = getattr(ci.CS.out, field)
setattr(ci.CS.out, field, value)
ci.CC.frame = 5 * index
_, packets = ci.apply(cc.as_reader(), 1_000_000_000 + index * 50_000_000)
ids = [packet[0] for packet in packets]
if field in ('canTimeout', 'canValid'):
self.assertNotIn(0xcb, ids)
self.assertNotIn(0x12a, ids)
else:
cb = next(packet for packet in packets if packet[0] == 0xcb)
self.assertEqual((cb[1][3] >> 4) & 0xf, 1)
self.assertEqual(cb[1][6], 0)
self.assertNotIn(0x362, ids)
setattr(ci.CS.out, field, previous)
cc.enabled = False
ci.CC.frame = 50
_, packets = ci.apply(cc.as_reader(), 2_000_000_000)
cb = next(packet for packet in packets if packet[0] == 0xcb)
self.assertEqual((cb[1][3] >> 4) & 0xf, 1)
self.assertEqual(cb[1][6], 0)
self.assertNotIn(0x362, [packet[0] for packet in packets])
def test_advertised_stock_availability_still_requires_startup_choice(self):
for alpha in (False, True):
stock = params(candidate=CAR.KIA_EV9, alpha=alpha)
self.assertTrue(stock.alphaLongitudinalAvailable)
self.assertFalse(stock.openpilotLongitudinalControl)
self.assertEqual(stock.safetyConfigs[0].safetyParam, 0x5c91)
not_requested = candidate(stock, enabled=False, is_release=False)
self.assertIs(not_requested, stock)
selected = candidate(stock, enabled=True, is_release=False)
self.assertTrue(qualified(selected))
self.assertEqual(selected.safetyConfigs[0].safetyParam, 0x5c95)
self.assertEqual(stock.safetyConfigs[0].safetyParam, 0x5c91)
self.assertFalse(stock.openpilotLongitudinalControl)
release_stock = params(candidate=CAR.KIA_EV9, alpha=alpha, release=True)
self.assertFalse(release_stock.alphaLongitudinalAvailable)
self.assertIs(candidate(release_stock, enabled=True, is_release=True), release_stock)
def test_long_healthy_without_stock_scc_but_stock_still_requires_scc(self):
for selected in (False, True):
stock = params(candidate=CAR.KIA_EV9)
cp = candidate(stock, enabled=selected, is_release=False)
ci = CarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
ci.update([])
subscribed = ci.can_parsers[Bus.pt].addresses
self.assertEqual(0x1a0 in subscribed, not selected)
for tick in range(1, 101):
frames = packets(packer, tick)
self.assertNotIn(0x1a0, [address for address, _, _ in frames])
ci.update([(tick * 10_000_000, frames)])
self.assertEqual(ci.CS.out.canValid, selected)
if selected:
self.assertFalse(ci.CS.out.canTimeout)
self.assertTrue(ci.CS.out.cruiseState.available)
self.assertTrue(ci.CS.out.cruiseState.enabled)
@@ -0,0 +1,36 @@
"""Original EV9 reached-wire cases; ordinary ownership is tested separately."""
import unittest
from types import SimpleNamespace
from opendbc.can import CANPacker
from opendbc.car.hyundai.hyundaicanfd import create_angle_steering_messages
from opendbc.car.hyundai.values import CAR, HyundaiFlags
class TestEV9Sender(unittest.TestCase):
@staticmethod
def frame(candidate, angle, status):
flags = HyundaiFlags.CANFD_ANGLE_STEERING | HyundaiFlags.CANFD_LKA_STEER_MSG | HyundaiFlags.CANFD_LKA_STEER_MSG_ALT
cp = SimpleNamespace(carFingerprint=candidate, flags=flags, openpilotLongitudinalControl=False)
packer = CANPacker('hyundai_canfd_generated')
packer.counters[0x110] = 37
return create_angle_steering_messages(packer, cp, SimpleNamespace(ACAN=0, ECAN=1),
True, True, angle, 0.5, status)[0]
def test_original_ev9_signed_half_step_quantization(self):
# Original DBC packer uses floor(angle / 0.1 + 0.5), not ties-to-even.
for angle, units in ((0.05, 1), (-0.15, -1), (5.0, 50), (-12.3, -123)):
with self.subTest(angle=angle):
address, data, bus = self.frame(CAR.KIA_EV9, angle, {})
raw = (data[11] << 6) | (data[10] >> 2)
signed = raw if raw < 8192 else raw - 16384
self.assertEqual((address, bus, data[2], signed), (0x110, 0, 37, units))
def test_ev9_active_status_reset_preserves_sibling_status_behavior(self):
stock = {'LKA_LHLnWrnSta': 2, 'LKA_RHLnWrnSta': 3, 'ToiFltSta': 2, 'LKA_UsmMod': 3}
_, ev9, _ = self.frame(CAR.KIA_EV9, 5.0, stock)
_, sibling, _ = self.frame(CAR.HYUNDAI_IONIQ_5_N, 5.0, stock)
# Source-derived original active body resets these fields to zero.
for data, expected in ((ev9, (0, 0, 0, 0)), (sibling, (2, 3, 2, 3))):
actual = ((data[3] >> 6) & 3, data[4] & 3, (data[6] >> 6) & 3, data[10] & 3)
self.assertEqual(actual, expected)
@@ -0,0 +1,134 @@
"""Exact EV9 stock-SCC metadata, decoder and ordinary request boundary."""
import math
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, structs
from opendbc.car.hyundai.ccnc_ev_stock import qualified, replacement_requested
from opendbc.car.hyundai.fingerprints import FW_VERSIONS
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.tests.test_ioniq5pe_stock import params, update
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
from opendbc.car.interfaces import get_torque_params
class TestEV9Stock(unittest.TestCase):
def test_original_metadata_and_constructor(self):
cfg = CAR.KIA_EV9.config
self.assertEqual((cfg.specs.mass, cfg.specs.wheelbase, cfg.specs.steerRatio), (2664, 3.1, 16))
self.assertEqual(cfg.flags, HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_ANGLE_STEERING | HyundaiFlags.CCNC)
self.assertEqual(cfg.dbc_dict[Bus.radar], "hyundai_mrr35_radar_generated")
tune = get_torque_params()[CAR.KIA_EV9]
self.assertEqual(tune["MAX_LAT_ACCEL_MEASURED"], 2.5)
self.assertTrue(math.isnan(tune["LAT_ACCEL_FACTOR"]))
self.assertEqual(CarInterface.get_std_params(CAR.KIA_EV9).maxLateralAccel, 2.5)
self.assertEqual(set(FW_VERSIONS[CAR.KIA_EV9]), {(structs.CarParams.Ecu.fwdCamera, 0x7c4, None),
(structs.CarParams.Ecu.fwdRadar, 0x7d0, None)})
def test_exact_stock_topology_and_no_automatic_long(self):
for alpha in (False, True):
for release in (False, True):
with self.subTest(alpha=alpha, release=release):
cp = params(candidate=CAR.KIA_EV9, alpha=alpha, release=release)
self.assertTrue(qualified(cp))
self.assertEqual(cp.safetyConfigs[0].safetyParam, 0x5c91)
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertTrue(cp.pcmCruise)
self.assertEqual(cp.alphaLongitudinalAvailable, not release)
self.assertFalse(cp.steerAtStandstill)
self.assertEqual(cp.longitudinalActuatorDelay, 0.5)
for alpha in (False, True):
for missing in (0x35, 0x110, 0x362, 0x1cf, 0x1a0):
cp = params(candidate=CAR.KIA_EV9, missing=missing, alpha=alpha)
self.assertFalse(qualified(cp))
self.assertFalse(cp.alphaLongitudinalAvailable)
self.assertFalse(cp.openpilotLongitudinalControl)
for topology in ("lka", "lfa"):
cp = params(candidate=CAR.KIA_EV9, topology=topology, alpha=alpha)
self.assertFalse(qualified(cp))
self.assertFalse(cp.alphaLongitudinalAvailable)
self.assertFalse(cp.openpilotLongitudinalControl)
for release in (False, True):
pe = params(alpha=alpha, release=release)
self.assertFalse(pe.alphaLongitudinalAvailable)
self.assertFalse(pe.openpilotLongitudinalControl)
self.assertEqual(pe.safetyConfigs[0].safetyParam, 0x5491)
def test_actual_ev_decoder_and_all_lv2_faults(self):
cp = params(candidate=CAR.KIA_EV9)
ci = CarInterface(cp)
self.assertEqual(ci.CS.gear_msg_canfd, "ACCELERATOR")
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
ci.update([])
for tick in range(1, 13):
state = update(ci, packer, tick)
self.assertTrue(state.canValid)
self.assertEqual(state.gearShifter, structs.CarState.GearShifter.drive)
for fault in range(1, 8):
state = update(ci, packer, 12 + fault, fault=fault)
self.assertTrue(state.steerFaultTemporary)
def test_normal_lateral_request_releases_at_standstill_and_fault(self):
cp = params(candidate=CAR.KIA_EV9)
state = structs.CarState(canValid=True, gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.enabled = True
control = structs.CarControl(enabled=True, latActive=True)
self.assertTrue(replacement_requested(cp, control, state))
for field in ("standstill", "brakePressed", "gasPressed", "steerFaultTemporary", "canTimeout"):
setattr(state, field, True)
self.assertFalse(replacement_requested(cp, control, state))
setattr(state, field, False)
control.latActive = False
self.assertFalse(replacement_requested(cp, control, state))
def test_actual_state_fault_and_ordered_replacement_recipe(self):
cp = params(candidate=CAR.KIA_EV9)
ci = CarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
ci.update([])
command = structs.CarControl()
command.enabled = command.latActive = True
command.actuators.steeringAngleDeg = 5.0
for warmup in range(1, 13):
state = update(ci, packer, warmup)
self.assertTrue(state.canValid)
self.assertFalse(state.canTimeout)
tick = 0
for fault, moving, gear, cruise, active in [(0, True, 5, True, True),
*[(value, True, 5, True, True) for value in range(1, 8)],
(0, False, 5, True, True), (0, True, 7, True, True),
(0, True, 5, False, True), (0, True, 5, True, False),
(0, True, 5, True, True)]:
with self.subTest(fault=fault, moving=moving, gear=gear, cruise=cruise, active=active):
command.latActive = active
state = update(ci, packer, tick + 13, fault=fault, moving=moving, gear=gear, cruise=cruise)
self.assertEqual(state.steerFaultTemporary, fault != 0)
self.assertEqual(state.standstill, not moving)
expected = fault == 0 and moving and gear == 5 and cruise and active
self.assertEqual(bool(replacement_requested(cp, command, state)), expected)
_, frames = ci.apply(command.as_reader(), (tick + 13) * 10_000_000)
addresses = [frame[0] for frame in frames]
self.assertEqual(0x110 in addresses, expected)
self.assertEqual(0x362 in addresses, expected and tick % 5 == 0)
if 0x362 in addresses:
self.assertLess(addresses.index(0x110), addresses.index(0x362))
tick += 1
def test_original_ev9_optional_corner_and_rear_blindspots(self):
cp = params(candidate=CAR.KIA_EV9)
self.assertTrue(cp.deprecated.enableBsm)
ci = CarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
ci.update([])
for tick in range(1, 13):
state = update(ci, packer, tick)
self.assertTrue(state.canValid) # No optional corner sources in startup.
for tick, (rear_left, rear_right, corner, expected) in enumerate(
((0, 0, 0x10, (True, False)), (0, 0, 0x08, (False, True)),
(1, 0, 0x08, (True, True)), (0, 1, 0, (False, True)), (0, 0, 0, (False, False))), 14):
update(ci, packer, tick)
messages = [packer.make_can_msg("ADAS_CMD_50_50ms", 1, {"BCW_LtIndSta": rear_left, "BCW_RtIndSta": rear_right}),
packer.make_can_msg("BLINDSPOTS_FRONT_CORNER_2", 1, {"SIDE_DETECT_STATE": corner})]
state = ci.update([(tick * 10_000_000 + 1, messages)])
self.assertTrue(state.canValid)
self.assertEqual((state.leftBlindspot, state.rightBlindspot), expected)
@@ -0,0 +1,62 @@
"""Source-derived G90 lead hysteresis and existing CAN encoder fields."""
import unittest
from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus, structs
from opendbc.car.hyundai.g90_lead import G90LeadState, LeadObservation
from opendbc.car.hyundai.hyundaican import create_acc_commands
from opendbc.car.hyundai.tests.test_g90_longitudinal import params
from opendbc.car.hyundai.values import CAR, DBC
def observation(distance=26., relative=-.5, present=True, drive=1):
return LeadObservation(drive, 100, 101, 102, present, distance, relative)
class TestG90Lead(unittest.TestCase):
def test_visibility_gap_and_relative_state_counts_controller_updates(self):
state = G90LeadState()
for _ in range(49):
lead = state.update(observation(), False)
self.assertFalse(lead.lead_visible)
self.assertEqual(lead.object_gap, 0)
lead = state.update(observation(), False)
self.assertTrue(lead.lead_visible)
self.assertEqual((lead.object_gap, lead.object_rel_gap), (4, 2))
for _ in range(49):
lead = state.update(observation(0, 0, False), False)
self.assertTrue(lead.lead_visible)
self.assertFalse(state.update(observation(0, 0, False), False).lead_visible)
state = G90LeadState()
for frame in range(50):
lead = state.update(observation(15 if frame % 2 else 26), False)
self.assertEqual(lead.object_gap, 2) # Original non-current gaps share one counter.
self.assertEqual(state.update(observation(.05, -1), False).lead_distance, 20.)
self.assertEqual(state.update(observation(999, -999), False).lead_rel_speed, -170.)
self.assertEqual(state.update(observation(26, -.2), False).object_rel_gap, 1)
def test_optional_payload_preserves_sibling_and_accel_aeb_bytes(self):
for car, alpha in ((CAR.GENESIS_G90, True), (CAR.GENESIS_G90, False), (CAR.GENESIS_G80, True)):
cp = params(car, alpha)
hud = structs.CarControl.HUDControl(leadVisible=True, leadDistanceBars=2)
state = G90LeadState()
for _ in range(50):
lead = state.update(observation(), False)
def packed(payload, cp=cp, hud=hud):
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
return create_acc_commands(packer, True, .2, 1., 7, hud, 70., False, False, True, cp, lead_data=payload)
baseline, changed = packed(None), packed(lead)
if car != CAR.GENESIS_G90 or not alpha:
self.assertEqual(baseline, changed)
else:
for old, new in zip(baseline, changed, strict=True):
if old[0] in (0x421, 0x38d):
self.assertEqual(old, new)
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('FCA11', 0), ('SCC12', 0)], 0)
parser.update([(1_000_000_000, changed)])
self.assertEqual(parser.vl['FCA11']['FCA_Status'], 1)
non_fca = create_acc_commands(CANPacker(DBC[cp.carFingerprint][Bus.pt]), True, .2, 1., 7,
hud, 70., False, False, False, cp, lead_data=lead)
parser.update([(1_010_000_000, non_fca)])
self.assertEqual(parser.vl['SCC12']['AEB_Status'], 1)
@@ -0,0 +1,71 @@
import unittest
from opendbc.can import CANParser
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.g90_longitudinal import G90LongitudinalPolicy, LongCtrlState
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC
def params(car=CAR.GENESIS_G90, alpha=True, release=False):
return CarInterface.get_params(car, gen_empty_fingerprint(), [], alpha, release, False)
class TestG90Longitudinal(unittest.TestCase):
def test_stop_hold_relaxes_braking_then_release_ramps(self):
policy = G90LongitudinalPolicy()
self.assertAlmostEqual(policy.update(-2, 2, LongCtrlState.stopping, True), -1.4)
self.assertAlmostEqual(policy.update(-2, 0, LongCtrlState.stopping, True), -1.3)
for _ in range(20):
policy.update(-2, 0, LongCtrlState.stopping, True)
self.assertAlmostEqual(policy.actual_accel, -0.1)
self.assertAlmostEqual(policy.update(1, 0, LongCtrlState.pid, True), -0.05)
self.assertTrue(policy.release_active)
self.assertAlmostEqual(policy.update(1, 0, LongCtrlState.pid, True), 0)
self.assertEqual(policy.update(1, 0, LongCtrlState.off, False), 0)
self.assertFalse(policy.release_active)
self.assertEqual(policy.update(1, 0, LongCtrlState.pid, True), 1)
def test_only_g90_with_longitudinal_ownership_uses_policy(self):
for car in (CAR.GENESIS_G90, CAR.GENESIS_G80):
for alpha in (False, True):
for release in (False, True):
with self.subTest(car=car, alpha=alpha, release=release):
cp = params(car, alpha, release)
ci = CarInterface(cp)
self.assertEqual(ci.CC.g90_longitudinal is not None, car == CAR.GENESIS_G90 and cp.openpilotLongitudinalControl)
self.assertEqual(cp.pcmCruise, not cp.openpilotLongitudinalControl)
def test_classic_can_uses_shaped_accel_and_inactive_messages_are_neutral(self):
from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.safety.tests.libsafety.libsafety_py import make_CANPacket
cp = params()
native = libsafety_py.libsafety
self.assertEqual(native.set_safety_hooks(cp.safetyConfigs[-1].safetyModel.raw, cp.safetyConfigs[-1].safetyParam), 0)
native.init_tests()
native.set_controls_allowed(True)
ci = CarInterface(cp)
ci.update([])
parser = CANParser(DBC[CAR.GENESIS_G90][Bus.pt], [("SCC12", 0)], 0)
command = structs.CarControl(enabled=True, longActive=True)
command.hudControl.setSpeed = 20
for frame in range(160):
stopped = frame < 80
command.actuators.longControlState = LongCtrlState.stopping if stopped else LongCtrlState.pid
command.actuators.accel = -2 if stopped else 1
ci.CS.out.vEgo = max(0, 2 - frame * 0.05)
native.set_timer(frame * 10_000)
actuators, messages = ci.CC.update(command.as_reader(), ci.CS, frame * 10_000_000)
for address, data, bus in messages:
self.assertTrue(native.safety_tx_hook(make_CANPacket(address, bus, data)), (frame, address, data))
if frame % 2 == 0:
parser.update((1_000_000_000 + frame * 10_000_000, messages))
self.assertAlmostEqual(parser.vl["SCC12"]["aReqRaw"], actuators.accel, delta=0.011)
self.assertAlmostEqual(parser.vl["SCC12"]["aReqValue"], actuators.accel, delta=0.011)
command.enabled = command.longActive = False
command.actuators.accel = 0
_, messages = ci.CC.update(command.as_reader(), ci.CS, 1_600_000_000)
parser.update((2_600_000_000, messages))
self.assertEqual(parser.vl["SCC12"]["aReqRaw"], 0)
self.assertEqual(parser.vl["SCC12"]["aReqValue"], 0)
@@ -0,0 +1,117 @@
import math
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.radar_interface import RadarInterface
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags, HYUNDAI_G90_RADAR_DBC
from opendbc.dbc.generator.hyundai.hyundai_kia_mando_front_radar import generate
def params(bank=None):
fp = gen_empty_fingerprint()
if bank is not None:
fp[1].update(bank)
return CarInterface.get_params(CAR.GENESIS_G90, fp, [], False, False, False)
def raw_motorola(data, start, size, signed=False):
raw = 0
bit = start
for _ in range(size):
raw = (raw << 1) | ((data[bit // 8] >> (bit % 8)) & 1)
bit = bit - 1 if bit % 8 else bit + 15
return raw - (1 << size) if signed and raw & (1 << (size - 1)) else raw
class TestG90Radar(unittest.TestCase):
def setUp(self):
self.packer = CANPacker(HYUNDAI_G90_RADAR_DBC)
self.bank = dict.fromkeys(range(0x500, 0x540), 8)
def test_readiness_lower_bank_exact_length_and_stable_authority(self):
absent = params()
present = params(self.bank)
self.assertTrue(absent.radarUnavailable)
self.assertFalse(present.radarUnavailable)
self.assertTrue(present.flags & HyundaiFlags.MANDO_RADAR)
self.assertFalse(present.openpilotLongitudinalControl)
self.assertEqual([(cfg.safetyModel, cfg.safetyParam) for cfg in absent.safetyConfigs],
[(cfg.safetyModel, cfg.safetyParam) for cfg in present.safetyConfigs])
lower_bank = dict.fromkeys(range(0x500, 0x520), 8)
self.assertFalse(params(lower_bank).radarUnavailable)
radar = RadarInterface(params(lower_bank))
lower_frames = [self.packer.make_can_msg(f'RADAR_TRACK_{addr:x}', 1, {'STATE': 3, 'LONG_DIST': 10})
for addr in lower_bank]
result = radar.update((1_000_000_000, lower_frames))
self.assertEqual(len(result.points), 32)
self.assertTrue(all(addr < 0x520 for addr in radar.pts))
for address in lower_bank:
bank = lower_bank.copy()
del bank[address]
self.assertTrue(params(bank).radarUnavailable)
bank[address] = 16
self.assertTrue(params(bank).radarUnavailable)
def test_both_halves_all_current_typed_outputs_match_original_bits(self):
radar = RadarInterface(params(self.bank))
self.assertEqual((radar.rcp.bus, radar.start_addr, radar.msg_count, radar.trigger_msg), (1, 0x500, 64, 0x51F))
frames = []
for index, address in enumerate(self.bank):
frames.append(self.packer.make_can_msg(f'RADAR_TRACK_{address:x}', 1, {
'STATE': 3 if index % 2 == 0 else 4, 'LONG_DIST': index + 1,
'AZIMUTH': 10.0 if index % 2 == 0 else -10.0, 'REL_SPEED': -.01 * index,
}))
result = radar.update((1_000_000_000, frames))
self.assertFalse(result.errors.canError)
self.assertEqual(len(result.points), 64)
self.assertEqual(len({point.trackId for point in result.points}), 64)
for point, (_, data, _) in zip(result.points, frames, strict=True):
azimuth = math.radians(raw_motorola(data, 12, 10, True) * .2)
distance = raw_motorola(data, 18, 11) * .1
speed = raw_motorola(data, 53, 14, True) * .01
self.assertAlmostEqual(point.dRel, math.cos(azimuth) * distance, places=5)
self.assertAlmostEqual(point.yRel, -.5 * math.sin(azimuth) * distance, places=5)
self.assertAlmostEqual(point.vRel, speed, places=6)
def test_original_lower_trigger_and_upper_cycle_freshness(self):
radar = RadarInterface(params(self.bank))
lower = [self.packer.make_can_msg(f'RADAR_TRACK_{addr:x}', 1, {}) for addr in range(0x500, 0x520)]
upper = self.packer.make_can_msg('RADAR_TRACK_520', 1, {'STATE': 3, 'LONG_DIST': 20, 'REL_SPEED': -1})
self.assertIsNone(radar.update((1_000_000_000, [(upper[0], upper[1], 0)])))
self.assertIsNone(radar.update((1_010_000_000, [(upper[0], upper[1] * 2, 1)])))
self.assertIsNone(radar.update((1_020_000_000, [(upper[0], upper[1][:4], 1)])))
self.assertEqual(len(radar.update((1_030_000_000, lower)).points), 0)
self.assertIsNone(radar.update((1_040_000_000, [upper])))
result = radar.update((1_050_000_000, lower))
self.assertEqual(len(result.points), 1)
first_id = result.points[0].trackId
# Fresh upper-bank packet never drives cadence by itself.
self.assertIsNone(radar.update((1_060_000_000, [upper])))
self.assertEqual(len(radar.update((1_090_000_000, lower)).points), 0)
self.assertIsNone(radar.update((1_100_000_000, [upper])))
self.assertEqual(len(radar.update((1_110_000_000, lower)).points), 1)
self.assertGreater(radar.pts[0x520].trackId, first_id)
invalid = self.packer.make_can_msg('RADAR_TRACK_520', 1, {'STATE': 2})
radar.update((1_120_000_000, [invalid]))
self.assertEqual(len(radar.update((1_130_000_000, lower)).points), 0)
def test_ordinary_mando_generator_and_parser_unchanged(self):
outputs = generate()
ordinary = outputs['hyundai_kia_mando_front_radar.dbc']
extended = outputs['hyundai_genesis_g90_radar.dbc']
self.assertTrue(extended.startswith(ordinary))
self.assertEqual(ordinary.count('\nBO_ '), 32)
self.assertEqual(extended.count('\nBO_ '), 64)
cars = [car for car in CAR if car != CAR.GENESIS_G90 and
DBC[car].get(Bus.radar) == 'hyundai_kia_mando_front_radar_generated']
self.assertTrue(cars)
for car in cars:
cp = CarInterface.get_params(car, gen_empty_fingerprint(), [], False, False, False)
radar = RadarInterface(cp)
self.assertEqual((radar.rcp.bus, radar.start_addr, radar.msg_count, radar.trigger_msg), (1, 0x500, 32, 0x51F))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,132 @@
"""Actual isolated parser tests using synthetic packed camera CAN evidence."""
import unittest
from unittest.mock import patch
from opendbc.can.packer import CANPacker
from opendbc.car import Bus, CanData, gen_empty_fingerprint, structs
from opendbc.car.hyundai.gv70_camera_lead import GV70CameraLead
from opendbc.car.hyundai.hyundaicanfd import CanBus, hkg_can_fd_checksum
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
def params(lka=True):
fingerprint = gen_empty_fingerprint()
if lka:
fingerprint[2][0x50] = 16
fingerprint[1 if lka else 0][0x1cf] = 8
firmware = [structs.CarParams.CarFw(ecu=structs.CarParams.Ecu.adas)] if lka else []
cp = CarInterface.get_params(CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, fingerprint, firmware, True, False, False)
assert bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG) == lka
return cp
def packet(packer, bus, counter, distance=12., relative=-2.):
address, raw, src = packer.make_can_msg('FR_CMR_03_50ms', bus, {
'FR_CMR_AlvCnt3Val': counter, 'Longitudinal_Distance': distance, 'Relative_Velocity': relative})
data = bytearray(raw)
data[:2] = hkg_can_fd_checksum(address, None, data).to_bytes(2, 'little')
return CanData(address, bytes(data), src)
class TestGV70CameraLead(unittest.TestCase):
def setUp(self):
boot_clock = patch("opendbc.car.hyundai.canfd_camera_lead.time.CLOCK_BOOTTIME", 7, create=True)
boot_clock.start()
self.addCleanup(boot_clock.stop)
self.clock = patch("opendbc.car.hyundai.canfd_camera_lead.time.clock_gettime_ns", return_value=10_000_000_000)
self.clock_mock = self.clock.start()
self.addCleanup(self.clock.stop)
def test_scoped_raw_integrity_units_and_topology(self):
for lka in (True, False):
cp = params(lka)
owner = GV70CameraLead(cp)
buses = CanBus(cp)
self.assertEqual(owner.bus, buses.ECAN if lka else buses.CAM)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
for counter in (1, 2):
owner.update([(counter*50_000_000, [packet(packer, owner.bus, counter)])])
observation = owner.current(100_000_000)
self.assertIsNotNone(observation)
self.assertTrue(observation.visible)
self.assertEqual(observation.distance_m, 12.)
self.assertEqual(observation.relative_speed_mps, -2.)
decoded = owner.parser.vl['FR_CMR_03_50ms']
self.assertNotIn('CHECKSUM', decoded)
self.assertNotIn('COUNTER', decoded)
self.assertEqual(decoded['FR_CMR_AlvCnt3Val'], 2)
self.assertEqual(owner.parser.message_states[0x1b5].counter_fail, 0)
def test_bad_crc_counter_wrong_bus_length_do_not_renew_and_expire(self):
cp = params()
owner = GV70CameraLead(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
owner.update([(50_000_000, [packet(packer, owner.bus, 1)])])
good = owner.current(50_000_000)
self.assertIsNotNone(good)
wrong_crc = bytearray(packet(packer, owner.bus, 2).dat)
wrong_crc[27] ^= 1
owner.update([(100_000_000, [(0x1b5, bytes(wrong_crc), owner.bus)])])
self.assertEqual(owner.current(100_000_000), good)
# Repeated raw counters cannot renew the optional observation.
for tick in range(3, 9):
owner.update([(tick*50_000_000, [packet(packer, owner.bus, 1)])])
self.assertEqual(owner.observation, good)
self.assertIsNone(owner.current(400_000_000))
for bus, data in ((owner.bus+1, packet(packer, owner.bus, 3).dat),
(owner.bus, bytes(31)), (owner.bus, bytes(33))):
owner.update([(450_000_000, [(0x1b5, data, bus)])])
self.assertEqual(owner.observation, good)
owner.update([(500_000_000, [packet(packer, owner.bus, 2)])])
self.assertEqual(owner.current(500_000_000).producer_boot_ns, 500_000_000)
owner.update([(550_000_000, [packet(packer, owner.bus, 4)])])
self.assertEqual(owner.observation.producer_boot_ns, 500_000_000)
owner.update([(600_000_000, [packet(packer, owner.bus, 5)])])
self.assertEqual(owner.current(600_000_000).producer_boot_ns, 600_000_000)
def test_clock_floor_visibility_and_descriptor_scope(self):
cp = params()
owner = GV70CameraLead(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
owner.update([(100_000_000, [packet(packer, owner.bus, 1, .1)])])
self.assertFalse(owner.current(100_000_000).visible)
self.assertIsNone(owner.current(99_999_999))
self.assertIsNone(owner.current(100_000_000, 100_000_000))
self.assertIsNotNone(owner.current(400_000_000))
self.assertIsNone(owner.current(400_000_001))
previous_counter = owner.last_counter
owner.update([(99_999_999, [packet(packer, owner.bus, 2, .15)])])
self.assertEqual(owner.last_counter, previous_counter)
owner.update([(10_000_000_001, [packet(packer, owner.bus, 2, .15)])])
self.assertEqual(owner.last_counter, previous_counter)
self.assertIsNone(owner.current(10_000_000_001))
self.assertEqual(owner.observation.producer_boot_ns, 100_000_000)
with patch("opendbc.car.hyundai.canfd_camera_lead.time.CLOCK_BOOTTIME", None):
owner.update([(200_000_000, [packet(packer, owner.bus, 2, .15)])])
with patch("opendbc.car.hyundai.canfd_camera_lead.time.clock_gettime_ns", side_effect=OSError):
owner.update([(200_000_000, [packet(packer, owner.bus, 2, .15)])])
self.assertEqual(owner.observation.producer_boot_ns, 100_000_000)
self.assertEqual(owner.last_packet_ns, 100_000_000)
cp.openpilotLongitudinalControl = False
with self.assertRaises(ValueError):
GV70CameraLead(cp)
cp.openpilotLongitudinalControl = True
cp.carFingerprint = CAR.KIA_EV6
with self.assertRaises(ValueError):
GV70CameraLead(cp)
def test_isolated_counter_failure_does_not_join_controls_health(self):
cp = params()
ci = CarInterface(cp)
owner = GV70CameraLead(cp)
self.assertNotIn(owner.parser, ci.can_parsers.values())
self.assertTrue(all(0x1b5 not in parser.addresses for parser in ci.can_parsers.values()))
before = [(parser.can_valid, parser.bus_timeout, parser.can_invalid_cnt) for parser in ci.can_parsers.values()]
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
for tick in range(8):
owner.update([((tick+1)*50_000_000, [packet(packer, owner.bus, 1)])])
self.assertEqual(owner.observation.producer_boot_ns, 50_000_000)
after = [(parser.can_valid, parser.bus_timeout, parser.can_invalid_cnt) for parser in ci.can_parsers.values()]
self.assertEqual([x[:2] for x in before], [x[:2] for x in after])
@@ -0,0 +1,123 @@
"""First-generation GV70 demand/cancel policy through actual CAN callers."""
import unittest
from opendbc.can.packer import CANPacker
from opendbc.can.parser import CANParser
from opendbc.car import Bus, CanData, gen_empty_fingerprint, structs
from opendbc.car.hyundai.gv70_longitudinal import scc_request, suppress_stock_cancel
from opendbc.car.hyundai.hyundaicanfd import CanBus, create_acc_control
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
def params(car=CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, alpha=True, *, lka=True, alt=False):
fp = gen_empty_fingerprint()
if lka:
fp[2][0x50] = 16
fp[1 if lka else 0][0x1aa if alt else 0x1cf] = 16 if alt else 8
fw = [structs.CarParams.CarFw(ecu=structs.CarParams.Ecu.adas)] if lka else []
return CarInterface.get_params(car, fp, fw, alpha, False, False)
def update(ci, packer, tick, brake=False):
can = CanBus(ci.CP)
frames = [CanData(*packer.make_can_msg('WHEEL_SPEEDS', can.ECAN,
dict.fromkeys(('WHL_SpdFLVal', 'WHL_SpdFRVal', 'WHL_SpdRLVal', 'WHL_SpdRRVal'), 36.))),
CanData(*packer.make_can_msg('ACCELERATOR', can.ECAN, {'GEAR': 4})),
CanData(*packer.make_can_msg('TCS', can.ECAN, {'DriverBraking': int(brake)})),
CanData(*packer.make_can_msg('CAM_0x2a4', can.CAM, {})),
CanData(*packer.make_can_msg('CRUISE_BUTTONS_ALT', can.ECAN, {})),
CanData(*packer.make_can_msg('SCC_CONTROL', can.CAM if ci.CP.flags & HyundaiFlags.CANFD_CAMERA_SCC else can.ECAN, {}))]
return ci.update([((tick+1)*10_000_000, frames)])
class TestGV70Longitudinal(unittest.TestCase):
def test_demand_thresholds_disable_override_and_shaped_history(self):
for raw, stop, lower in ((-.999, False, 2.), (-1., False, 5.), (.7, True, 5.)):
result = scc_request(True, False, stop, raw, 0.)
self.assertEqual(result.raw_accel, raw)
self.assertEqual(result.jerk_lower, lower)
self.assertEqual(result.jerk_upper, 1.5)
self.assertAlmostEqual(result.accel, min(max(raw, -lower/50.), .03))
for enabled, override in ((False, False), (True, True)):
self.assertEqual(scc_request(enabled, override, False, 1., .5).accel, 0.)
self.assertAlmostEqual(scc_request(True, False, False, 1., .03).accel, .06)
def test_actual_50hz_sender_raw_shaped_jerk_and_disabled_modes(self):
for lka, alt in ((True, False), (False, False), (False, True)):
with self.subTest(lka=lka, alt=alt):
cp = params(lka=lka, alt=alt)
self.assertTrue(cp.openpilotLongitudinalControl)
self.assertEqual(bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG), lka)
self.assertEqual(bool(cp.flags & HyundaiFlags.CANFD_ALT_BUTTONS), alt)
ci = CarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
decoder = CANParser(DBC[cp.carFingerprint][Bus.pt], [('SCC_CONTROL', 50)], CanBus(cp).ECAN)
command = structs.CarControl()
history = 0.
for tick in range(14):
raw, stop, enabled, override = [(1., False, True, False), (-.5, False, True, False),
(-1., False, True, False), (.8, True, True, False),
(.4, False, True, True), (.8, False, False, False),
(1., False, True, False)][tick//2]
command.enabled = enabled
command.cruiseControl.override = override
command.actuators.accel = raw
command.actuators.longControlState = structs.CarControl.Actuators.LongControlState.stopping if stop else \
structs.CarControl.Actuators.LongControlState.pid
update(ci, packer, tick)
_, outputs = ci.apply(command.as_reader(), (tick+1)*10_000_000)
packets = [CanData(*frame) for frame in outputs if frame[0] == 0x1a0]
self.assertEqual(len(packets), int(tick % 2 == 0))
if not packets:
self.assertEqual(ci.CC.accel_last, history)
continue
lower = 5. if stop or raw <= -1. else 2.
history = min(max(raw, history-lower/50.), history+.03) if enabled and not override else 0.
decoder.update([((tick+1)*10_000_000, packets)])
values = decoder.vl['SCC_CONTROL']
self.assertAlmostEqual(values['aReqRaw'], raw if enabled and not override else 0., places=2)
self.assertAlmostEqual(values['aReqValue'], history, places=2)
self.assertEqual(values['JerkUpperLimit'], 1.5)
self.assertEqual(values['JerkLowerLimit'], lower)
self.assertEqual(values['ACCMode'], 0 if not enabled else 2 if override else 1)
self.assertEqual(values['StopReq'], int(stop))
self.assertEqual(values['MainMode_ACC'], 1)
self.assertAlmostEqual(ci.CC.accel_last, history)
def test_stock_brake_cancel_scope_through_actual_controller(self):
for car, brake, lateral, suppress, lka, alt in (
(CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, True, True, True, True, False),
(CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, False, True, False, True, False),
(CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, True, False, False, True, False),
(CAR.KIA_EV6, True, True, False, True, False),
(CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, True, True, True, False, False),
(CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, False, True, False, False, False),
(CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, True, True, True, False, True),
(CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN, False, True, False, False, True)):
cp = params(car, False, lka=lka, alt=alt)
self.assertEqual(bool(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG), lka)
self.assertEqual(bool(cp.flags & HyundaiFlags.CANFD_ALT_BUTTONS), alt)
self.assertEqual(suppress_stock_cancel(cp, brake, lateral), suppress)
ci = CarInterface(cp)
# Non-LKA parsers lazily subscribe TCS on the first actual state update.
# Prime registration before the first observed brake frame.
ci.update([])
packer = CANPacker(DBC[car][Bus.pt])
command = structs.CarControl()
command.cruiseControl.cancel = True
command.latActive = lateral
buttons = []
for tick in range(60):
state = update(ci, packer, tick, brake)
self.assertEqual(state.brakePressed, brake, (car, lka, alt, tick))
_, outputs = ci.apply(command.as_reader(), (tick+1)*10_000_000)
buttons.extend(frame for frame in outputs if frame[0] == (0x1a0 if alt else 0x1cf))
self.assertEqual(bool(buttons), not suppress)
def test_raw_optional_keyword_preserves_generic_and_ioniq_defaults(self):
for car in (CAR.KIA_EV6, CAR.HYUNDAI_IONIQ_6):
cp = params(car)
first, second = CANPacker(DBC[car][Bus.pt]), CANPacker(DBC[car][Bus.pt])
args = (CanBus(cp), True, 0., .5, False, False, 80., structs.CarControl.HUDControl())
self.assertEqual(create_acc_control(first, *args), create_acc_control(second, *args, raw_accel=None))
@@ -0,0 +1,126 @@
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.radar_interface import RadarInterface, get_radar_can_parser
from opendbc.car.hyundai.values import CAR, DBC, HYUNDAI_GV70_RADAR_DBC, HYUNDAI_MRR35_RADAR_DBC
CAR_ID = CAR.GENESIS_GV70_ELECTRIFIED_1ST_GEN
def params(bank=None):
fp = gen_empty_fingerprint()
fp[0][0x1CF] = 8
if bank is not None:
fp[0].update(bank)
return CarInterface.get_params(CAR_ID, fp, [], False, False, False)
class TestGv70Radar(unittest.TestCase):
def test_complete_exact_bank_required_without_changing_controls(self):
complete = dict.fromkeys(range(0x210, 0x220), 32)
absent = params()
active = params(complete)
self.assertTrue(absent.radarUnavailable)
self.assertFalse(active.radarUnavailable)
self.assertEqual(absent.flags, active.flags)
self.assertEqual([(cfg.safetyModel, cfg.safetyParam) for cfg in absent.safetyConfigs],
[(cfg.safetyModel, cfg.safetyParam) for cfg in active.safetyConfigs])
self.assertEqual(absent.openpilotLongitudinalControl, active.openpilotLongitudinalControl)
for address in complete:
bank = complete.copy()
del bank[address]
self.assertTrue(params(bank).radarUnavailable)
bank[address] = 24
self.assertTrue(params(bank).radarUnavailable)
self.assertEqual(DBC[CAR_ID][Bus.radar], HYUNDAI_GV70_RADAR_DBC)
self.assertEqual(DBC[CAR.GENESIS_GV70_ELECTRIFIED_2ND_GEN][Bus.radar], HYUNDAI_MRR35_RADAR_DBC)
def test_existing_non_gv70_radar_parser_bus_and_bank_unchanged(self):
expected = {
'hyundai_kia_mando_front_radar_generated': (1, 0x500, 32),
'hyundai_mrrevo14f_radar_generated': (1, 0x602, 16),
'hyundai_mrr30_radar_generated': (0, 0x210, 16),
'hyundai_mrr35_radar_generated': (0, 0x3A5, 32),
}
checked = set()
for car in CAR:
dbc = DBC[car].get(Bus.radar)
if dbc not in expected or dbc in checked:
continue
cp = CarInterface.get_params(car, gen_empty_fingerprint(), [], False, False, False)
radar = RadarInterface(cp)
parser = get_radar_can_parser(cp)
bus, start, count = expected[dbc]
if car == CAR.HYUNDAI_IONIQ_6:
continue
self.assertEqual((parser.bus, radar.start_addr, radar.msg_count), (bus, start, count))
checked.add(dbc)
self.assertEqual(checked, set(expected))
def test_all_32_original_target_slots_remain_distinct(self):
radar = RadarInterface(params(dict.fromkeys(range(0x210, 0x220), 32)))
packer = CANPacker(HYUNDAI_GV70_RADAR_DBC)
frames = []
for ordinal, addr in enumerate(range(0x210, 0x220)):
values = {}
for slot in (1, 2):
target = ordinal * 2 + slot
values.update({f'{slot}_STATE': 3 if slot == 1 else 4,
f'{slot}_LONG_DIST': target, f'{slot}_LAT_DIST': -.05 * target,
f'{slot}_REL_SPEED': -.01 * target})
frames.append(packer.make_can_msg(f'RADAR_TRACK_{addr:x}', 0, values))
result = radar.update((1_000_000_000, frames))
self.assertFalse(result.errors.canError)
self.assertEqual(len(result.points), 32)
self.assertEqual(len({point.trackId for point in result.points}), 32)
for ordinal, point in enumerate(result.points, 1):
self.assertAlmostEqual(point.dRel, ordinal)
self.assertAlmostEqual(point.yRel, -.05 * ordinal, places=6)
self.assertAlmostEqual(point.vRel, -.01 * ordinal, places=6)
def test_original_layout_to_current_typed_points_and_freshness(self):
radar = RadarInterface(params(dict.fromkeys(range(0x210, 0x220), 32)))
self.assertEqual((radar.rcp.bus, radar.start_addr, radar.msg_count), (0, 0x210, 16))
packer = CANPacker(HYUNDAI_GV70_RADAR_DBC)
frames = [packer.make_can_msg(f'RADAR_TRACK_{addr:x}', 0, {}) for addr in range(0x210, 0x220)]
frames[0] = packer.make_can_msg('RADAR_TRACK_210', 0, {
'1_STATE': 3, '1_LONG_DIST': 42.5, '1_LAT_DIST': -1.25, '1_REL_SPEED': -2.5,
'2_STATE': 4, '2_LONG_DIST': 18.0, '2_LAT_DIST': 1.5, '2_REL_SPEED': 3.25,
})
data = frames[0][1]
self.assertEqual(len(data), 32)
# Independent raw extraction of original bit54/182 Motorola state and
# little-endian bit64/192 distance, bit76/204 signed lateral, bit88/216 speed.
expected = []
for offset in (0, 16):
self.assertEqual((data[offset + 6] >> 4) & 7, 3 if offset == 0 else 4)
raw = int.from_bytes(data[offset + 8:offset + 13], 'little')
distance = (raw & 0xFFF) * .05
lateral = (raw >> 12) & 0xFFF
speed = (raw >> 24) & 0x3FFF
expected.append((distance, (lateral - 4096 if lateral & 2048 else lateral) * .05,
(speed - 16384 if speed & 8192 else speed) * .01))
self.assertIsNone(radar.update((1_000_000_000, [(addr, dat, 1) for addr, dat, _ in frames])))
self.assertIsNone(radar.update((1_010_000_000, [(addr, dat[:24], bus) for addr, dat, bus in frames])))
result = radar.update((1_020_000_000, frames))
self.assertEqual(len(result.points), 2)
ids = [point.trackId for point in result.points]
for point, values in zip(result.points, expected, strict=True):
self.assertAlmostEqual(point.dRel, values[0])
self.assertAlmostEqual(point.yRel, values[1])
self.assertAlmostEqual(point.vRel, values[2])
result = radar.update((1_040_000_000, frames))
self.assertEqual([point.trackId for point in result.points], ids)
self.assertEqual(len(radar.update((1_090_000_000, [frames[-1]])).points), 0)
self.assertIsNone(radar.update((1_100_000_000, [frames[0]])))
self.assertEqual(len(radar.update((1_160_000_000, [frames[-1]])).points), 0)
# Valid→invalid must remove tracks, never retain an earlier target.
radar.update((1_200_000_000, frames))
frames[0] = packer.make_can_msg('RADAR_TRACK_210', 0, {'1_STATE': 2, '2_STATE': 7})
self.assertEqual(len(radar.update((1_220_000_000, frames)).points), 0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,70 @@
"""Synthetic CRC-valid fixtures qualify arithmetic, not GV70 hardware provenance."""
from dataclasses import FrozenInstanceError
import math
import unittest
from opendbc.car.hyundai.ev6_template import EV6Template
from opendbc.car.hyundai.gv70_template import GV70Template
def crc(data):
value = 0
for byte in data[2:] + bytes((0x51, 0)):
value ^= byte << 8
for _ in range(8):
value = ((value << 1) ^ (0x1021 if value & 0x8000 else 0)) & 65535
return value ^ 0x9f5b
def capture(counter=254):
data = bytearray(range(32))
data[2] = counter
data[:2] = crc(data).to_bytes(2, 'little')
return bytes(data)
class TestGV70Template(unittest.TestCase):
def test_finite_speed_round_clip_counter_drive_and_crc(self):
for counter in (0, 254, 255):
source = capture(counter)
template = GV70Template.capture(source)
for frame in (0, 1, 255, 256):
for drive in (False, True):
for speed in (-1., 0., .005, .015, 1.005, 12.345, 655.335, 655.34, 655.345, 1000.):
with self.subTest(counter=counter, frame=frame, drive=drive, speed=speed):
expected = bytearray(source)
expected[2] = (counter + frame + 1) & 255
expected[3] = (source[3] & ~1) | int(drive)
expected[8:10] = min(max(round(speed * 100.), 0), 65534).to_bytes(2, 'little')
expected[:2] = crc(expected).to_bytes(2, 'little')
self.assertEqual(template.frame(frame, drive, speed=speed), (0x51, bytes(expected), 0))
self.assertEqual(template.data, source)
def test_absent_and_nonfinite_speed_preserve_capture(self):
source = capture()
descriptor = GV70Template.capture(source)
ev6 = EV6Template.capture(source)
for speed in (None, math.nan, math.inf, -math.inf):
self.assertEqual(descriptor.frame(7, True, speed=speed), ev6.frame(7, True))
self.assertEqual(descriptor.frame(7, True, speed=speed).dat[8:10], source[8:10])
def test_capture_integrity_instances_and_ev6_signature_remain_closed(self):
source = capture()
descriptor = GV70Template.capture(source)
for instance in (descriptor, EV6Template.capture(source)):
for name, value in (('data', bytes(32)), ('frame', None), ('_finish', None), ('extra', 1)):
with self.subTest(descriptor=type(instance).__name__, field=name):
with self.assertRaises(FrozenInstanceError):
setattr(instance, name, value)
bad = bytearray(source)
bad[12] ^= 1
for value in (b'', source[:-1], source+b'0', bytearray(source), bytes(bad)):
with self.assertRaises(ValueError):
GV70Template.capture(value)
with self.assertRaises(TypeError):
EV6Template.capture(source).frame(0, False, speed=123.)
other = GV70Template.capture(capture(0))
self.assertNotEqual(descriptor.frame(0, False).dat[2], other.frame(0, False).dat[2])
for args in ((-1, False, 0), (True, False, 0), (0, 1, 0), (0, False, 1)):
with self.assertRaises(ValueError):
descriptor.frame(*args, speed=1.)
@@ -1,12 +1,17 @@
import unittest
from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus, structs
from opendbc.car import gen_empty_fingerprint
from opendbc.car.structs import CarParams
from opendbc.car.fw_versions import build_fw_dict
from opendbc.car.fw_versions import build_fw_dict, match_fw_to_car
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai import hyundaican
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.radar_interface import RADAR_START_ADDR
from opendbc.car.hyundai.values import CAR, DATE_FW_ECUS, FW_QUERY_CONFIG, CANFD_FUZZY_WHITELIST, \
from opendbc.car.hyundai.values import CAR, DBC, DATE_FW_ECUS, FW_QUERY_CONFIG, CANFD_FUZZY_WHITELIST, \
PLATFORM_CODE_ECUS, HYUNDAI_VERSION_REQUEST_LONG, \
HyundaiFlags, get_platform_codes, HyundaiSafetyFlags
from opendbc.car.hyundai.fingerprints import FW_VERSIONS
@@ -19,12 +24,24 @@ Ecu = CarParams.Ecu
NO_DATES_PLATFORMS = {
# CAN FD
CAR.KIA_SPORTAGE_5TH_GEN,
CAR.KIA_SPORTAGE_2026,
CAR.KIA_SPORTAGE_HEV_2026,
CAR.HYUNDAI_SANTA_CRUZ_1ST_GEN,
CAR.HYUNDAI_SANTA_CRUZ_2025,
CAR.HYUNDAI_TUCSON_4TH_GEN,
CAR.HYUNDAI_TUCSON_2025,
CAR.HYUNDAI_TUCSON_HEV_2025,
CAR.HYUNDAI_TUCSON_PHEV_2025,
# CAN
CAR.HYUNDAI_ELANTRA,
CAR.HYUNDAI_ELANTRA_GT_I30,
CAR.KIA_CEED,
CAR.KIA_XCEED_PHEV,
CAR.HYUNDAI_BAYON_1ST_GEN_NON_SCC,
CAR.HYUNDAI_KONA_NON_SCC,
CAR.KIA_CEED_PHEV_2022_NON_SCC,
CAR.KIA_FORTE_2019_NON_SCC,
CAR.KIA_FORTE_2021_NON_SCC,
CAR.KIA_CEED_PHEV,
CAR.KIA_FORTE,
CAR.KIA_OPTIMA_G4,
@@ -52,6 +69,293 @@ def cars_with(flags):
class TestHyundaiFingerprint(unittest.TestCase):
def test_k4_shared_platform_physical_button_events(self):
from opendbc.car.hyundai.values import Buttons
button_type = structs.CarState.ButtonEvent.Type
signals = (("LDA_BTN", 1, button_type.lkas), ("ADAPTIVE_CRUISE_MAIN_BTN", 1, button_type.mainCruise),
("CRUISE_BUTTONS", Buttons.RES_ACCEL, button_type.accelCruise),
("CRUISE_BUTTONS", Buttons.SET_DECEL, button_type.decelCruise),
("CRUISE_BUTTONS", Buttons.CANCEL, button_type.cancel))
for alpha_long in (False, True):
for alternate in (False, True):
for signal, value, expected in signals:
with self.subTest(alpha_long=alpha_long, alternate=alternate, signal=signal, value=value):
fingerprint = gen_empty_fingerprint()
if not alternate:
fingerprint[0][0x1CF] = 8
cp = CarInterface.get_params(CAR.KIA_K4_2025, fingerprint, [], alpha_long, False, False)
self.assertEqual(cp.carFingerprint, CAR.KIA_K4_2025)
self.assertEqual(bool(cp.flags & HyundaiFlags.CANFD_ALT_BUTTONS), alternate)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
state.update(parsers)
for frame, sample in enumerate((0, value, 0), 1):
message = packer.make_can_msg(state.cruise_btns_msg_canfd, CanBus(cp).ECAN, {signal: sample})
parsers[Bus.pt].update([(frame * 10_000_000, [message])])
events = state.update(parsers).buttonEvents
actual = [(event.type, event.pressed) for event in events]
self.assertEqual(actual, [] if frame == 1 else [(expected, frame == 2)])
def test_eight_ordinary_ccnc_interfaces_and_display_frames(self):
family = (
CAR.HYUNDAI_KONA_2ND_GEN, CAR.HYUNDAI_KONA_HEV_2ND_GEN, CAR.HYUNDAI_SANTA_CRUZ_2025,
CAR.HYUNDAI_SONATA_2024, CAR.HYUNDAI_SONATA_HEV_2024, CAR.HYUNDAI_TUCSON_2025,
CAR.HYUNDAI_TUCSON_HEV_2025, CAR.KIA_K5_2025,
)
for car_model in family:
for alpha_long in (False, True):
with self.subTest(car_model=car_model, alpha_long=alpha_long):
cp = CarInterface.get_params(car_model, gen_empty_fingerprint(), [], alpha_long, False, False)
self.assertTrue(cp.flags & HyundaiFlags.CCNC)
self.assertFalse(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG)
self.assertEqual(bool(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CCNC), True)
self.assertEqual(bool(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.LONG), alpha_long)
self.assertFalse(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CAN_REFRESH_MSGS)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
cam = parsers[Bus.cam]
self.assertTrue(cam.can_valid)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
can = CanBus(cp)
output_parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [("CCNC_0x161", 0), ("CCNC_0x162", 0)], can.ECAN)
controller = CarController(DBC[cp.carFingerprint], cp)
control = structs.CarControl()
control.enabled = control.latActive = True
control.longActive = alpha_long
control.hudControl.setSpeed = 20.0
# Establish the controller's startup baseline before camera display arrives.
state.out = state.update(parsers)
controller.update(control.as_reader(), state, 990_000_000)
controller.frame = 5
stock_161 = {"COUNTER": 7, "BACKGROUND": 3, "SETSPEED_SPEED": 83,
"ALERTS_2": 4, "ALERTS_3": 7, "ALERTS_5": 4, "SOUNDS_3": 5}
stock_162 = {"COUNTER": 9, "SPEEDLIMIT": 60, "FAULT_DAS": 1, "LEAD_DISTANCE": 25}
incoming = [packer.make_can_msg("CCNC_0x161", can.CAM, stock_161),
packer.make_can_msg("CCNC_0x162", can.CAM, stock_162),
packer.make_can_msg("FR_CMR_03_50ms", can.CAM, {"Longitudinal_Distance": 47})]
self.assertEqual(cam.update((1_000_000_000, incoming)), {0x161, 0x162, 0x1b5})
self.assertEqual([cam.ts_nanos[addr]["COUNTER"] for addr in (0x161, 0x162)], [1_000_000_000] * 2)
state.out = state.update(parsers)
state.out.vCruiseCluster = 72.0 # car/card's current driver-selected display speed
_, sent = controller.update(control.as_reader(), state, 1_000_000_000)
displays = [msg for msg in sent if msg[0] in (0x161, 0x162)]
self.assertEqual([(addr, bus, len(data)) for addr, data, bus in displays],
[(0x161, can.ECAN, 32), (0x162, can.ECAN, 32)])
self.assertFalse(any(addr in (0x160, 0x1E0, 0x7C4, 0xEA) and bus == can.ECAN for addr, _, bus in sent))
output_parser.update((1_010_000_000, displays))
self.assertEqual(output_parser.vl["CCNC_0x161"]["COUNTER"], 7)
self.assertEqual(output_parser.vl["CCNC_0x162"]["COUNTER"], 9)
self.assertEqual(output_parser.vl["CCNC_0x161"]["BACKGROUND"], 3)
self.assertEqual(output_parser.vl["CCNC_0x162"]["SPEEDLIMIT"], 60)
self.assertEqual(output_parser.vl["CCNC_0x162"]["FAULT_DAS"], 1)
self.assertEqual(output_parser.vl["CCNC_0x162"]["LEAD_DISTANCE"], 47 if alpha_long else 25)
for signal, expected in (("ALERTS_3", 7), ("ALERTS_5", 4), ("SOUNDS_3", 5)):
self.assertEqual(output_parser.vl["CCNC_0x161"][signal], expected)
self.assertEqual(output_parser.vl["CCNC_0x161"]["SETSPEED_SPEED"], 72 if alpha_long else 83)
# No new camera pair means no second CCNC emission at the next 20 Hz slot.
for tick in range(1, 6):
_, held = controller.update(control.as_reader(), state, 1_000_000_000 + tick * 10_000_000)
self.assertFalse(any(addr in (0x161, 0x162) for addr, _, _ in held))
# A new 161 alone cannot be paired with held 162, and old 1b5
# camera geometry/lead distance cannot overwrite a later pair.
next_161 = packer.make_can_msg("CCNC_0x161", can.CAM, {**stock_161, "COUNTER": 8})
cam.update((1_200_000_000, [next_161]))
state.out = state.update(parsers)
controller.frame = 15
_, unpaired = controller.update(control.as_reader(), state, 1_200_000_000)
self.assertFalse(any(addr in (0x161, 0x162) for addr, _, _ in unpaired))
next_162 = packer.make_can_msg("CCNC_0x162", can.CAM, {**stock_162, "COUNTER": 10, "LEAD_DISTANCE": 29})
cam.update((1_210_000_000, [next_162]))
state.out = state.update(parsers)
controller.frame = 20
_, paired = controller.update(control.as_reader(), state, 1_210_000_000)
display_pair = [msg for msg in paired if msg[0] in (0x161, 0x162)]
self.assertEqual([msg[0] for msg in display_pair], [0x161, 0x162])
output_parser.update((1_220_000_000, display_pair))
self.assertEqual(output_parser.vl["CCNC_0x162"]["LEAD_DISTANCE"], 29)
# Reversing the controller clock baselines the held pair again.
controller.frame = 25
_, reversed_clock = controller.update(control.as_reader(), state, 1_100_000_000)
self.assertFalse(any(addr in (0x161, 0x162) for addr, _, _ in reversed_clock))
fresh_pair = [packer.make_can_msg("CCNC_0x161", can.CAM, {**stock_161, "COUNTER": 9}),
packer.make_can_msg("CCNC_0x162", can.CAM, {**stock_162, "COUNTER": 11})]
cam.update((1_300_000_000, fresh_pair))
state.out = state.update(parsers)
controller.frame = 30
_, resumed = controller.update(control.as_reader(), state, 1_300_000_000)
self.assertEqual([msg[0] for msg in resumed if msg[0] in (0x161, 0x162)], [0x161, 0x162])
# Paired timestamps alone are insufficient if the control loop has
# not emitted them until well after that display cycle.
late_pair = [packer.make_can_msg("CCNC_0x161", can.CAM, {**stock_161, "COUNTER": 10}),
packer.make_can_msg("CCNC_0x162", can.CAM, {**stock_162, "COUNTER": 12})]
cam.update((1_400_000_000, late_pair))
state.out = state.update(parsers)
controller.frame = 35
_, late = controller.update(control.as_reader(), state, 2_000_000_000)
self.assertFalse(any(msg[0] in (0x161, 0x162) for msg in late))
control.latActive = False
inactive_pair = [packer.make_can_msg("CCNC_0x161", can.CAM,
{**stock_161, "COUNTER": 11, "LFA_ICON": 2, "CENTERLINE": 1}),
packer.make_can_msg("CCNC_0x162", can.CAM, {**stock_162, "COUNTER": 13})]
cam.update((2_050_000_000, inactive_pair))
state.out = state.update(parsers)
controller.frame = 40
_, inactive = controller.update(control.as_reader(), state, 2_050_000_000)
display_pair = [msg for msg in inactive if msg[0] in (0x161, 0x162)]
self.assertEqual(len(display_pair), 2)
output_parser.update((2_060_000_000, display_pair))
for signal in ("LFA_ICON", "CENTERLINE", "LANELINE_LEFT", "LANELINE_RIGHT"):
self.assertEqual(output_parser.vl["CCNC_0x161"][signal], 0)
self.assertEqual(output_parser.vl["CCNC_0x162"]["FAULT_DAS"], 1)
def test_ccnc_permission_requires_observed_non_lka_camera_topology(self):
fingerprint = gen_empty_fingerprint()
cam_bus = CanBus(None, fingerprint).CAM
fingerprint[cam_bus][0x50] = 16
cp = CarInterface.get_params(CAR.HYUNDAI_KONA_2ND_GEN, fingerprint, [], False, False, False)
self.assertTrue(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG)
self.assertFalse(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.CCNC)
cam = CarState(cp).get_can_parsers(cp)[Bus.cam]
self.assertNotIn("CCNC_0x161", cam.vl)
self.assertNotIn("CCNC_0x162", cam.vl)
def test_elantra_hev_2024_optional_buttons_do_not_require_reception(self):
cp = CarInterface.get_params(CAR.HYUNDAI_ELANTRA_HEV_2024, gen_empty_fingerprint(), [], False, False, False)
pt = CarState(cp).get_can_parsers(cp)[Bus.pt]
# Only the two optional button subscriptions exist before ordinary signals
# are accessed. Missing either button message must not invalidate CAN health.
for tick in range(100):
pt.update((1_000_000_000 + tick * 10_000_000, []))
self.assertTrue(pt.can_valid)
def test_ioniq6_left_paddle_is_factual_button_event(self):
for alpha_long in (False, True):
with self.subTest(alpha_long=alpha_long):
cp = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, gen_empty_fingerprint(), [], alpha_long, False, False)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
pt = parsers[Bus.pt]
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
bus = CanBus(cp).ECAN
def update(value: int, frame: int, *, packer=packer, bus=bus, pt=pt, state=state, parsers=parsers):
msg = packer.make_can_msg("CRUISE_BUTTONS", bus, {"SET_ME_1": 1, "LEFT_PADDLE": value})
pt.update((frame * 20_000_000, [msg]))
return state.update(parsers)
update(0, 1)
pressed = update(1, 2)
held = update(1, 3)
released = update(0, 4)
button = structs.CarState.ButtonEvent.Type.altButton2
self.assertEqual([(event.type, event.pressed) for event in pressed.buttonEvents if event.type == button], [(button, True)])
self.assertFalse(any(event.type == button for event in held.buttonEvents))
self.assertEqual([(event.type, event.pressed) for event in released.buttonEvents if event.type == button], [(button, False)])
def test_elantra_hev_2024_refresh_contract(self):
for alpha_long in (False, True):
with self.subTest(alpha_long=alpha_long):
cp = CarInterface.get_params(CAR.HYUNDAI_ELANTRA_HEV_2024, gen_empty_fingerprint(), [], alpha_long, False, False)
self.assertEqual(cp.carFingerprint, CAR.HYUNDAI_ELANTRA_HEV_2024)
self.assertEqual(CAR.HYUNDAI_ELANTRA_HEV_2024.config.dbc_dict[Bus.pt], "hyundai_can_refresh_generated")
self.assertEqual(cp.openpilotLongitudinalControl, alpha_long)
self.assertAlmostEqual(cp.longitudinalActuatorDelay, 0.22)
self.assertEqual(int(cp.safetyConfigs[0].safetyParam), int(HyundaiSafetyFlags.CAMERA_SCC | HyundaiSafetyFlags.CAN_REFRESH_MSGS |
HyundaiSafetyFlags.HYBRID_GAS | (HyundaiSafetyFlags.LONG if alpha_long else 0)))
prior = CarInterface.get_params(CAR.HYUNDAI_ELANTRA_HEV_2021, gen_empty_fingerprint(), [], alpha_long, False, False)
self.assertEqual(cp.mass, prior.mass)
ecu_versions = FW_VERSIONS[CAR.HYUNDAI_ELANTRA_HEV_2024]
self.assertEqual({ecu for ecu, _, _ in ecu_versions}, {Ecu.fwdRadar, Ecu.eps, Ecu.fwdCamera})
self.assertEqual(sum(map(len, ecu_versions.values())), 8)
def test_elantra_hev_2024_exact_firmware_tuple(self):
fw = FW_VERSIONS[CAR.HYUNDAI_ELANTRA_HEV_2024]
car_fw = [CarParams.CarFw(ecu=ecu, address=addr, subAddress=0, fwVersion=versions[-1], brand="hyundai")
for (ecu, addr, _), versions in fw.items()]
exact, matches = match_fw_to_car(car_fw, "", allow_exact=True, allow_fuzzy=False, log=False)
self.assertTrue(exact)
self.assertEqual(matches, {CAR.HYUNDAI_ELANTRA_HEV_2024})
def test_elantra_hev_2024_parser_pulse_and_hybrid_signals(self):
cp = CarInterface.get_params(CAR.HYUNDAI_ELANTRA_HEV_2024, gen_empty_fingerprint(), [], False, False, False)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
pt = parsers[Bus.pt]
packer = CANPacker("hyundai_can_refresh_generated")
state.update(parsers) # subscribe to ordinary dynamically accessed signals
pulse = [packer.make_can_msg("BCM_PO_11", 0, {"LDA_BTN": value}) for value in (1, 0)]
signals = [packer.make_can_msg("E_EMS11", 0, {"CR_Vcu_AccPedDep_Pos": 3}),
packer.make_can_msg("ELECT_GEAR", 0, {"Elect_Gear_Shifter": 5}),
packer.make_can_msg("TCS13", 0, {"DriverOverride": 2})]
pt.update((1_000_000_000, [*pulse, *signals]))
self.assertEqual(pt.vl_all["BCM_PO_11"]["LDA_BTN"], [1, 0])
ret = state.update(parsers)
self.assertTrue(ret.gasPressed)
self.assertTrue(ret.brakePressed)
self.assertEqual(ret.gearShifter, structs.CarState.GearShifter.drive)
self.assertTrue(any(event.type == structs.CarState.ButtonEvent.Type.lkas and event.pressed for event in ret.buttonEvents))
cleared_signals = [packer.make_can_msg("CLU13", 0, {"CF_Clu_LdwsLkasSW": 0}),
packer.make_can_msg("E_EMS11", 0, {"CR_Vcu_AccPedDep_Pos": 0}),
packer.make_can_msg("ELECT_GEAR", 0, {"Elect_Gear_Shifter": 0}),
packer.make_can_msg("TCS13", 0, {"DriverOverride": 0})]
pt.update((1_010_000_000, cleared_signals))
released = state.update(parsers)
self.assertTrue(any(event.type == structs.CarState.ButtonEvent.Type.lkas and not event.pressed for event in released.buttonEvents))
self.assertFalse(released.gasPressed)
self.assertFalse(released.brakePressed)
self.assertEqual(released.gearShifter, structs.CarState.GearShifter.park)
def test_elantra_hev_2024_cluster_button_short_pulse(self):
cp = CarInterface.get_params(CAR.HYUNDAI_ELANTRA_HEV_2024, gen_empty_fingerprint(), [], False, False, False)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
pt = parsers[Bus.pt]
packer = CANPacker("hyundai_can_refresh_generated")
state.update(parsers)
pulse = [packer.make_can_msg("CLU13", 0, {"CF_Clu_LdwsLkasSW": value}) for value in (1, 0)]
pt.update((1_000_000_000, pulse))
self.assertEqual(pt.vl_all["CLU13"]["CF_Clu_LdwsLkasSW"], [1, 0])
pressed = state.update(parsers)
self.assertTrue(any(event.type == structs.CarState.ButtonEvent.Type.lkas and event.pressed for event in pressed.buttonEvents))
pt.update((1_010_000_000, [packer.make_can_msg("CLU13", 0, {"CF_Clu_LdwsLkasSW": 0})]))
released = state.update(parsers)
self.assertTrue(any(event.type == structs.CarState.ButtonEvent.Type.lkas and not event.pressed for event in released.buttonEvents))
def test_elantra_2024_refresh_contract(self):
for alpha_long in (False, True):
with self.subTest(alpha_long=alpha_long):
cp = CarInterface.get_params(CAR.HYUNDAI_ELANTRA_2024, gen_empty_fingerprint(), [], alpha_long, False, False)
self.assertEqual(cp.carFingerprint, CAR.HYUNDAI_ELANTRA_2024)
self.assertEqual(CAR.HYUNDAI_ELANTRA_2024.config.dbc_dict[Bus.pt], "hyundai_can_refresh_generated")
self.assertEqual(cp.openpilotLongitudinalControl, alpha_long)
self.assertTrue(cp.safetyConfigs[0].safetyParam & HyundaiSafetyFlags.CAMERA_SCC)
self.assertTrue(cp.safetyConfigs[0].safetyParam & HyundaiSafetyFlags.CAN_REFRESH_MSGS)
self.assertEqual(bool(cp.safetyConfigs[0].safetyParam & HyundaiSafetyFlags.LONG), alpha_long)
ecu_versions = FW_VERSIONS[CAR.HYUNDAI_ELANTRA_2024]
self.assertEqual({ecu for ecu, _, _ in ecu_versions}, {Ecu.fwdRadar, Ecu.eps, Ecu.fwdCamera, Ecu.abs})
self.assertEqual(sum(map(len, ecu_versions.values())), 5)
def test_elantra_2024_refresh_dbc_round_trip(self):
packer = CANPacker("hyundai_can_refresh_generated")
parser = CANParser("hyundai_can_refresh_generated", [("LFAHDA_MFC", 0)], 0)
msg = packer.make_can_msg("LFAHDA_MFC", 0, {"LFA_Icon_State": 2})
self.assertEqual((msg[0], msg[2], len(msg[1])), (0x485, 0, 8))
self.assertEqual(parser.update((1_000_000_000, [msg])), {0x485})
self.assertEqual(parser.vl["LFAHDA_MFC"]["LFA_Icon_State"], 2)
old_msg = CANPacker("hyundai_can_generated").make_can_msg("LFAHDA_MFC", 0, {"LFA_Icon_State": 2})
self.assertEqual(len(old_msg[1]), 4)
self.assertEqual(len(hyundaican.create_lfahda_mfc(packer, True)[1]), 8)
def test_feature_detection(self):
# LKA steering
for lka_steering in (True, False):
@@ -97,10 +401,10 @@ class TestHyundaiFingerprint(unittest.TestCase):
def test_canfd_ecu_whitelist(self):
# Asserts only expected Ecus can exist in database for CAN-FD cars
for car_model in CAR:
for car_model, fw_versions in FW_VERSIONS.items():
if not (car_model.config.flags & HyundaiFlags.CANFD):
continue
ecus = {fw[0] for fw in FW_VERSIONS[car_model].keys()}
ecus = {fw[0] for fw in fw_versions.keys()}
ecus_not_in_whitelist = ecus - CANFD_EXPECTED_ECUS
ecu_strings = ", ".join([f"Ecu.{ecu}" for ecu in ecus_not_in_whitelist])
assert len(ecus_not_in_whitelist) == 0, \
@@ -147,7 +451,7 @@ class TestHyundaiFingerprint(unittest.TestCase):
# Third and fourth character are usually EV/hybrid identifiers
codes = {code.split(b"-")[0][:2] for code, _ in get_platform_codes(fws)}
if car_model == CAR.HYUNDAI_PALISADE:
if car_model in (CAR.HYUNDAI_PALISADE, CAR.HYUNDAI_PALISADE_2023):
assert codes == {b"LX", b"ON"}, f"Car has unexpected platform codes: {car_model} {codes}"
elif car_model == CAR.HYUNDAI_KONA_EV and ecu[0] == Ecu.fwdCamera:
assert codes == {b"OE", b"OS"}, f"Car has unexpected platform codes: {car_model} {codes}"
@@ -158,8 +462,12 @@ class TestHyundaiFingerprint(unittest.TestCase):
# fingerprint in the absence of full FW matches:
def test_platform_code_ecus_available(self):
# TODO: add queries for these non-CAN FD cars to get EPS
no_eps_platforms = cars_with(HyundaiFlags.CANFD) | {CAR.KIA_SORENTO, CAR.KIA_OPTIMA_G4, CAR.KIA_OPTIMA_G4_FL, CAR.KIA_OPTIMA_H, CAR.KIA_K7_2017,
CAR.KIA_OPTIMA_H_G4_FL, CAR.HYUNDAI_SONATA_LF, CAR.HYUNDAI_TUCSON, CAR.GENESIS_G90, CAR.GENESIS_G80, CAR.HYUNDAI_ELANTRA}
no_eps_platforms = cars_with(HyundaiFlags.CANFD) | {
CAR.KIA_RAY_EV,
CAR.HYUNDAI_BAYON_1ST_GEN_NON_SCC, CAR.KIA_SORENTO, CAR.KIA_OPTIMA_G4, CAR.KIA_OPTIMA_G4_FL,
CAR.KIA_OPTIMA_H, CAR.KIA_K7_2017, CAR.KIA_OPTIMA_H_G4_FL, CAR.HYUNDAI_SONATA_LF,
CAR.HYUNDAI_TUCSON, CAR.GENESIS_G90, CAR.GENESIS_G80, CAR.HYUNDAI_ELANTRA,
}
# Asserts ECU keys essential for fuzzy fingerprinting are available on all platforms
for car_model, ecus in FW_VERSIONS.items():
@@ -167,6 +475,8 @@ class TestHyundaiFingerprint(unittest.TestCase):
for platform_code_ecu in PLATFORM_CODE_ECUS:
if platform_code_ecu in (Ecu.fwdRadar, Ecu.eps) and car_model == CAR.HYUNDAI_GENESIS:
continue
if platform_code_ecu == Ecu.fwdRadar and car_model.config.flags & HyundaiFlags.NON_SCC:
continue
if platform_code_ecu == Ecu.eps and car_model in no_eps_platforms:
continue
assert platform_code_ecu in [e[0] for e in ecus]
@@ -188,6 +498,15 @@ class TestHyundaiFingerprint(unittest.TestCase):
result = get_platform_codes([fw])
assert 1 == len(result), f"Unable to parse FW: {fw}"
codes |= result
# Exact historical camera record has no embedded ECU part number.
# Keep every other record, including these platforms' radars, strict.
historical_no_part = (
car_model in (CAR.KIA_SPORTAGE_2026, CAR.KIA_SPORTAGE_HEV_2026) and
ecu == (Ecu.fwdCamera, 0x7c4, None) and
fw == b'\xf1\x00NQ51.011.021.012551000HKP_NQ524_50509099211P1110'
)
if car_model != CAR.HYUNDAI_GENESIS and not historical_no_part:
assert all(b"-" in code for code, _ in result), f"FW does not have part number: {fw}"
if ecu[0] not in DATE_FW_ECUS or car_model in NO_DATES_PLATFORMS:
assert all(date is None for _, date in codes)
@@ -199,8 +518,6 @@ class TestHyundaiFingerprint(unittest.TestCase):
# Hyundai places the ECU part number in their FW versions, assert all parsable
# Some examples of valid formats: b"56310-L0010", b"56310L0010", b"56310/M6300"
assert all(b"-" in code for code, _ in codes), \
f"FW does not have part number: {fw}"
def test_platform_codes_spot_check(self):
# Asserts basic platform code parsing behavior for a few cases
@@ -236,9 +553,13 @@ class TestHyundaiFingerprint(unittest.TestCase):
excluded_platforms = {
CAR.GENESIS_G70, # shared platform code, part number, and date
CAR.GENESIS_G70_2020,
CAR.GENESIS_G70_2021_NON_SCC,
}
excluded_platforms |= cars_with(HyundaiFlags.CANFD) - cars_with(HyundaiFlags.EV) - CANFD_FUZZY_WHITELIST # shared platform codes
excluded_platforms |= NO_DATES_PLATFORMS # date codes are required to match
# Manual-only identities are absent from the FW database and cannot appear
# in the matcher loop below.
excluded_platforms &= FW_VERSIONS.keys()
platforms_with_shared_codes = set()
for platform, fw_by_addr in FW_VERSIONS.items():
@@ -0,0 +1,23 @@
"""The legacy PE helper keeps its identity boundary after shared extraction."""
import unittest
from opendbc.car import structs
from opendbc.car.hyundai import ccnc_ev_stock, ioniq5pe
from opendbc.car.hyundai.tests.test_ioniq5pe_stock import params
from opendbc.car.hyundai.values import CAR
class TestIoniq5PECompatibility(unittest.TestCase):
def test_pe_api_rejects_ev9_while_shared_owner_accepts_both(self):
state = structs.CarState(canValid=True, gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.enabled = True
control = structs.CarControl(enabled=True, latActive=True)
for identity in (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9):
cp = params(candidate=identity)
expected = identity == CAR.HYUNDAI_IONIQ_5_PE
self.assertTrue(ccnc_ev_stock.qualified(cp))
self.assertTrue(ccnc_ev_stock.request_allowed(cp, state))
self.assertTrue(ccnc_ev_stock.replacement_requested(cp, control, state))
self.assertEqual(ioniq5pe.qualified(cp), expected)
self.assertEqual(ioniq5pe.request_allowed(cp, state), expected)
self.assertEqual(ioniq5pe.replacement_requested(cp, control, state), expected)
@@ -0,0 +1,44 @@
import math
import unittest
from opendbc.car import Bus, structs
from opendbc.car.hyundai.fingerprints import FW_VERSIONS
from opendbc.car.hyundai.values import CAR, HyundaiFlags
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.interfaces import get_torque_params
class TestIoniq5PEMetadata(unittest.TestCase):
def test_exact_platform_specs_and_angle_flags(self):
config = CAR.HYUNDAI_IONIQ_5_PE.config
self.assertEqual(config.specs, CAR.HYUNDAI_IONIQ_5.config.specs)
self.assertEqual((config.specs.mass, config.specs.wheelbase, config.specs.steerRatio, config.specs.tireStiffnessFactor),
(1948, 2.97, 14.26, 0.65))
self.assertEqual(config.flags, HyundaiFlags.CANFD | HyundaiFlags.EV | HyundaiFlags.CANFD_ANGLE_STEERING | HyundaiFlags.CCNC)
self.assertEqual(config.dbc_dict[Bus.pt], "hyundai_canfd_generated")
self.assertEqual(config.dbc_dict[Bus.radar], "hyundai_mrr35_radar_generated")
self.assertNotEqual(config.specs, CAR.HYUNDAI_IONIQ_5_N.config.specs)
def test_original_angle_metadata_allows_interface_constructor(self):
tune = get_torque_params()[CAR.HYUNDAI_IONIQ_5_PE]
self.assertEqual(tune["MAX_LAT_ACCEL_MEASURED"], 2.5)
self.assertTrue(math.isnan(tune["LAT_ACCEL_FACTOR"]))
self.assertTrue(math.isnan(tune["FRICTION"]))
cp = CarInterface.get_std_params(CAR.HYUNDAI_IONIQ_5_PE)
self.assertEqual(cp.maxLateralAccel, 2.5)
def test_exact_firmware_entries_remain_distinct(self):
versions = FW_VERSIONS[CAR.HYUNDAI_IONIQ_5_PE]
radar = versions[(structs.CarParams.Ecu.fwdRadar, 0x7d0, None)]
camera = versions[(structs.CarParams.Ecu.fwdCamera, 0x7c4, None)]
self.assertEqual(len(versions), 2)
self.assertEqual(len(radar), 2)
self.assertEqual(len(camera), 4)
self.assertTrue(all(version.startswith(b"\xf1\x00NE__ RDR") for version in radar))
self.assertTrue(all(version.startswith(b"\xf1\x00NE MFC") for version in camera))
old_camera = FW_VERSIONS[CAR.HYUNDAI_IONIQ_5][(structs.CarParams.Ecu.fwdCamera, 0x7c4, None)]
self.assertFalse(set(camera) & set(old_camera))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,36 @@
"""Original PE reached-wire cases; ordinary ownership is tested separately."""
import unittest
from types import SimpleNamespace
from opendbc.can import CANPacker
from opendbc.car.hyundai.hyundaicanfd import create_angle_steering_messages
from opendbc.car.hyundai.values import CAR, HyundaiFlags
class TestIoniq5PESender(unittest.TestCase):
@staticmethod
def frame(candidate, angle, status):
flags = HyundaiFlags.CANFD_ANGLE_STEERING | HyundaiFlags.CANFD_LKA_STEER_MSG | HyundaiFlags.CANFD_LKA_STEER_MSG_ALT
cp = SimpleNamespace(carFingerprint=candidate, flags=flags, openpilotLongitudinalControl=False)
packer = CANPacker('hyundai_canfd_generated')
packer.counters[0x110] = 37
return create_angle_steering_messages(packer, cp, SimpleNamespace(ACAN=0, ECAN=1),
True, True, angle, 0.5, status)[0]
def test_original_pe_signed_half_step_quantization(self):
# Original DBC packer uses floor(angle / 0.1 + 0.5), not ties-to-even.
for angle, units in ((0.05, 1), (-0.15, -1), (5.0, 50), (-12.3, -123)):
with self.subTest(angle=angle):
address, data, bus = self.frame(CAR.HYUNDAI_IONIQ_5_PE, angle, {})
raw = (data[11] << 6) | (data[10] >> 2)
signed = raw if raw < 8192 else raw - 16384
self.assertEqual((address, bus, data[2], signed), (0x110, 0, 37, units))
def test_pe_active_status_reset_preserves_sibling_status_behavior(self):
stock = {'LKA_LHLnWrnSta': 2, 'LKA_RHLnWrnSta': 3, 'ToiFltSta': 2, 'LKA_UsmMod': 3}
_, pe, _ = self.frame(CAR.HYUNDAI_IONIQ_5_PE, 5.0, stock)
_, sibling, _ = self.frame(CAR.HYUNDAI_IONIQ_5_N, 5.0, stock)
# Source-derived original active body resets these fields to zero.
for data, expected in ((pe, (0, 0, 0, 0)), (sibling, (2, 3, 2, 3))):
actual = ((data[3] >> 6) & 3, data[4] & 3, (data[6] >> 6) & 3, data[10] & 3)
self.assertEqual(actual, expected)
@@ -0,0 +1,128 @@
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.ioniq5pe import qualified, replacement_requested
from opendbc.car.hyundai.values import CAR, DBC
def params(*, candidate=CAR.HYUNDAI_IONIQ_5_PE, alpha=False, release=False, topology="lka_alt", missing=None, gear_metadata=0x130):
fingerprint = gen_empty_fingerprint()
fingerprint[1].update({0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24, 0x1CF: 8, 0x1A0: 32})
if gear_metadata is not None:
fingerprint[1][gear_metadata] = 16 if gear_metadata == 0x130 else 32
if topology == "lka_alt":
fingerprint[2].update({0x110: 32, 0x362: 32})
elif topology == "lka":
fingerprint[2][0x50] = 16
if missing is not None:
for bus in fingerprint.values():
bus.pop(missing, None)
return CarInterface.get_params(candidate, fingerprint, [], alpha, release, False)
def update(ci, packer, tick, *, fault=0, moving=True, gear=5, cruise=True):
wheels = dict.fromkeys(("WHL_SpdFLVal", "WHL_SpdFRVal", "WHL_SpdRLVal", "WHL_SpdRRVal"), 36.0 if moving else 0.0)
values = {"ACCELERATOR": {"GEAR": gear}, "GEAR_SHIFTER": {"GEAR": gear}, "TCS": {"ACCEnable": 0, "ACC_REQ": int(cruise)},
"WHEEL_SPEEDS": wheels, "MDPS": {"MDPS_ADAS_AciFltSig_Lv2": fault}, "STEERING_SENSORS": {},
"DOORS_SEATBELTS": {"DRIVER_SEATBELT": 1}, "BLINKERS": {}, "CRUISE_BUTTONS": {},
"SCC_CONTROL": {"ACCMode": int(cruise), "VSetDis": 80}}
frames = [packer.make_can_msg(name, 1, fields) for name, fields in values.items()]
frames += [packer.make_can_msg("LKAS_ALT", 2, {}), packer.make_can_msg("CAM_0x362", 2, {})]
return ci.update([(tick * 10_000_000, frames)])
class TestIoniq5PEStock(unittest.TestCase):
def test_final_stock_cp_and_topology_boundary(self):
for alpha in (False, True):
for release in (False, True):
cp = params(alpha=alpha, release=release)
self.assertTrue(qualified(cp))
self.assertEqual(cp.safetyConfigs[0].safetyParam, 0x5491)
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertFalse(cp.alphaLongitudinalAvailable)
self.assertTrue(cp.pcmCruise)
self.assertEqual(cp.alternativeExperience, 0)
self.assertAlmostEqual(cp.wheelbase, 2.97)
self.assertAlmostEqual(cp.longitudinalActuatorDelay, 0.35)
self.assertTrue(cp.steerAtStandstill)
for gear_metadata in (None, 0x40):
cp = params(gear_metadata=gear_metadata)
self.assertTrue(qualified(cp))
ci = CarInterface(cp)
self.assertEqual(ci.CS.gear_msg_canfd, "ACCELERATOR")
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
ci.update([])
for tick in range(1, 13):
state = update(ci, packer, tick)
self.assertTrue(state.canValid)
self.assertEqual(state.gearShifter, structs.CarState.GearShifter.drive)
self.assertEqual(cp.safetyConfigs[0].safetyParam, 0x5491)
for topology in ("lka", "lfa"):
self.assertTrue(params(topology=topology).dashcamOnly)
for missing in (0x35, 0x110, 0x362, 0x1CF, 0x1A0):
self.assertFalse(qualified(params(missing=missing)))
def test_exact_profile_rejects_nonstock_and_other_identities(self):
for field, value in (("passive", True), ("dashcamOnly", True), ("notCar", True),
("openpilotLongitudinalControl", True), ("pcmCruise", False),
("alternativeExperience", 32)):
with self.subTest(field=field):
cp = params()
setattr(cp, field, value)
self.assertFalse(qualified(cp))
for candidate in (CAR.HYUNDAI_IONIQ_5, CAR.HYUNDAI_IONIQ_5_N, CAR.HYUNDAI_IONIQ_6):
with self.subTest(candidate=candidate):
cp = params(candidate=candidate)
self.assertFalse(qualified(cp))
self.assertNotEqual(cp.safetyConfigs[-1].safetyParam, 0x5491)
cp = params()
for fault in (False, True):
state = structs.CarState()
state.canValid = True
state.cruiseState.enabled = True
state.gearShifter = structs.CarState.GearShifter.drive
state.steerFaultPermanent = fault
command = structs.CarControl()
command.latActive = True
self.assertEqual(bool(replacement_requested(cp, command, state)), not fault)
state.canTimeout = True
self.assertFalse(replacement_requested(cp, command, state))
def test_actual_state_fault_and_ordered_replacement_recipe(self):
cp = params()
ci = CarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
ci.update([])
command = structs.CarControl()
command.enabled = command.latActive = True
command.actuators.steeringAngleDeg = 5.0
for warmup in range(1, 13):
state = update(ci, packer, warmup)
self.assertTrue(state.canValid)
self.assertFalse(state.canTimeout)
tick = 0
for fault, moving, gear, cruise, active in [(0, True, 5, True, True),
*[(value, True, 5, True, True) for value in range(1, 8)],
(0, False, 5, True, True), (0, True, 7, True, True),
(0, True, 5, False, True), (0, True, 5, True, False),
(0, True, 5, True, True)]:
with self.subTest(fault=fault, moving=moving, gear=gear, cruise=cruise, active=active):
command.latActive = active
state = update(ci, packer, tick + 13, fault=fault, moving=moving, gear=gear, cruise=cruise)
self.assertEqual(state.steerFaultTemporary, fault != 0)
self.assertEqual(state.standstill, not moving)
expected = fault == 0 and moving and gear == 5 and cruise and active
self.assertEqual(bool(replacement_requested(cp, command, state)), expected)
_, frames = ci.apply(command.as_reader(), (tick + 13) * 10_000_000)
addresses = [frame[0] for frame in frames]
self.assertEqual(0x110 in addresses, expected)
self.assertEqual(0x362 in addresses, expected and tick % 5 == 0)
if 0x362 in addresses:
self.assertLess(addresses.index(0x110), addresses.index(0x362))
tick += 1
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,127 @@
import json
import unittest
from pathlib import Path
from opendbc.can import CANPacker
from opendbc.car import Bus, CanData, gen_empty_fingerprint, structs
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.hyundaicanfd import create_ioniq6_blindspot_status, hkg_can_fd_checksum
from opendbc.car.hyundai.ioniq6_bsm import BlindspotStatus, Ioniq6BlindspotSources
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.ioniq6_handoff import build_ioniq6_hda2_long_candidate
from opendbc.car.hyundai.values import CAR, DBC
CASES = json.loads((Path(__file__).parent / "testdata" / "ioniq6_bsm_frozen.json").read_text())["cases"]
def long_params():
fingerprint = gen_empty_fingerprint()
fingerprint[2].update({0x50: 16, 0x2A4: 24})
fingerprint[1].update({0x1CF: 8, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24,
0x1BA: 24, 0x1E5: 16, 0x36A: 16})
fingerprint[0][0x3A5] = 24
stock = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fingerprint, [], False, False, False)
candidate = build_ioniq6_hda2_long_candidate(stock, fingerprint)
assert candidate is not None
return candidate
class TestIoniq6BlindspotStatus(unittest.TestCase):
def test_all_sixteen_frozen_executable_outputs_and_integrity(self):
for case in CASES:
with self.subTest(case=case):
left = 1 + int(case["leftBlinker"]) if case["left"] else 0
right = 1 + int(case["rightBlinker"]) if case["right"] else 0
frames = create_ioniq6_blindspot_status(case["counter"], BlindspotStatus(left, right))
self.assertEqual([(addr, bus) for addr, _, bus in frames], [(0x1BA, 1), (0x1E5, 1)])
self.assertEqual([payload.hex() for _, payload, _ in frames], [case["rear"], case["front"]])
for addr, payload, _ in frames:
self.assertEqual(int.from_bytes(payload[:2], "little"), hkg_can_fd_checksum(addr, None, bytearray(payload)))
def test_fresh_corner_and_lamp_holding_boundary(self):
sources = Ioniq6BlindspotSources()
t = 1_000_000_000
self.assertIsNone(sources.status(t))
sources.observe(corner_source_ns=t, corner_state=0x18, lamp_source_ns=t, left_lamp=True, right_lamp=False)
self.assertEqual(sources.status(t), BlindspotStatus(2, 1))
sources.observe(corner_source_ns=t + 100_000_000, corner_state=0x18,
lamp_source_ns=t + 100_000_000, left_lamp=False, right_lamp=False)
self.assertEqual(sources.status(t + 100_000_000), BlindspotStatus(2, 1))
# Repeated false lamp frames must not restart the hold.
sources.observe(corner_source_ns=t + 590_000_000, corner_state=0x18,
lamp_source_ns=t + 590_000_000, left_lamp=False, right_lamp=False)
self.assertEqual(sources.status(t + 600_000_000), BlindspotStatus(2, 1))
self.assertEqual(sources.status(t + 600_000_001), BlindspotStatus(1, 1))
self.assertIsNone(sources.status(t + 690_000_001)) # corner expiry
sources.observe(corner_source_ns=t + 700_000_000, corner_state=0x18,
lamp_source_ns=t + 700_000_000, left_lamp=False, right_lamp=False)
sources.observe(corner_source_ns=t + 2_200_000_001, corner_state=0x18,
lamp_source_ns=t + 700_000_000, left_lamp=False, right_lamp=False)
self.assertIsNone(sources.status(t + 2_200_000_001)) # lamp expiry
def test_live_lamp_does_not_expire_before_its_off_transition(self):
sources = Ioniq6BlindspotSources()
t = 1_000_000_000
sources.observe(corner_source_ns=t, corner_state=0x10, lamp_source_ns=t, left_lamp=True, right_lamp=False)
sources.observe(corner_source_ns=t + 600_000_000, corner_state=0x10,
lamp_source_ns=t + 600_000_000, left_lamp=True, right_lamp=False)
self.assertEqual(sources.status(t + 600_000_000), BlindspotStatus(2, 0))
sources.observe(corner_source_ns=t + 700_000_000, corner_state=0x10,
lamp_source_ns=t + 700_000_000, left_lamp=False, right_lamp=False)
self.assertEqual(sources.status(t + 700_000_000), BlindspotStatus(2, 0))
sources.observe(corner_source_ns=t + 1_200_000_001, corner_state=0x10,
lamp_source_ns=t + 1_200_000_001, left_lamp=False, right_lamp=False)
self.assertEqual(sources.status(t + 1_200_000_001), BlindspotStatus(1, 0))
def test_stale_lamp_gap_cannot_create_a_new_hold(self):
sources = Ioniq6BlindspotSources()
t = 1_000_000_000
sources.observe(corner_source_ns=t, corner_state=0x10, lamp_source_ns=t, left_lamp=True, right_lamp=False)
self.assertEqual(sources.status(t), BlindspotStatus(2, 0))
self.assertIsNone(sources.status(t + 1_500_000_001))
sources.observe(corner_source_ns=t + 2_000_000_000, corner_state=0x10,
lamp_source_ns=t + 2_000_000_000, left_lamp=False, right_lamp=False)
self.assertEqual(sources.status(t + 2_000_000_000), BlindspotStatus(1, 0))
def test_actual_parser_dynamic_alt_lamp_and_checksum_freshness(self):
cp = long_params()
ci = CarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
t = 1_000_000_000
corner = packer.make_can_msg("BLINDSPOTS_FRONT_CORNER_2", 1, {"SIDE_DETECT_STATE": 0x10})
alt_lamp = packer.make_can_msg("BLINKERS", 1, {"USE_ALT_LAMP": 1, "LEFT_LAMP_ALT": 1})
ci.update([(t, [corner, alt_lamp])])
self.assertTrue(ci.CS.out.leftBlinker)
self.assertEqual(ci.CS.ioniq6_bsm_sources.status(t), BlindspotStatus(2, 0))
corrupted = bytearray(corner[1])
corrupted[0] ^= 1
ci.update([(t + 101_000_000, [CanData(corner[0], bytes(corrupted), corner[2])])])
self.assertIsNone(ci.CS.ioniq6_bsm_sources.status(t + 101_000_000))
def test_controller_emits_only_fresh_paired_status_in_exact_long_profile(self):
cp = long_params()
ci = CarInterface(cp)
controller = CarController(DBC[cp.carFingerprint], cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
t = 10_000_000_000 # CAN BOOTTIME; controller below sees MONOTONIC.
ci.update([(t, [packer.make_can_msg("BLINDSPOTS_FRONT_CORNER_2", 1, {"SIDE_DETECT_STATE": 0x10}),
packer.make_can_msg("BLINKERS", 1, {})])])
control = structs.CarControl()
_, frames = controller.update(control.as_reader(), ci.CS, t - 9_000_000_000)
rear_front = [(addr, payload, bus) for addr, payload, bus in frames if addr in (0x1BA, 0x1E5)]
self.assertEqual(rear_front, create_ioniq6_blindspot_status(0, BlindspotStatus(1, 0)))
for i in range(1, 5):
controller.update(control.as_reader(), ci.CS, t - 9_000_000_000 + i * 10_000_000)
ci.update([(t + 50_000_000, [packer.make_can_msg("BLINDSPOTS_FRONT_CORNER_2", 1, {"SIDE_DETECT_STATE": 0x10}),
packer.make_can_msg("BLINKERS", 1, {})])])
_, next_frames = controller.update(control.as_reader(), ci.CS, t - 9_000_000_000 + 50_000_000)
self.assertEqual([(addr, payload, bus) for addr, payload, bus in next_frames if addr in (0x1BA, 0x1E5)],
create_ioniq6_blindspot_status(1, BlindspotStatus(1, 0)))
for i in range(6, 10):
controller.update(control.as_reader(), ci.CS, t - 9_000_000_000 + i * 10_000_000)
# An unrelated physical CAN update advances BOOTTIME without refreshing the
# corner source. Host must withhold both frames; no zero-status substitute.
ci.update([(t + 160_000_000, [packer.make_can_msg("WHEEL_SPEEDS", 1, {})])])
_, stale = controller.update(control.as_reader(), ci.CS, t - 9_000_000_000 + 160_000_000)
self.assertFalse(any(addr in (0x1BA, 0x1E5) for addr, _, _ in stale))
@@ -0,0 +1,68 @@
import unittest
from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.canfd_dash_icons import CanFDDashIcons
from opendbc.car.hyundai.tests.test_ioniq6_longitudinal import controller_fixture
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC
class TestCanFDDashIcons(unittest.TestCase):
def test_lateral_and_cruise_activity_disengage_blink_and_reengage(self):
icons = CanFDDashIcons()
for frame, enabled, lateral, expected in ((0, False, False, 0), (1, False, True, 2),
(2, False, False, 3), (100, False, False, 3),
(101, False, False, 0), (102, True, False, 2),
(103, False, True, 2), (104, False, False, 3)):
self.assertEqual(icons.update(frame, enabled, lateral), expected)
def test_actual_controller_preserves_cluster_cadence_and_axis_lifecycle(self):
for alternate in (False, True):
cp, cs, controller = controller_fixture(alternate, aol=True)
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LFAHDA_CLUSTER', 0)], controller.CAN.ECAN)
command = structs.CarControl()
for frame in range(120):
command.latActive = 5 <= frame < 10 or frame >= 115
command.enabled = 105 <= frame < 110
_, messages = controller.update(command.as_reader(), cs, 1_000_000_000 + frame * 10_000_000)
cluster = [message for message in messages if message[0] == 0x1E0]
self.assertEqual(len(cluster), int(frame % 5 == 0))
if cluster:
parser.update([(1_000_000_000 + frame * 10_000_000, cluster)])
value = parser.vl['LFAHDA_CLUSTER']
expected = 2 if command.enabled or command.latActive else 3 if 10 <= frame < 109 else 0
self.assertEqual(value['LFA_ICON'], expected)
self.assertEqual(value['HDA_ICON'], int(command.enabled))
def test_actual_alpha_off_interface_does_not_install_icons_or_emit_cluster(self):
for alternate in (False, True):
with self.subTest(alternate=alternate):
fingerprint = gen_empty_fingerprint()
fingerprint[2].update({0x110: 32, 0x362: 32} if alternate else {0x50: 16, 0x2A4: 24})
fingerprint[1].update({0x1CF: 8, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24,
0x1BA: 24, 0x1E5: 16, 0x36A: 16})
fingerprint[0].update({0x3A5: 24, 0x100: 24})
cp = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fingerprint, [], False, False, False)
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertTrue(cp.pcmCruise)
ci = CarInterface(cp)
self.assertIsNone(ci.CC.canfd_dash_icons)
ci.update([]) # Register the actual lazy parser reads.
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
control = structs.CarControl()
for tick in range(120):
stamp = 1_000_000_000 + tick * 10_000_000
inputs = [packer.make_can_msg(name, ci.CC.CAN.ECAN, values) for name, values in (
('ACCELERATOR', {'GEAR': 5}), ('TCS', {'ACCEnable': 0, 'ACC_REQ': 1}),
('WHEEL_SPEEDS', {}), ('MDPS', {'MDPS_StrTqSnsrVal': 0, 'MDPS_OutTqVal': 0}),
('CRUISE_BUTTONS', {'COUNTER': tick % 16}),
('DOORS_SEATBELTS', {'DRIVER_SEATBELT': 1}), ('STEERING_SENSORS', {}), ('BLINKERS', {}),
('SCC_CONTROL', {'MainMode_ACC': 1, 'ACCMode': 1}))]
inputs.append(packer.make_can_msg('CAM_0x362' if alternate else 'CAM_0x2a4', ci.CC.CAN.CAM, {}))
state = ci.update([(stamp, inputs)])
self.assertTrue(state.canValid)
control.enabled = 10 <= tick < 20
control.latActive = 5 <= tick < 15
_, messages = ci.apply(control.as_reader(), stamp)
self.assertFalse(any(message[0] == 0x1E0 for message in messages), tick)
@@ -0,0 +1,996 @@
import threading
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
from opendbc.car import gen_empty_fingerprint
from opendbc.car.can_definitions import CanData
from opendbc.car.car_helpers import get_car
from opendbc.car.hyundai.ioniq6_handoff import (HandoffOutcome, HandoffResult, ObservingCanRecv, build_ioniq6_hda2_long_candidate,
Ioniq6StartupKeepalive, finalize_ioniq6_prepublication,
prepare_ioniq6_long_candidate, inspect_ioniq6_long_sources,
_measured_period, _read_window, query_ioniq6_adas, run_ioniq6_handoff)
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR
from opendbc.car.hyundai.hyundaicanfd import hkg_can_fd_checksum
class FakeIoniq6Bus:
def __init__(self, *, source=True, heartbeat_source=True, heartbeat_stays_after_disable=False,
bsm_source=True, bsm_stays_after_disable=False, missing_status=None,
disable_works=True, disable_ack=True, restore_works=True,
restore_ack=True, missing_required=None, post_missing_required=None):
self.now = 0.0
self.source = source
self.heartbeat_source = heartbeat_source
self.heartbeat_stays_after_disable = heartbeat_stays_after_disable
self.bsm_source = bsm_source
self.bsm_stays_after_disable = bsm_stays_after_disable
self.missing_status = missing_status
self.disable_works = disable_works
self.disable_ack = disable_ack
self.restore_works = restore_works
self.restore_ack = restore_ack
self.missing_required = missing_required
self.post_missing_required = post_missing_required
self.counter = 0
self.queries = []
@staticmethod
def frame(address, length, counter=0):
data = bytearray(length)
data[2] = counter
data[:2] = hkg_can_fd_checksum(address, None, data).to_bytes(2, "little")
return CanData(address, bytes(data), 1)
def recv(self, wait_for_one=False):
self.now = round(self.now + 0.01, 4)
if round(self.now * 100) % 2:
return []
messages = [self.frame(address, length) for address, length in
((0x35, 32), (0x175, 24), (0xA0, 24), (0xEA, 24), (0x1CF, 8), (0x36A, 16))
if address != self.missing_required and (self.source or address != self.post_missing_required)]
if self.source:
messages.append(self.frame(0x1A0, 32, self.counter))
if self.bsm_source:
messages.extend(self.frame(address, length, self.counter) for address, length in ((0x1BA, 24), (0x1E5, 16))
if address != self.missing_status)
if self.source:
self.counter = (self.counter + 1) % 256
if self.heartbeat_source:
messages.append(CanData(0x100, self.frame(0x100, 24, self.counter).dat, 0))
return [messages]
def exchange(self, request, expected):
self.queries.append((request, expected))
if request == b"\x10\x03":
return True
if request == b"\x28\x01\x01":
if self.disable_works:
self.source = False
self.heartbeat_source = self.heartbeat_stays_after_disable
self.bsm_source = self.bsm_stays_after_disable
return self.disable_ack
if request == b"\x28\x00\x01":
if self.restore_works:
self.source = True
self.heartbeat_source = True
self.bsm_source = True
return self.restore_ack
if request == b"\x10\x01":
return self.restore_ack
raise AssertionError(request)
def run(self):
return run_ioniq6_handoff(self.recv, self.exchange, lambda: self.now)
class TestIoniq6PrepublicationHandoff(unittest.TestCase):
def test_exact_positive_ack_and_measured_silence(self):
bus = FakeIoniq6Bus()
result = bus.run()
self.assertEqual(result.outcome, HandoffOutcome.CONFIRMED)
self.assertAlmostEqual(result.source_period, 0.02, delta=0.001)
self.assertTrue(result.disable_acknowledged)
self.assertEqual(bus.queries, [(b"\x10\x03", b"\x50\x03"), (b"\x28\x01\x01", b"\x68\x01")])
def test_positive_ack_without_silence_restores_before_stock(self):
bus = FakeIoniq6Bus(disable_works=False)
result = bus.run()
self.assertEqual(result.outcome, HandoffOutcome.STOCK)
self.assertTrue(result.restore_acknowledged)
self.assertEqual(bus.queries[-2:], [(b"\x28\x00\x01", b"\x68\x00"), (b"\x10\x01", b"\x50\x01")])
def test_unanswered_disable_can_have_taken_effect_and_must_restore(self):
bus = FakeIoniq6Bus(disable_ack=False)
self.assertEqual(bus.run().outcome, HandoffOutcome.STOCK)
self.assertTrue(bus.source)
def test_missing_pre_source_never_attempts_disable(self):
bus = FakeIoniq6Bus(source=False)
self.assertEqual(bus.run().outcome, HandoffOutcome.NOT_READY)
self.assertEqual(bus.queries, [])
bus.source = True
self.assertEqual(bus.run().outcome, HandoffOutcome.CONFIRMED)
def test_missing_or_persistent_stock_heartbeat_never_confirms_long(self):
missing = FakeIoniq6Bus(heartbeat_source=False)
self.assertEqual(missing.run().outcome, HandoffOutcome.NOT_READY)
self.assertEqual(missing.queries, [])
persistent = FakeIoniq6Bus(heartbeat_stays_after_disable=True)
self.assertEqual(persistent.run().outcome, HandoffOutcome.STOCK)
self.assertTrue(persistent.heartbeat_source)
def test_both_stock_bsm_status_streams_must_stop_and_restore(self):
for address in (0x1BA, 0x1E5):
with self.subTest(missing=hex(address)):
missing = FakeIoniq6Bus(missing_status=address)
self.assertEqual(missing.run().outcome, HandoffOutcome.NOT_READY)
self.assertEqual(missing.queries, [])
persistent = FakeIoniq6Bus(bsm_stays_after_disable=True)
self.assertEqual(persistent.run().outcome, HandoffOutcome.STOCK)
self.assertTrue(persistent.bsm_source)
unverified = FakeIoniq6Bus(disable_ack=False, restore_works=False)
self.assertEqual(unverified.run().outcome, HandoffOutcome.UNAVAILABLE)
def test_real_card_callback_batched_producer_time_needs_fresh_arrivals(self):
from openpilot.selfdrive.car.card import can_comm_callbacks
def make_event(counter, producer_ns):
source = FakeIoniq6Bus.frame(0x1A0, 32, counter)
return SimpleNamespace(logMonoTime=producer_ns,
can=[SimpleNamespace(address=source.address, dat=source.dat, src=source.src)])
for old_burst_only in (False, True):
with self.subTest(old_burst_only=old_burst_only):
now = [0.0]
groups = [[make_event(i, 5_000_000_000 + i * 20_000_000) for i in range(10)]] if old_burst_only else [
[make_event(2 * j + i, 5_000_000_000 + (2 * j + i) * 20_000_000) for i in range(2)]
for j in range(5)]
def drain(_socket, wait_for_one=False, *, observed=now, batches=groups):
observed[0] += 0.04
return batches.pop(0) if batches else []
with patch("openpilot.selfdrive.car.card.messaging.drain_sock", side_effect=drain):
receive, _ = can_comm_callbacks(SimpleNamespace(), SimpleNamespace(send=lambda packet: None))
samples, _, _, _, _, _ = _read_window(receive, lambda observed=now: observed[0], 0.20)
if old_burst_only:
self.assertIsNone(_measured_period(samples))
else:
self.assertAlmostEqual(_measured_period(samples), 0.02, places=6)
def test_single_sub_eight_ms_stock_arrival_requires_new_preflight_window(self):
# The parked Ioniq trace had continuous +1 SCC counters, but one 7.1 ms
# producer interval in an otherwise 20 ms window. Do not lower the bound.
deltas = (0.0169, 0.0229, 0.0170, 0.0203, 0.0294, 0.0071, 0.0216,
0.0181, 0.0203, 0.0220, 0.0195, 0.0227, 0.0175)
now = 0.0
samples = [(now, 90, now)]
for index, delta in enumerate(deltas, start=1):
now += delta
samples.append((now, 90 + index, now))
self.assertIsNone(_measured_period(samples))
clean = [(index * 0.02, 110 + index, index * 0.02) for index in range(14)]
self.assertAlmostEqual(_measured_period(clean), 0.02)
def test_missing_health_or_unverified_restore_is_unavailable(self):
for options, expected in (({"missing_required": 0xEA}, HandoffOutcome.NOT_READY),
({"disable_works": False, "restore_ack": False}, HandoffOutcome.UNAVAILABLE),
({"post_missing_required": 0xEA, "restore_works": False}, HandoffOutcome.UNAVAILABLE)):
with self.subTest(options=options):
bus = FakeIoniq6Bus(**options)
self.assertEqual(bus.run().outcome, expected)
def test_get_car_hook_runs_before_interface_parser_and_controller_construction(self):
fingerprint = gen_empty_fingerprint()
fingerprint[2][0x50] = 16
fingerprint[1][0x1CF] = 8
def fake_fingerprint(*args, **kwargs):
return CAR.HYUNDAI_IONIQ_6, fingerprint, "", [], 0, True
def pre_create(cp, candidate, observed, car_fw):
self.assertEqual(candidate, CAR.HYUNDAI_IONIQ_6)
self.assertIs(observed, fingerprint)
self.assertFalse(cp.openpilotLongitudinalControl)
cp.steerAtStandstill = True
return cp
with patch("opendbc.car.car_helpers.fingerprint", fake_fingerprint):
ci = get_car(lambda wait_for_one=False: [], lambda msgs: None, lambda enabled: None,
False, False, pre_create_hook=pre_create)
self.assertTrue(ci.CP.steerAtStandstill)
self.assertTrue(ci.CS.CP.steerAtStandstill)
self.assertTrue(ci.CC.CP.steerAtStandstill)
def test_stock_ev_optional_msla_does_not_invalidate_lateral_inputs(self):
from opendbc.can import CANPacker
from opendbc.car import Bus
from opendbc.car.hyundai.values import DBC
for model in (CAR.HYUNDAI_IONIQ_6, CAR.HYUNDAI_IONIQ_5, CAR.KIA_EV6):
with self.subTest(model=model):
fp = gen_empty_fingerprint()
fp[2].update({0x50: 16, 0x2A4: 24})
fp[1].update({0x1CF: 8, 0x35: 32, 0x130: 32})
cp = CarInterface.get_params(model, fp, [], False, False, False)
ci = CarInterface(cp)
packer = CANPacker(DBC[model][Bus.pt])
ci.update((1_000_000_000, []))
sources = [(parser, state) for parser in ci.can_parsers.values()
for state in parser.message_states.values() if not state.ignore_alive and state.address != 0x2E0]
for frame in range(120):
packets = [packer.make_can_msg(state.name, parser.bus,
{"COUNTER": frame % 256} if any(sig.name == "COUNTER" for sig in state.signals) else {})
for parser, state in sources]
state = ci.update((1_000_000_000 + frame * 100_000_000, packets))
self.assertTrue(state.canValid)
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertTrue(cp.pcmCruise)
self.assertFalse(cp.dashcamOnly)
# Independent native steering inputs remain required.
for frame in range(120, 240):
packets = [packer.make_can_msg(msg.name, parser.bus,
{"COUNTER": frame % 256} if any(sig.name == "COUNTER" for sig in msg.signals) else {})
for parser, msg in sources if msg.name != "MDPS"]
state = ci.update((1_000_000_000 + frame * 100_000_000, packets))
self.assertFalse(state.canValid)
def test_card_tags_aol_candidate_before_cp_publication_without_disabling_ecu(self):
self.assert_card_prepublication_fallback({})
def test_card_missing_long_prerequisites_preserves_stock_lateral(self):
for options in ({"source": False}, {"heartbeat_source": False}, {"bsm_source": False},
{"missing_required": 0xEA}, {"missing_status": 0x1E5}):
with self.subTest(options=options):
self.assert_card_prepublication_fallback(options)
def assert_card_prepublication_fallback(self, options):
from openpilot.common.params import Params
from openpilot.selfdrive.car import card as card_module
class CandidateCaptured(Exception):
pass
fp = gen_empty_fingerprint()
fp[2].update({0x50: 16, 0x2A4: 24})
fp[0][0x3A5] = 24
fp[1].update({0x1CF: 8, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24, 0x1BA: 24, 0x1E5: 16, 0x36A: 16})
bus = FakeIoniq6Bus(**options)
stock = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fp, [], False, False, False)
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
params = Params(directory.name)
for key in ("OpenpilotEnabledToggle", "AlphaLongitudinalEnabled"):
params.put_bool(key, True, block=True)
class FakeSubMaster:
valid = {"pandaStates": True}
alive = {"pandaStates": True}
@staticmethod
def update(timeout):
pass
def __getitem__(self, key):
return [SimpleNamespace(ignitionLine=True, ignitionCan=False)]
sm = FakeSubMaster()
pm = SimpleNamespace(sock={"sendcan": object()})
def capture_get_car(*args, pre_create_hook=None, **kwargs):
self.assertIsNotNone(pre_create_hook)
selected = pre_create_hook(stock, CAR.HYUNDAI_IONIQ_6, fp, [])
self.assertEqual(selected.carFingerprint, CAR.HYUNDAI_IONIQ_6)
self.assertFalse(selected.dashcamOnly)
self.assertEqual(selected.safetyConfigs[-1].safetyParam, 0x11 if options else 0x8815)
self.assertEqual(selected.openpilotLongitudinalControl, not bool(options))
self.assertEqual(selected.pcmCruise, bool(options))
interface = CarInterface(selected)
parser = interface.CS.get_can_parsers(selected)
from opendbc.car import Bus
if options:
self.assertNotIn(0x2A4, parser[Bus.cam].message_states)
self.assertEqual(parser[Bus.pt].message_states[0x1CF].frequency, 1)
else:
self.assertEqual(parser[Bus.cam].message_states[0x2A4].frequency, 20)
raise CandidateCaptured
def capture_preparation(stock_cp, long_cp, **kwargs):
if options:
self.assertIsNone(long_cp)
else:
self.assertEqual(long_cp.safetyConfigs[-1].safetyParam, 0x8815)
self.assertTrue(kwargs["enabled"])
return prepare_ioniq6_long_candidate(stock_cp, long_cp, **kwargs)
with patch.dict("os.environ", {"AOL_REPLAY_RUNTIME": ""}), \
patch.object(card_module, "feature_requested", side_effect=lambda _params, feature: feature == 'aol'), \
patch.object(card_module, "prewarm_cache_contracts"), \
patch.object(card_module.messaging, "sub_sock", return_value=object()), \
patch.object(card_module.messaging, "SubMaster", return_value=sm), \
patch.object(card_module.messaging, "PubMaster", return_value=pm), \
patch.object(card_module.messaging, "recv_one_retry", return_value=SimpleNamespace(can=[1])), \
patch.object(card_module, "Params", return_value=params), \
patch.object(card_module, "startup_candidate", return_value=None), \
patch.object(card_module, "get_cache", return_value=None), \
patch.object(card_module, "can_comm_callbacks", return_value=(lambda wait_for_one=False: [], lambda msgs: None)), \
patch.object(card_module, "read_settings", return_value=object()), \
patch.object(card_module, "independent_axis_requested", return_value=True), \
patch.object(card_module, "inspect_ioniq6_long_sources",
side_effect=lambda recv: inspect_ioniq6_long_sources(bus.recv, lambda: bus.now)), \
patch.object(card_module, "prepare_ioniq6_long_candidate", side_effect=capture_preparation), \
patch.object(card_module, "confirm_ioniq6_prepared_takeover") as confirm, \
patch.object(card_module, "get_car", side_effect=capture_get_car):
with self.assertRaises(CandidateCaptured):
card_module.Car()
confirm.assert_not_called()
self.assertEqual(bus.queries, [])
def test_prepublication_candidate_is_selected_only_after_complete_takeover(self):
for bus_options, expected in (({}, HandoffOutcome.CONFIRMED),
({"disable_works": False}, HandoffOutcome.STOCK),
({"post_missing_required": 0xEA, "restore_works": False}, HandoffOutcome.UNAVAILABLE)):
with self.subTest(bus_options=bus_options):
bus = FakeIoniq6Bus(**bus_options)
stock = SimpleNamespace(dashcamOnly=False)
long = SimpleNamespace(openpilotLongitudinalControl=True,
safetyConfigs=[SimpleNamespace(safetyParam=0x8015)])
with patch("opendbc.car.hyundai.ioniq6_handoff.query_ioniq6_adas",
side_effect=lambda recv, send, request, response, active_bus=bus: active_bus.exchange(request, response)):
selected, prearmed, result = finalize_ioniq6_prepublication(
stock, long, bus.recv, lambda msgs: None, enabled=True, is_release=False, clock=lambda active_bus=bus: active_bus.now)
self.assertEqual(result.outcome, expected)
self.assertIs(selected, long if expected is HandoffOutcome.CONFIRMED else stock)
self.assertEqual(prearmed, expected is HandoffOutcome.CONFIRMED)
self.assertEqual(stock.dashcamOnly, expected is HandoffOutcome.UNAVAILABLE)
def test_preparation_selects_immutable_candidate_without_source_transaction(self):
stock = SimpleNamespace()
prepared = SimpleNamespace(openpilotLongitudinalControl=True,
safetyConfigs=[SimpleNamespace(safetyParam=0x8895)])
selected, pending = prepare_ioniq6_long_candidate(stock, prepared, enabled=True, is_release=False)
self.assertIs(selected, prepared)
self.assertTrue(pending)
self.assertEqual(prepare_ioniq6_long_candidate(stock, prepared, enabled=False, is_release=False), (stock, False))
self.assertEqual(prepare_ioniq6_long_candidate(stock, prepared, enabled=True, is_release=True), (stock, False))
prepared.safetyConfigs[-1].safetyParam = 0x0015
with self.assertRaisesRegex(ValueError, 'unreviewed'):
prepare_ioniq6_long_candidate(stock, prepared, enabled=True, is_release=False)
def test_release_and_unreviewed_raw_do_not_request_takeover(self):
stock = SimpleNamespace(dashcamOnly=False)
long = SimpleNamespace(openpilotLongitudinalControl=True, safetyConfigs=[SimpleNamespace(safetyParam=0x8015)])
bus = FakeIoniq6Bus()
selected, armed, result = finalize_ioniq6_prepublication(
stock, long, bus.recv, lambda msgs: None, enabled=True, is_release=True, clock=lambda: bus.now)
self.assertIs(selected, stock)
self.assertFalse(armed)
self.assertIsNone(result)
self.assertEqual(bus.queries, [])
long.safetyConfigs[-1].safetyParam = 0x0015
with self.assertRaises(ValueError):
finalize_ioniq6_prepublication(stock, long, bus.recv, lambda msgs: None,
enabled=True, is_release=False, clock=lambda: bus.now)
def test_long_candidate_exact_topology_is_prepared_before_diagnostics(self):
for steering_addr, steering_len, support_addr, support_len, raw in ((0x50, 16, 0x2A4, 24, 0x8015),
(0x110, 32, 0x362, 32, 0x8095)):
with self.subTest(steering_addr=steering_addr):
fp = gen_empty_fingerprint()
fp[2][steering_addr] = steering_len
fp[2][support_addr] = support_len
fp[0][0x3A5] = 24
# The public Ioniq 6 LKA fixture contains both 0x1CF and 0x1AA; the
# selected standard-button profile is determined by the actual 0x1CF
# source and stock safety mode, not mere 0x1AA presence.
fp[1].update({0x1CF: 8, 0x1AA: 16, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24,
0x1BA: 24, 0x1E5: 16, 0x36A: 16})
stock = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fp, [], False, False, False)
candidate = build_ioniq6_hda2_long_candidate(stock, fp)
self.assertIsNotNone(candidate)
self.assertFalse(stock.openpilotLongitudinalControl)
self.assertTrue(stock.pcmCruise)
self.assertTrue(candidate.openpilotLongitudinalControl)
self.assertFalse(candidate.pcmCruise)
self.assertEqual(candidate.safetyConfigs[-1].safetyParam, raw)
fp[2][support_addr] = 8
self.assertIsNone(build_ioniq6_hda2_long_candidate(stock, fp))
def test_real_isotp_adapter_keeps_interleaved_scc_evidence(self):
pending = []
def can_recv(wait_for_one=False):
ret = [pending[:]] if pending else []
pending.clear()
return ret
def can_send(msgs):
for msg in msgs:
request_len = msg.dat[0]
request = msg.dat[1:request_len + 1]
response = {b"\x10\x03": b"\x50\x03\x00\x32\x01\xf4", b"\x28\x01\x01": b"\x68\x01",
b"\x10\x01": b"\x50\x01\x00\x32\x01\xf4", b"\x28\x00\x01": b"\x68\x00"}[request]
heartbeat = FakeIoniq6Bus.frame(0x100, 24, 9)
pending.extend((FakeIoniq6Bus.frame(0x1A0, 32, 9), CanData(0x100, heartbeat.dat, 0),
CanData(0x738, bytes([len(response)]) + response + bytes(7 - len(response)), 1)))
observed = ObservingCanRecv(can_recv)
self.assertTrue(query_ioniq6_adas(observed, can_send, b"\x10\x03", b"\x50\x03"))
self.assertTrue(query_ioniq6_adas(observed, can_send, b"\x28\x01\x01", b"\x68\x01"))
self.assertTrue(query_ioniq6_adas(observed, can_send, b"\x28\x00\x01", b"\x68\x00"))
self.assertTrue(query_ioniq6_adas(observed, can_send, b"\x10\x01", b"\x50\x01"))
self.assertEqual(observed.source_seen_count, 4)
self.assertEqual(observed.heartbeat_seen_count, 4)
self.assertIsNotNone(observed.latest_source_time)
def test_uds_response_shape_rejects_extra_communication_control_payload(self):
pending = []
def recv(wait_for_one=False):
ret = [pending[:]] if pending else []
pending.clear()
return ret
def send(msgs):
pending.append(CanData(0x738, b"\x03\x68\x01\x99\x00\x00\x00\x00", 1))
self.assertFalse(query_ioniq6_adas(recv, send, b"\x28\x01\x01", b"\x68\x01"))
def test_keepalive_aborts_at_bounded_deadline_without_claiming_stock(self):
now = [0.0]
sent = []
restored = []
aborted = []
def restore():
restored.append(True)
return False
owner = Ioniq6StartupKeepalive(
lambda messages: sent.extend(messages), restore, lambda: aborted.append(True),
lambda verified: aborted.append(verified), clock=lambda: now[0], max_startup_sec=3.0)
owner.tick()
self.assertEqual((sent[-1].address, sent[-1].src, sent[-1].dat), (0x730, 1, b"\x02\x3e\x80\x00\x00\x00\x00\x00"))
now[0] = 0.5
owner.tick()
self.assertEqual(len(sent), 2)
now[0] = 3.0
owner.tick()
self.assertEqual(restored, [True])
self.assertEqual(aborted, [True, False])
owner.tick()
self.assertEqual(len(sent), 2)
def test_keepalive_stop_waits_for_inflight_send_before_controller_owner(self):
entered = threading.Event()
release = threading.Event()
owner_done = threading.Event()
sends = []
def blocking_send(messages):
entered.set()
self.assertTrue(release.wait(2))
sends.extend(messages)
owner = Ioniq6StartupKeepalive(blocking_send, lambda: True, lambda: None, lambda verified: None)
tick = threading.Thread(target=owner.tick)
tick.start()
self.assertTrue(entered.wait(2))
stopper = threading.Thread(target=lambda: (owner.stop(), owner_done.set()))
stopper.start()
self.assertFalse(owner_done.wait(0.05))
release.set()
self.assertTrue(owner_done.wait(2))
tick.join(2)
stopper.join(2)
self.assertEqual(len(sends), 1)
owner.tick()
self.assertEqual(len(sends), 1)
def test_timeout_latches_loss_before_restore_and_stop_waits_for_restore(self):
now = [0.0]
restoring = threading.Event()
release = threading.Event()
stopped = threading.Event()
lost = []
def restore():
self.assertEqual(lost, [True])
restoring.set()
self.assertTrue(release.wait(2))
return False
owner = Ioniq6StartupKeepalive(lambda messages: None, restore, lambda: lost.append(True),
lambda verified: None, clock=lambda: now[0], max_startup_sec=1.0)
now[0] = 1.0
tick = threading.Thread(target=owner.tick)
tick.start()
self.assertTrue(restoring.wait(2))
stopper = threading.Thread(target=lambda: (owner.stop(), stopped.set()))
stopper.start()
self.assertFalse(stopped.wait(0.05))
release.set()
self.assertTrue(stopped.wait(2))
tick.join(2)
stopper.join(2)
self.assertEqual(lost, [True])
def test_card_warmup_and_late_source_are_separate_states(self):
from openpilot.selfdrive.car.card import Car
card = Car.__new__(Car)
card.ioniq6_long_prearmed = True
card.ioniq6_host_warmed = False
card.ioniq6_long_lost = False
card.ioniq6_keepalive = None
invalid = SimpleNamespace(canValid=False)
card.observe_ioniq6_long_authority([], invalid)
self.assertFalse(card.ioniq6_long_lost)
self.assertFalse(card.ioniq6_host_warmed)
valid = SimpleNamespace(canValid=True)
card.observe_ioniq6_long_authority([], valid)
self.assertTrue(card.ioniq6_host_warmed)
card.observe_ioniq6_long_authority([], invalid)
self.assertTrue(card.ioniq6_long_lost)
self.assertFalse(invalid.canValid)
card.ioniq6_long_lost = False
source = CanData(0x1A0, b"\x00" * 8, 1)
card.observe_ioniq6_long_authority([(1_000_000_000, [source])], valid)
self.assertTrue(card.ioniq6_long_lost)
self.assertFalse(valid.canValid)
@staticmethod
def ready_card(*, ioniq_selected: bool):
from openpilot.selfdrive.car.card import Car
class FakeSM:
def __init__(self):
self.valid = {'carControl': False, 'pandaStates': True}
self.alive = {'pandaStates': True}
self.panda = [SimpleNamespace(safetyModel=3, safetyParam=1, controlsAllowed=False)]
def all_alive(self, services):
return all(self.valid[service] for service in services)
def __getitem__(self, key):
return self.panda if key == 'pandaStates' else None
card = Car.__new__(Car)
card.ci_initialized = False
card.initialized_prev = False
card.ioniq6_long_selected = ioniq_selected
card.ioniq6_long_pending = ioniq_selected
card.ioniq6_last_preflight_at = 0.0
card.ioniq6_long_prearmed = False
card.ioniq6_long_lost = False
card.ioniq6_host_warmed = False
card.ioniq6_panda_armed = False
card.ioniq6_keepalive = None
card.ioniq6_restore_callbacks = None
card.ioniq6_handoff_result = None
card.can_callbacks = (lambda wait_for_one=False: [], lambda messages: None)
card.params = SimpleNamespace(put_bool=Mock())
card.CP = SimpleNamespace(safetyConfigs=[object()])
card.CI = SimpleNamespace(init=Mock(), apply=Mock(return_value=(object(), [])))
card.sm = FakeSM()
card.publish_sendcan = Mock()
card.ioniq6_panda_matches = Mock(return_value=False)
return card
def test_delayed_ioniq_takeover_waits_for_control_and_can_then_panda(self):
from openpilot.selfdrive.car import card as card_module
card = self.ready_card(ioniq_selected=True)
cs = SimpleNamespace(canValid=True, canTimeout=False)
cc = object()
confirmed = HandoffResult(HandoffOutcome.CONFIRMED, 'confirmed')
with (patch.object(card_module, 'confirm_ioniq6_prepared_takeover', return_value=confirmed) as takeover,
patch.object(card_module.cloudlog, 'event') as logged):
for _ in range(200): # Delayed model/control startup cannot disable the ECU.
card.controls_update(cs, cc)
takeover.assert_not_called()
card.params.put_bool.assert_not_called()
card.CI.apply.assert_not_called()
card.sm.valid['carControl'] = True
cs.canValid = False
card.controls_update(cs, cc)
takeover.assert_not_called()
cs.canValid = True
card.controls_update(cs, cc)
takeover.assert_called_once_with(*card.can_callbacks)
self.assertEqual(logged.call_args.args, ('ioniq6_long_handoff',))
self.assertEqual(logged.call_args.kwargs['outcome'], 'CONFIRMED')
card.params.put_bool.assert_called_once_with('ControlsReady', True)
card.CI.init.assert_not_called()
card.CI.apply.assert_not_called() # Panda is still in its prior mode.
card.ioniq6_panda_matches.return_value = True
card.controls_update(cs, cc)
card.CI.apply.assert_not_called()
self.assertFalse(card.ioniq6_host_warmed)
cs.canValid = False # Transaction consumed the old CAN cursor.
card.observe_ioniq6_long_authority([], cs)
self.assertFalse(card.ioniq6_long_lost)
card.controls_update(cs, cc)
card.CI.apply.assert_not_called()
cs.canValid = True
card.observe_ioniq6_long_authority([], cs)
card.controls_update(cs, cc)
card.CI.apply.assert_called_once()
cs.canValid = False
card.observe_ioniq6_long_authority([], cs)
self.assertTrue(card.ioniq6_long_lost)
card.controls_update(cs, cc)
card.CI.apply.assert_called_once()
self.assertTrue(card.ioniq6_long_prearmed)
self.assertTrue(card.ioniq6_panda_armed)
def test_failed_delayed_takeover_never_acts_or_publishes_valid_carstate(self):
from openpilot.selfdrive.car import card as card_module
card = self.ready_card(ioniq_selected=True)
card.sm.valid['carControl'] = True
cs = SimpleNamespace(canValid=True, canTimeout=False)
cc = object()
failed = HandoffResult(HandoffOutcome.UNAVAILABLE, 'restore unverified')
with (patch.object(card_module, 'confirm_ioniq6_prepared_takeover', return_value=failed) as takeover,
patch.object(card_module.cloudlog, 'event') as logged):
card.controls_update(cs, cc)
self.assertFalse(cs.canValid)
card.params.put_bool.assert_not_called()
card.CI.apply.assert_not_called()
for _ in range(10):
cs.canValid = True # The next raw CAN frame is valid but CP is still LONG.
card.observe_ioniq6_long_authority([], cs)
self.assertFalse(cs.canValid)
card.controls_update(cs, cc)
takeover.assert_called_once()
self.assertEqual(logged.call_args.kwargs['reason'], 'restore unverified')
card.CI.apply.assert_not_called()
def test_pending_takeover_requires_live_diagnostic_panda_and_neutral_controls(self):
from openpilot.selfdrive.car import card as card_module
card = self.ready_card(ioniq_selected=True)
card.sm.valid['carControl'] = True
cs = SimpleNamespace(canValid=True, canTimeout=False)
cc = object()
confirmed = HandoffResult(HandoffOutcome.CONFIRMED, 'confirmed')
with patch.object(card_module, 'confirm_ioniq6_prepared_takeover', return_value=confirmed) as takeover:
for mutation in (
lambda: card.sm.valid.__setitem__('pandaStates', False),
lambda: card.sm.alive.__setitem__('pandaStates', False),
lambda: setattr(card.sm.panda[0], 'safetyModel', 28),
lambda: setattr(card.sm.panda[0], 'safetyParam', 2),
lambda: setattr(card.sm.panda[0], 'controlsAllowed', True),
lambda: card.sm.panda.append(SimpleNamespace(safetyModel=3, safetyParam=1, controlsAllowed=False)),
):
card.sm.valid['pandaStates'] = True
card.sm.alive['pandaStates'] = True
card.sm.panda[:] = [SimpleNamespace(safetyModel=3, safetyParam=1, controlsAllowed=False)]
mutation()
card.controls_update(cs, cc)
takeover.assert_not_called()
self.assertTrue(card.ioniq6_long_pending)
card.params.put_bool.assert_not_called()
card.CI.apply.assert_not_called()
for diagnostic_param in (0, 1):
next_card = self.ready_card(ioniq_selected=True)
next_card.sm.valid['carControl'] = True
next_card.sm.panda[0].safetyParam = diagnostic_param
next_card.controls_update(SimpleNamespace(canValid=True, canTimeout=False), cc)
self.assertTrue(next_card.ioniq6_long_prearmed)
next_card.params.put_bool.assert_called_once_with('ControlsReady', True)
self.assertEqual(takeover.call_count, 2)
def test_preflight_miss_retries_without_uds_or_permanent_latch(self):
from openpilot.selfdrive.car import card as card_module
card = self.ready_card(ioniq_selected=True)
card.sm.valid['carControl'] = True
cs = SimpleNamespace(canValid=True, canTimeout=False)
cc = object()
clock = [100.0]
waiting = HandoffResult(HandoffOutcome.NOT_READY, 'preflight_scc_period')
confirmed = HandoffResult(HandoffOutcome.CONFIRMED, 'confirmed')
with (patch.object(card_module, 'confirm_ioniq6_prepared_takeover', side_effect=(waiting, confirmed)) as takeover,
patch.object(card_module.time, 'monotonic', side_effect=lambda: clock[0])):
card.controls_update(cs, cc)
self.assertTrue(card.ioniq6_long_pending)
self.assertFalse(card.ioniq6_long_lost)
self.assertTrue(cs.canValid)
card.params.put_bool.assert_not_called()
card.CI.apply.assert_not_called()
self.assertEqual(takeover.call_count, 1)
clock[0] += 0.2
card.controls_update(cs, cc)
self.assertEqual(takeover.call_count, 1)
clock[0] += 0.4
card.controls_update(cs, cc)
self.assertEqual(takeover.call_count, 2)
self.assertFalse(card.ioniq6_long_pending)
self.assertTrue(card.ioniq6_long_prearmed)
card.params.put_bool.assert_called_once_with('ControlsReady', True)
card.CI.apply.assert_not_called() # Hyundai safety has not switched yet.
def test_generic_ecu_init_waits_for_valid_can_and_live_control_once(self):
card = self.ready_card(ioniq_selected=False)
cs = SimpleNamespace(canValid=True, canTimeout=False)
cc = object()
for _ in range(20):
card.controls_update(cs, cc)
card.CI.init.assert_not_called()
card.sm.valid['carControl'] = True
cs.canValid = False
card.controls_update(cs, cc)
card.CI.init.assert_not_called()
cs.canValid = True
card.controls_update(cs, cc)
card.controls_update(cs, cc)
card.CI.init.assert_called_once_with(card.CP, *card.can_callbacks)
card.params.put_bool.assert_called_once_with('ControlsReady', True)
self.assertEqual(card.CI.apply.call_count, 2)
def test_card_late_source_reads_actual_can_converter_tuples(self):
from openpilot.selfdrive.car.card import Car
from openpilot.selfdrive.pandad import can_capnp_to_list, can_list_to_can_capnp
card = Car.__new__(Car)
card.ioniq6_long_prearmed = True
card.ioniq6_host_warmed = True
card.ioniq6_long_lost = False
card.ioniq6_keepalive = None
valid = SimpleNamespace(canValid=True)
def observe(frames):
packets = can_capnp_to_list([can_list_to_can_capnp(frames)])
self.assertIs(type(packets[0][1][0]), tuple)
card.observe_ioniq6_long_authority(packets, valid)
observe([CanData(0x35, b'\0' * 32, 1), CanData(0x1A0, b'\0' * 32, 0)])
self.assertFalse(card.ioniq6_long_lost) # The SCC address on the wrong bus is not a source.
observe([CanData(0x1A0, b'\0' * 32, 1)])
self.assertTrue(card.ioniq6_long_lost)
self.assertFalse(valid.canValid)
card.ioniq6_long_lost = False
valid.canValid = True
heartbeat = CanData(0x100, b"\x00" * 8, 0)
card.observe_ioniq6_long_authority([(1_000_000_000, [heartbeat])], valid)
self.assertTrue(card.ioniq6_long_lost)
self.assertFalse(valid.canValid)
def test_card_serialized_receive_publish_send_and_source_revocation(self):
from opendbc.car import structs
from opendbc.car.structs import car
from openpilot.cereal import messaging
from openpilot.selfdrive.car import card as card_module
from openpilot.selfdrive.pandad import can_capnp_to_list, can_list_to_can_capnp
card_owner = card_module.Car.__new__(card_module.Car)
cp = car.CarParams(openpilotLongitudinalControl=True, pcmCruise=False, passive=False)
cp.init('safetyConfigs', 1)
card_owner.CP = cp
card_owner.ci_initialized = False
card_owner.initialized_prev = False
card_owner.ioniq6_long_selected = True
card_owner.ioniq6_long_pending = True
card_owner.ioniq6_last_preflight_at = 0.0
card_owner.ioniq6_long_prearmed = False
card_owner.ioniq6_long_lost = False
card_owner.ioniq6_host_warmed = False
card_owner.ioniq6_panda_armed = False
card_owner.ioniq6_keepalive = None
card_owner.ioniq6_restore_callbacks = None
card_owner.ioniq6_restore_attempted = False
card_owner.can_rcv_cum_timeout_counter = 0
card_owner.can_sock = object()
card_owner.can_callbacks = (lambda wait_for_one=False: [], lambda frames: None)
card_owner.params = SimpleNamespace(put_bool=Mock())
card_owner.slc_replay = card_owner.curve_replay = card_owner.conditional_replay = card_owner.aol_replay = False
card_owner.aol_card_intent = None
card_owner.ioniq6_media = None
card_owner.CC_prev = car.CarControl()
card_owner.CS_prev = car.CarState()
card_owner.last_actuators_output = structs.CarControl.Actuators()
card_owner.rk = SimpleNamespace(remaining=0.0)
card_owner.is_metric = True
card_owner.experimental_mode = False
card_owner.v_cruise_helper = SimpleNamespace(update_v_cruise=lambda *_: None, initialize_v_cruise=lambda *_: None,
slc_consumed_button=None,
slc_cruise_change=None, v_cruise_kph=60.0, v_cruise_cluster_kph=60.0)
card_owner.RI = SimpleNamespace(update=lambda _: None)
control = car.CarControl(enabled=True, longActive=True)
class SM:
frame = 1
seen = {'onroadEvents': True}
valid = {'carControl': True}
alive = {'carControl': True}
logMonoTime = {'carControl': 1}
def update(self, _timeout):
pass
def __getitem__(self, name):
return [] if name == 'onroadEvents' else control
def all_alive(self, names):
return all(self.alive[name] for name in names)
def all_checks(self, names):
return all(self.valid[name] for name in names)
card_owner.sm = SM()
sent = []
card_owner.pm = SimpleNamespace(send=lambda service, packet: sent.append((service, packet)))
card_owner.CI = SimpleNamespace(CS=SimpleNamespace(),
update=Mock(side_effect=lambda packets: car.CarState(canValid=True, canTimeout=False)),
apply=Mock(return_value=(structs.CarControl.Actuators(), [CanData(0x1A0, b'\0' * 8, 1)])),
init=Mock())
card_owner.ioniq6_panda_diagnostic_ready = Mock(return_value=True)
card_owner.ioniq6_panda_matches = Mock(side_effect=(False, True, True))
packets = iter((
can_list_to_can_capnp([CanData(0x35, b'\0' * 32, 1)]),
can_list_to_can_capnp([CanData(0x35, b'\0' * 32, 1)]),
can_list_to_can_capnp([CanData(0x1A0, b'\0' * 32, 1)]),
))
with (patch.object(card_module.messaging, 'drain_sock_raw', side_effect=lambda *_args, **_kwargs: [next(packets)]),
patch.object(card_module, 'confirm_ioniq6_prepared_takeover', return_value=HandoffResult(HandoffOutcome.CONFIRMED, 'confirmed')),
patch.object(card_module.cloudlog, 'event')):
card_owner.step()
card_owner.step()
card_owner.step()
card_owner.CI.init.assert_not_called()
self.assertEqual(card_owner.CI.apply.call_count, 1)
self.assertEqual(card_owner.CI.update.call_count, 3)
self.assertTrue(all(type(group[0][1][0]) is tuple for (group,) in (call.args for call in card_owner.CI.update.call_args_list)))
outputs = [(messaging.log_from_bytes(packet).valid, can_capnp_to_list([packet], msgtype='sendcan')[0][1])
for service, packet in sent if service == 'sendcan']
self.assertEqual([(valid, [frame[0] for frame in frames]) for valid, frames in outputs],
[(False, []), (True, [0x1A0]), (False, [])])
published = [packet for service, packet in sent if service == 'carState']
self.assertEqual([message.valid for message in published], [True, True, False])
self.assertTrue(card_owner.ioniq6_long_lost)
with (patch.object(card_module, 'config_realtime_process'),
patch.object(card_module.Car, '__new__', return_value=card_owner),
patch.object(card_module.Car, '__init__', return_value=None),
patch.object(card_module.Car, 'card_thread', return_value=None),
patch.object(card_module, 'restore_ioniq6_adas', return_value=True) as restore):
card_module.main()
restore.assert_called_once_with(*card_owner.can_callbacks)
def test_card_requires_fresh_matching_panda_mode(self):
from openpilot.selfdrive.car.card import Car
class PandaSM:
def __init__(self):
self.valid = {"pandaStates": True}
self.alive = {"pandaStates": True}
self.states = [SimpleNamespace(safetyModel=28, safetyParam=0x8015,
alternativeExperience=0, safetyRxChecksInvalid=False)]
def __getitem__(self, key):
return self.states
card = Car.__new__(Car)
card.sm = PandaSM()
card.CP = SimpleNamespace(safetyConfigs=[SimpleNamespace(safetyModel=28, safetyParam=0x8015)],
alternativeExperience=0)
self.assertTrue(card.ioniq6_panda_matches())
card.sm.alive["pandaStates"] = False
self.assertFalse(card.ioniq6_panda_matches())
card.sm.alive["pandaStates"] = True
card.sm.states[0].safetyParam = 0x11
self.assertFalse(card.ioniq6_panda_matches())
def test_main_stops_diagnostic_worker_after_partial_constructor_failure(self):
from openpilot.selfdrive.car import card as card_module
stopped = []
def fail_after_prearm(card):
card.ioniq6_keepalive = SimpleNamespace(stop=lambda: stopped.append(True))
card.ioniq6_long_lost = False
card.ioniq6_long_prearmed = True
card.ioniq6_init_complete = False
card.ioniq6_restore_callbacks = (lambda wait_for_one=False: [], lambda messages: None)
raise RuntimeError("failed after ECU handoff")
with (patch.object(card_module, "config_realtime_process"), patch.object(card_module.Car, "__init__", fail_after_prearm),
patch.object(card_module, "restore_ioniq6_adas", return_value=True) as restore):
with self.assertRaisesRegex(RuntimeError, "failed after ECU handoff"):
card_module.main()
self.assertEqual(stopped, [True])
restore.assert_called_once()
def test_main_restores_after_post_init_exception_and_normal_shutdown(self):
from openpilot.selfdrive.car import card as card_module
def check_case(outcome, restored):
cards = []
stopped = []
callbacks = (lambda wait_for_one=False: [], lambda messages: None)
def prepared(card):
cards.append(card)
card.ioniq6_keepalive = SimpleNamespace(stop=lambda: stopped.append(True))
card.ioniq6_long_lost = False
card.ioniq6_long_prearmed = True
card.ioniq6_init_complete = True
card.ioniq6_restore_callbacks = callbacks
card.ioniq6_restore_attempted = False
def run(_card):
if outcome == 'crash':
raise RuntimeError('failed after CP publication')
with (patch.object(card_module, 'config_realtime_process'), patch.object(card_module.Car, '__init__', prepared),
patch.object(card_module.Car, 'card_thread', run),
patch.object(card_module, 'restore_ioniq6_adas', return_value=restored) as restore,
patch.object(card_module.cloudlog, 'error') as logged):
if outcome == 'crash':
with self.assertRaisesRegex(RuntimeError, 'failed after CP publication'):
card_module.main()
else:
card_module.main()
self.assertTrue(cards[0].ioniq6_long_lost)
self.assertTrue(cards[0].ioniq6_restore_attempted)
self.assertEqual(stopped, [True])
restore.assert_called_once_with(*callbacks)
if restored:
logged.assert_not_called()
else:
logged.assert_called_once_with('Ioniq 6 Card exited; stock SCC restoration unverified')
for outcome, restored in (('crash', True), ('shutdown', True), ('crash', False)):
with self.subTest(outcome=outcome, restored=restored):
check_case(outcome, restored)
def test_main_does_not_repeat_keepalive_restore(self):
from openpilot.selfdrive.car import card as card_module
def prepared(card):
card.ioniq6_keepalive = SimpleNamespace(stop=lambda: None)
card.ioniq6_long_lost = False
card.ioniq6_long_prearmed = True
card.ioniq6_restore_callbacks = (lambda wait_for_one=False: [], lambda messages: None)
card.ioniq6_restore_attempted = True # Timeout worker already restored or tried to restore.
with (patch.object(card_module, 'config_realtime_process'), patch.object(card_module.Car, '__init__', prepared),
patch.object(card_module.Car, 'card_thread', lambda _card: None),
patch.object(card_module, 'restore_ioniq6_adas') as restore):
card_module.main()
restore.assert_not_called()
def test_same_card_can_cursor_consumes_pre_disable_scc_before_state_update(self):
from openpilot.selfdrive.car.card import can_comm_callbacks
bus = FakeIoniq6Bus()
bus.counter = 1
pending = [[FakeIoniq6Bus.frame(0x1A0, 32, 0)]]
def drain(_socket, wait_for_one=False):
if pending:
packet = pending.pop(0)
bus.now += 0.02
frames = [packet]
else:
frames = bus.recv(wait_for_one)
return [SimpleNamespace(logMonoTime=int(bus.now * 1e9),
can=[SimpleNamespace(address=m.address, dat=m.dat, src=m.src) for m in packet])
for packet in frames]
with patch("openpilot.selfdrive.car.card.messaging.drain_sock", side_effect=drain):
receive, _ = can_comm_callbacks(SimpleNamespace(), SimpleNamespace(send=lambda packet: None))
result = run_ioniq6_handoff(receive, bus.exchange, lambda: bus.now)
self.assertEqual(result.outcome, HandoffOutcome.CONFIRMED)
self.assertEqual(pending, [])
self.assertFalse(any(m.address == 0x1A0 for packet in receive() for m in packet))
@@ -0,0 +1,327 @@
import hashlib
import json
from pathlib import Path
from types import SimpleNamespace
import unittest
from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.hyundaicanfd import CanBus, create_acc_control, create_ioniq6_radar_heartbeat
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.ioniq6_handoff import build_ioniq6_hda2_long_candidate
from opendbc.car.hyundai.ioniq6_longitudinal import Ioniq6LongitudinalPolicy, Ioniq6LongitudinalState, LongState, update_calibration
from opendbc.car.hyundai.values import CAR, DBC
from openpilot.selfdrive.controls.lib.longcontrol import LongControl
from openpilot.starpilot.longitudinal.ioniq6_start import StartEvidence
DATA = Path(__file__).parent / 'testdata'
def controller_fixture(alternate=False, *, aol=False):
fingerprint = gen_empty_fingerprint()
fingerprint[2].update({0x110: 32, 0x362: 32} if alternate else {0x50: 16, 0x2A4: 24})
fingerprint[1].update({0x1CF: 8, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24,
0x1BA: 24, 0x1E5: 16, 0x36A: 16})
fingerprint[0][0x3A5] = 24
fingerprint[0][0x100] = 24
stock = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fingerprint, [], False, False, False)
cp = build_ioniq6_hda2_long_candidate(stock, fingerprint)
if aol:
cp.safetyConfigs[-1].safetyParam |= 0x0800
ci = CarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
bus = CanBus(cp)
inputs = [packer.make_can_msg(name, bus.ECAN, values) for name, values in (
('ACCELERATOR', {'GEAR': 5}), ('TCS', {'ACCEnable': 0, 'ACC_REQ': 1}),
('WHEEL_SPEEDS', {}), ('MDPS', {}), ('CRUISE_BUTTONS', {'COUNTER': 1}),
('DOORS_SEATBELTS', {'DRIVER_SEATBELT': 1}), ('STEERING_SENSORS', {}))]
inputs.append(packer.make_can_msg('CAM_0x362' if alternate else 'CAM_0x2a4', bus.CAM, {}))
ci.update((1_000_000_000, inputs))
return cp, ci.CS, CarController(DBC[cp.carFingerprint], cp)
class TestIoniq6Longitudinal(unittest.TestCase):
def test_active_calibration_matches_continuous_corrected_frozen_reference(self):
reference = json.loads((DATA / 'ioniq6_long_calibration.json').read_text())
corrected = json.loads((DATA / 'ioniq6_long_calibration_soft_floor.json').read_text())
self.assertEqual(corrected['originalTraceSha256'], hashlib.sha256((DATA / 'ioniq6_long_calibration.json').read_bytes()).hexdigest())
# Independently derived from committed prechange calibration by adding
# only the approved below-floor revocation. Keep every state transition
# continuous across both historical soft-start windows.
changed_rows = [index for index, (old, new) in enumerate(zip(reference['outputs'], corrected['outputs'], strict=True)) if old != new]
self.assertEqual(changed_rows, list(range(12)) + list(range(76, 90)))
state = Ioniq6LongitudinalState()
for row, expected in zip(reference['inputs'], corrected['outputs'], strict=True):
accel, speed, measured_accel, mode = row
update_calibration(state, accel, speed, measured_accel, getattr(LongState, mode))
actual = (state.desired_accel, state.actual_accel, state.accel_last, state.jerk_upper,
state.jerk_lower, state.launch_active, state.stopping, int(state.long_control_state_last))
for value, previous in zip(actual, expected, strict=True):
self.assertAlmostEqual(value, previous, places=12)
def test_soft_target_revokes_launch_on_offcycle_and_scheduled_ticks(self):
for drop_frame in (31, 35):
with self.subTest(drop_frame=drop_frame):
policy = Ioniq6LongitudinalPolicy()
last_sent = 0.0
for frame in range(drop_frame):
request = policy.update(frame, active=True, override=False, accel=1.2, speed=0.0,
measured_accel=0.0, control_state=LongState.starting, last_sent_accel=last_sent)
if frame % 2 == 0:
last_sent = request.accel
self.assertTrue(policy.state.launch_active)
soft = policy.update(drop_frame, active=True, override=False, accel=.2, speed=0.0,
measured_accel=0.0, control_state=LongState.starting, last_sent_accel=last_sent)
self.assertFalse(policy.state.launch_active)
self.assertLess(soft.accel, last_sent)
self.assertGreater(soft.accel, .2) # Residual demand is rate-limited, not abruptly clipped.
self.assertLessEqual(soft.accel, last_sent + 1e-12)
negative = policy.update(drop_frame + 1, active=True, override=False, accel=-1.0, speed=0.0,
measured_accel=0.0, control_state=LongState.starting, last_sent_accel=soft.accel)
self.assertFalse(policy.state.launch_active)
self.assertLess(negative.accel, soft.accel)
self.assertGreater(negative.accel, -1.0)
policy.update(drop_frame + 2, active=True, override=False, accel=1.2, speed=0.0,
measured_accel=0.0, control_state=LongState.starting, last_sent_accel=negative.accel)
if (drop_frame + 2) % 5 != 0:
self.assertFalse(policy.state.launch_active)
for frame in range(drop_frame + 3, drop_frame + 8):
policy.update(frame, active=True, override=False, accel=1.2, speed=0.0,
measured_accel=0.0, control_state=LongState.starting, last_sent_accel=negative.accel)
self.assertTrue(policy.state.launch_active)
policy.update(drop_frame + 8, active=True, override=True, accel=1.2, speed=0.0,
measured_accel=0.0, control_state=LongState.starting, last_sent_accel=0.0)
self.assertEqual(policy.state, Ioniq6LongitudinalState())
def test_disengage_override_and_off_state_discard_old_demand(self):
for inactive in ({'active': False}, {'override': True}, {'control_state': LongState.off}):
with self.subTest(inactive=inactive):
policy = Ioniq6LongitudinalPolicy()
inputs = dict(active=True, override=False, accel=1.8, speed=0.0, measured_accel=0.0,
control_state=LongState.starting, last_sent_accel=0.0)
for frame in range(50):
inputs['last_sent_accel'] = policy.update(frame, **inputs).accel
result = policy.update(51, **(inputs | inactive))
self.assertEqual(result.accel, 0.0)
self.assertFalse(result.stopping)
self.assertEqual(policy.state, Ioniq6LongitudinalState())
resumed = policy.update(52, **(inputs | {'accel': .1, 'last_sent_accel': 0.0}))
fresh = Ioniq6LongitudinalPolicy().update(52, **(inputs | {'accel': .1, 'last_sent_accel': 0.0}))
self.assertEqual(resumed, fresh)
def test_braking_rate_uses_last_transmitted_frame_and_stays_bounded(self):
policy = Ioniq6LongitudinalPolicy()
inputs = dict(active=True, override=False, speed=20., measured_accel=.2, control_state=LongState.pid)
last_sent = 0.0
for frame in range(300):
result = policy.update(frame, **inputs, accel=2.0 if frame < 50 else -3.5, last_sent_accel=last_sent)
self.assertGreaterEqual(result.accel, -3.5)
self.assertLessEqual(result.accel, 2.0)
if frame % 2 == 0:
if frame >= 50:
self.assertGreaterEqual(result.accel + 1e-12, last_sent - .36)
last_sent = result.accel
self.assertAlmostEqual(last_sent, -3.5)
def test_real_controller_scc_respects_all_four_axis_states(self):
for alternate in (False, True):
for lateral, longitudinal in ((False, False), (True, False), (False, True), (True, True)):
for override in (False, True):
with self.subTest(alternate=alternate, lat=lateral, long=longitudinal, override=override):
cp, cs, controller = controller_fixture(alternate)
control = structs.CarControl()
control.enabled = lateral or longitudinal
control.latActive, control.longActive = lateral, longitudinal
control.cruiseControl.override = override
cs.out.gasPressed = lateral and not longitudinal and override
control.actuators.accel = 1.0
control.actuators.torque = .02
control.actuators.longControlState = LongState.starting
output, frames = controller.update(control.as_reader(), cs, 1_000_000_000)
scc = next(frame for frame in frames if frame[0] == 0x1A0)
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('SCC_CONTROL', 0)], 1)
parser.update((1_000_000_000, [scc]))
values = parser.vl['SCC_CONTROL']
self.assertEqual(values['ACCMode'], 2 if control.enabled and override else 1 if longitudinal else 0)
if not longitudinal or override:
self.assertEqual((values['aReqRaw'], values['aReqValue'], output.accel), (0., 0., 0.))
self.assertEqual(values['StopReq'], 0)
else:
self.assertGreater(values['aReqValue'], 0.)
self.assertAlmostEqual(values['aReqValue'], output.accel, delta=.0051)
self.assertAlmostEqual(values['aReqRaw'], output.accel, delta=.0051)
if not lateral:
self.assertEqual(output.torque, 0.)
def test_pedal_override_keeps_scc_engaged_without_acceleration(self):
cp, cs, controller = controller_fixture()
control = structs.CarControl()
control.enabled = True
control.latActive = True
control.actuators.accel = 1.0
control.actuators.longControlState = LongState.pid
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('SCC_CONTROL', 0)], 1)
for enabled, long_active, override, gas_pressed, brake_pressed, expected_mode in (
(True, True, False, False, False, 1),
(True, False, True, True, False, 2),
(True, False, True, False, False, 0),
(True, False, True, True, True, 0),
(True, True, False, False, False, 1),
(False, False, False, False, False, 0),
):
control.enabled = enabled
control.longActive = long_active
control.cruiseControl.override = override
cs.out.gasPressed = gas_pressed
cs.out.brakePressed = brake_pressed
_, frames = controller.update(control.as_reader(), cs, 1_000_000_000)
scc = next(frame for frame in frames if frame[0] == 0x1A0)
parser.update((1_000_000_000, [scc]))
values = parser.vl['SCC_CONTROL']
self.assertEqual(values['ACCMode'], expected_mode)
if not long_active:
self.assertEqual((values['aReqRaw'], values['aReqValue'], values['StopReq']), (0., 0., 0))
controller.frame += 1
def test_real_controller_scc_withdraws_launch_floor_after_soft_target(self):
for alternate in (False, True):
for drop_frame in (31, 34): # Between SCC sends and on an SCC send.
with self.subTest(alternate=alternate, drop_frame=drop_frame):
cp, cs, controller = controller_fixture(alternate)
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('SCC_CONTROL', 0)], 1)
control = structs.CarControl()
control.enabled = True
control.longActive = True
control.actuators.longControlState = LongState.starting
last_scc_accel = 0.0
def tick(frame: int, target: float, *, override: bool = False,
control=control, controller=controller, cs=cs, parser=parser):
control.actuators.accel = target
control.cruiseControl.override = override
output, frames = controller.update(control.as_reader(), cs, 1_000_000_000 + frame * 10_000_000)
scc = [message for message in frames if message[0] == 0x1A0]
self.assertEqual(len(scc), int(frame % 2 == 0))
if scc:
parser.update((1_000_000_000 + frame * 10_000_000, scc))
self.assertAlmostEqual(parser.vl['SCC_CONTROL']['aReqValue'], output.accel, delta=.0051)
return output.accel, parser.vl['SCC_CONTROL']['aReqValue'] if scc else None
for frame in range(drop_frame):
_, sent = tick(frame, 1.2)
if sent is not None:
last_scc_accel = sent
self.assertTrue(controller.ioniq6_longitudinal.state.launch_active)
soft_output, soft_scc = tick(drop_frame, .2)
self.assertFalse(controller.ioniq6_longitudinal.state.launch_active)
if soft_scc is not None:
self.assertLess(soft_scc, last_scc_accel)
self.assertGreater(soft_scc, .2)
else:
self.assertLess(soft_output, last_scc_accel)
_, next_scc = tick(drop_frame + 1, .2)
self.assertIsNotNone(next_scc)
self.assertLess(next_scc, last_scc_accel)
self.assertGreater(next_scc, .2)
next_frame = drop_frame + (2 if drop_frame % 2 == 0 else 3)
for frame in range(drop_frame + (2 if drop_frame % 2 else 1), next_frame):
tick(frame, .2)
negative, sent = tick(next_frame, -1.0)
self.assertFalse(controller.ioniq6_longitudinal.state.launch_active)
self.assertIsNotNone(sent)
self.assertLess(negative, soft_output)
self.assertGreater(negative, -1.0)
for frame in range(next_frame + 1, next_frame + 7):
tick(frame, 1.2)
self.assertTrue(controller.ioniq6_longitudinal.state.launch_active)
reset, sent = tick(next_frame + 7, 1.2, override=True)
self.assertEqual(reset, 0.0)
self.assertEqual(controller.ioniq6_longitudinal.state, Ioniq6LongitudinalState())
def test_real_host_state_then_controller_scc_on_same_tick(self):
for alternate in (False, True):
with self.subTest(alternate=alternate):
cp, cs, controller = controller_fixture(alternate)
host = LongControl(cp)
self.assertIsNotNone(host.ioniq6_start)
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('SCC_CONTROL', 0)], 1)
cs.out.canValid = True
cs.out.canTimeout = False
cs.out.vEgo = 0.0
cs.out.aEgo = 0.0
control = structs.CarControl()
control.enabled = True
control.longActive = True
def advance(frame: int, target: float, *, override: bool = False,
host=host, cs=cs, control=control, controller=controller, parser=parser):
control.cruiseControl.override = override
evidence = StartEvidence(True, True, True, 1, 1_000_000_000 + frame * 10_000_000)
output = host.update(not override, cs.out, target, False, (-3.5, 2.0), start_evidence=evidence)
# Match Controls' tagged-Ioniq order: publish state after LoC.update.
control.actuators.longControlState = host.long_control_state
control.actuators.accel = float(output)
sent, frames = controller.update(control.as_reader(), cs, 1_000_000_000 + frame * 10_000_000)
scc = [message for message in frames if message[0] == 0x1A0]
if scc:
parser.update((1_000_000_000 + frame * 10_000_000, scc))
return sent.accel, parser.vl['SCC_CONTROL']['aReqValue'] if scc else None
for frame in range(34):
advance(frame, .8)
self.assertEqual(host.long_control_state, LongState.pid)
output, scc = advance(34, .8)
self.assertEqual(host.long_control_state, LongState.starting)
self.assertIsNotNone(scc)
self.assertAlmostEqual(output, scc, delta=.0051)
advance(35, .8) # Controller calibration runs every fifth tick.
self.assertTrue(controller.ioniq6_longitudinal.state.launch_active)
soft_output, soft_scc = advance(36, .2)
self.assertEqual(host.long_control_state, LongState.pid)
self.assertFalse(controller.ioniq6_longitudinal.state.launch_active)
self.assertIsNotNone(soft_scc)
self.assertLess(soft_scc, scc)
self.assertAlmostEqual(soft_output, soft_scc, delta=.0051)
negative, _ = advance(37, -.3)
self.assertFalse(controller.ioniq6_longitudinal.state.launch_active)
self.assertLess(negative, soft_output)
def test_default_scc_packing_matches_committed_parent_bytes(self):
reference = json.loads((DATA / 'scc_parent_frames.json').read_text())
for row in reference['cases']:
packer = CANPacker(DBC[CAR.HYUNDAI_IONIQ_6][Bus.pt])
frame = create_acc_control(packer, SimpleNamespace(ECAN=1), *row['args'], SimpleNamespace(leadDistanceBars=2))
self.assertEqual(frame[1].hex(), row['data'])
def test_radar_heartbeat_matches_frozen_wire_and_runs_while_axes_are_off(self):
reference = json.loads((DATA / 'ioniq6_heartbeat.json').read_text())
for row in reference['cases']:
frame = create_ioniq6_radar_heartbeat(row['counter'], row['brake'], row['gas'])
self.assertEqual((frame.address, frame.src, len(frame.dat)), (0x100, 0, 24))
self.assertEqual(frame.dat.hex(), row['payload'])
for alternate in (False, True):
_, cs, controller = controller_fixture(alternate)
control = structs.CarControl()
counters = []
for tick in range(1040):
cs.out.brakePressed = tick % 8 < 4
cs.out.gasPressed = tick % 12 < 4
_, frames = controller.update(control.as_reader(), cs, 1_000_000_000 + tick * 10_000_000)
heartbeats = [frame for frame in frames if frame[0] == 0x100]
self.assertEqual(len(heartbeats), int(tick % 4 == 0))
if heartbeats:
frame = heartbeats[0]
self.assertEqual(frame[2], 0)
self.assertEqual(frame[1][4], int(cs.out.brakePressed))
self.assertEqual(frame[1][22], int(cs.out.gasPressed))
counters.append(frame[1][2])
self.assertEqual(counters, [counter & 0xff for counter in range(260)])
def test_stock_ioniq_and_other_cars_do_not_install_this_policy(self):
for car in (CAR.HYUNDAI_IONIQ_6, CAR.HYUNDAI_IONIQ_5, CAR.KIA_EV6):
cp = CarInterface.get_non_essential_params(car)
self.assertIsNone(CarController(DBC[car], cp).ioniq6_longitudinal)
@@ -0,0 +1,94 @@
import math
import unittest
from opendbc.can import CANPacker, CANParser
from opendbc.car.hyundai.ioniq6_media import Ioniq6MediaButtons, MEDIA_ADDRESS, MEDIA_MAX_GAP_NS
START = 10_000_000_000
def packet(stamp, *, mode=False, custom=False, bus=1):
payload = bytearray(8)
payload[2] = 0x40 if mode else 0
payload[5] = 0x10 if custom else 0
return (stamp, [(MEDIA_ADDRESS, bytes(payload), bus)])
class TestIoniq6Media(unittest.TestCase):
def test_dbc_matches_independent_physical_bits(self):
packer = CANPacker('hyundai_canfd_generated')
parser = CANParser('hyundai_canfd_generated', [('STEERING_WHEEL_MEDIA_BUTTONS', math.nan)], 1)
source = Ioniq6MediaButtons(1)
for index, (mode, custom) in enumerate(((False, False), (True, False), (False, True), (True, True))):
expected = packet(START + index * 200_000_000, mode=mode, custom=custom)
frame = packer.make_can_msg('STEERING_WHEEL_MEDIA_BUTTONS', 1, {'MODE_BUTTON': mode, 'CUSTOM_BUTTON': custom})
self.assertEqual(tuple(frame), expected[1][0])
parser.update([expected])
self.assertEqual(parser.vl['STEERING_WHEEL_MEDIA_BUTTONS']['MODE_BUTTON'], int(mode))
self.assertEqual(parser.vl['STEERING_WHEEL_MEDIA_BUTTONS']['CUSTOM_BUTTON'], int(custom))
sample = source.update([expected]).samples[0]
self.assertEqual((sample.mode_held, sample.custom_held), (mode, custom))
def test_keeps_packet_order_and_never_repeats_cached_samples(self):
source = Ioniq6MediaButtons(1)
observed = source.update([packet(START), packet(START + 100_000_000, mode=True), packet(START + 200_000_000)])
self.assertTrue(observed.valid)
self.assertEqual([s.mode_held for s in observed.samples], [False, True, False])
self.assertEqual([s.source_boot_ns for s in observed.samples], [START, START + 100_000_000, START + 200_000_000])
cached = source.update([(START + 250_000_000, [(0x123, b'other', 1)])])
self.assertTrue(cached.valid)
self.assertEqual(cached.samples, ())
self.assertEqual(cached.source_epoch, observed.source_epoch)
def test_wrong_bus_and_echo_cannot_create_or_refresh_source(self):
for bus in (0, 2, 3, 129):
with self.subTest(bus=bus):
source = Ioniq6MediaButtons(1)
self.assertFalse(source.update([packet(START, mode=True, bus=bus)]).valid)
self.assertTrue(source.update([packet(START + 1)]).valid)
result = source.update([packet(START + MEDIA_MAX_GAP_NS + 2, mode=True, bus=bus)])
self.assertFalse(result.valid)
self.assertEqual(result.samples, ())
for bus in (True, -1, 4, 129):
with self.assertRaises(ValueError):
Ioniq6MediaButtons(bus)
def test_short_long_and_duplicate_packets_revoke_whole_batch(self):
for payload in (b'', b'\0' * 5, b'\0' * 7, b'\0' * 9, b'\0' * 64):
with self.subTest(length=len(payload)):
source = Ioniq6MediaButtons(1)
initial = source.update([packet(START)])
observed = source.update([packet(START + 10, mode=True), (START + 20, [(MEDIA_ADDRESS, payload, 1)])])
self.assertFalse(observed.valid)
self.assertEqual(observed.samples, ())
self.assertGreater(observed.source_epoch, initial.source_epoch)
source = Ioniq6MediaButtons(1)
source.update([packet(START)])
self.assertFalse(source.update([packet(START, mode=True)]).valid)
same_stamp = source.update([packet(START + 10, mode=True), packet(START + 10)])
self.assertFalse(same_stamp.valid)
self.assertEqual(same_stamp.samples, ())
def test_gap_missing_can_and_clock_rewind_start_new_epoch(self):
cases = ([packet(START + MEDIA_MAX_GAP_NS + 1, mode=True)],
[(START + MEDIA_MAX_GAP_NS + 1, [])], [packet(START - 1)], [])
for next_packets in cases:
with self.subTest(packets=next_packets):
source = Ioniq6MediaButtons(1)
first = source.update([packet(START, mode=True)])
invalid = source.update(next_packets)
self.assertFalse(invalid.valid)
self.assertGreater(invalid.source_epoch, first.source_epoch)
# Recovery still supplies only observations; the gesture owner must
# observe neutral in the new epoch before accepting a held button.
recovered = source.update([packet(START + 1_000_000_000, mode=True)])
self.assertTrue(recovered.valid)
self.assertEqual(recovered.source_epoch, invalid.source_epoch)
source = Ioniq6MediaButtons(1)
source.update([packet(START)])
self.assertTrue(source.update([packet(START + MEDIA_MAX_GAP_NS)]).valid)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,124 @@
"""Source-only preflight regression: never imports the original/runtime tree."""
import ast
import binascii
from collections import namedtuple
from pathlib import Path
import statistics
import time
import unittest
from dataclasses import dataclass
from enum import Enum, auto
from unittest.mock import patch
def checksum(address, _sig, data):
# Equivalent XMODEM polynomial and existing Hyundai length XORs.
return binascii.crc_hqx(bytes(data[2:]) + address.to_bytes(2, 'little'), 0) ^ {8: 0x5F29, 16: 0x041D, 24: 0x819D, 32: 0x9F5B}[len(data)]
SOURCE = Path(__file__).resolve().parents[1] / 'ioniq6_handoff.py'
NAMES = {'HandoffOutcome', 'HandoffResult', '_valid_canfd_crc', '_read_window', '_measured_period', 'inspect_ioniq6_long_sources', 'run_ioniq6_handoff'}
tree = ast.parse(SOURCE.read_text())
body = [n for n in tree.body if (isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in NAMES) or
(isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id.startswith('IONIQ6_') for t in n.targets))]
ns = dict(statistics=statistics, time=time, dataclass=dataclass, Enum=Enum, auto=auto, hkg_can_fd_checksum=checksum, __name__=__name__)
body.insert(0, ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0))
exec(compile(ast.fix_missing_locations(ast.Module(body=body, type_ignores=[])), str(SOURCE), 'exec'), ns)
CanData = namedtuple('CanData', 'address dat src')
class Bus:
def __init__(self, modes):
self.modes, self.now, self.window, self.index, self.drains, self.flags = modes, 0., -1, -1, 0, []
@staticmethod
def frame(address, length, counter=0, src=1, bad_crc=False):
data = bytearray(length)
data[2] = counter % 256
data[:2] = checksum(address, None, data).to_bytes(2, 'little')
if bad_crc:
data[0] ^= 1
return CanData(address, bytes(data), src)
def recv(self, wait_for_one=False):
self.flags.append(wait_for_one)
if self.index in (-1, 13):
self.window += 1
self.drains += 1
self.index = 0
self.now += .001
return []
self.index += 1
mode = self.modes[min(self.window, len(self.modes) - 1)]
self.now += .020
if mode == 'empty':
return []
frames = [self.frame(a, length) for a, length in ns['IONIQ6_REQUIRED_RX'].items()
if not (mode == 'required' and a == 0xEA)]
frames.append(self.frame(0x1A0, 32, self.index * (2 if mode == 'counter' else 1), bad_crc=mode == 'crc'))
if mode != 'bsm':
frames.extend(self.frame(a, length) for a, length in ns['IONIQ6_STOCK_BSM'].items())
if mode != 'heartbeat':
frames.append(self.frame(0x100, 24, src=0))
packet = type('Packet', (list,), {})(frames)
packet.log_mono_time_ns = int((self.now - (.013 if mode == 'period' and self.index == 5 else 0)) * 1e9)
return [packet]
class TestRetry(unittest.TestCase):
def inspect(self, modes):
bus = Bus(modes)
with patch.object(time, 'sleep'):
result = ns['inspect_ioniq6_long_sources'](bus.recv, lambda: bus.now)
return bus, result
def test_invalid_then_valid(self):
bus, result = self.inspect(['period', 'valid'])
self.assertEqual(result.reason, 'preflight_ready')
self.assertAlmostEqual(result.source_period, .020)
self.assertEqual(bus.drains, 2)
self.assertFalse(any(bus.flags))
def test_all_invalid_denies_without_uds(self):
bus, exchanges = Bus(['period']), []
with patch.object(time, 'sleep'):
result = ns['run_ioniq6_handoff'](bus.recv, lambda *args: exchanges.append(args), lambda: bus.now)
self.assertEqual(result.reason, 'preflight_scc_period')
self.assertIsNone(result.source_period)
self.assertEqual(bus.drains, 3)
self.assertEqual(exchanges, [])
self.assertLess(bus.now, .81)
def test_crc_counter_health_unchanged(self):
for mode, reason in [('crc', 'scc_period'), ('counter', 'scc_period'), ('required', 'required_rx'), ('bsm', 'bsm_status'), ('heartbeat', 'radar_heartbeat')]:
with self.subTest(mode=mode):
bus, result = self.inspect([mode])
self.assertIsNone(result.source_period)
self.assertIn(reason, result.reason)
self.assertEqual(bus.drains, 3)
def test_no_cross_window_health_and_latest_failure(self):
bus, result = self.inspect(['heartbeat', 'bsm', 'required'])
self.assertEqual(result.reason, 'preflight_required_rx')
self.assertIsNone(result.source_period)
self.assertEqual(bus.drains, 3)
def test_empty_bus_real_clock_is_bounded(self):
flags = []
started = time.monotonic()
result = ns['inspect_ioniq6_long_sources'](lambda wait_for_one=False: flags.append(wait_for_one) or [])
self.assertIsNone(result.source_period)
self.assertFalse(any(flags))
self.assertLess(time.monotonic() - started, 1.25)
def test_cadence_thresholds_unchanged(self):
measured = ns['_measured_period']
good = [(i * .020, i, i * .020) for i in range(8)]
self.assertAlmostEqual(measured(good), .020)
for bad in ([ (t,c,0.) for t,c,_ in good], [(t,c*2,r) for t,c,r in good],
[(i*.031,i,i*.031) for i in range(8)], [(i*.011,i,i*.020) for i in range(8)], good[:3]):
self.assertIsNone(measured(bad))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,127 @@
import unittest
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.carstate import CarState, decode_ioniq_6_corner_bsm
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.radar_interface import MRR35_RADAR_START_ADDR, RadarInterface, get_radar_can_parser, radar_bus
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
def params(topology: str, *, radar_bus_number: int | None = None, radar_length: int = 24, bsm: bool = False):
fingerprint = gen_empty_fingerprint()
if topology == "lka":
fingerprint[2][0x50] = 16
elif topology == "lka_alt":
fingerprint[2][0x110] = 32
else:
assert topology == "camera"
ecan = 1 if topology != "camera" else 0
fingerprint[ecan][0x1cf] = 8
if radar_bus_number is not None:
fingerprint[radar_bus_number][MRR35_RADAR_START_ADDR] = radar_length
if bsm:
fingerprint[ecan][0x1ba] = 24
return CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fingerprint, [], False, False, False)
class TestIoniq6ReceiveOnly(unittest.TestCase):
def test_radar_bus_length_and_stock_cruise_are_topology_bound(self):
for topology, expected_bus in (("camera", 1), ("lka", 0), ("lka_alt", 0)):
for observed_bus, length in ((None, 24), (expected_bus, 24), (1 - expected_bus, 24), (expected_bus, 8)):
with self.subTest(topology=topology, bus=observed_bus, length=length):
cp = params(topology, radar_bus_number=observed_bus, radar_length=length)
self.assertEqual(radar_bus(cp), expected_bus)
self.assertEqual(get_radar_can_parser(cp).bus, expected_bus)
self.assertEqual(cp.radarUnavailable, observed_bus != expected_bus or length != 24)
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertTrue(cp.pcmCruise)
self.assertFalse(cp.alphaLongitudinalAvailable)
self.assertEqual(bool(cp.flags & HyundaiFlags.CANFD_CAMERA_SCC), topology == "camera")
def test_mrr35_real_packed_cycle_with_missing_and_delayed_tracks(self):
for topology, expected_bus in (("camera", 1), ("lka", 0), ("lka_alt", 0)):
with self.subTest(topology=topology):
cp = params(topology, radar_bus_number=expected_bus)
radar = RadarInterface(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.radar])
track = packer.make_can_msg("RADAR_TRACK_3a5", expected_bus, {
"STATE": 3, "LONG_DIST": 28.5, "LAT_DIST": -1.25, "REL_SPEED": -2.5,
})
trigger = packer.make_can_msg("RADAR_TRACK_3c4", expected_bus, {})
wrong_bus_track = (track[0], track[1], 1 - expected_bus)
self.assertIsNone(radar.update((1_000_000_000, [wrong_bus_track])))
self.assertEqual(len(radar.update((1_010_000_000, [(track[0], track[1][:8], expected_bus), trigger])).points), 0)
out = radar.update((1_050_000_000, [track, trigger]))
self.assertEqual(len(out.points), 1)
self.assertAlmostEqual(out.points[0].dRel, 28.5, delta=0.05)
self.assertAlmostEqual(out.points[0].yRel, -1.25, delta=0.05)
self.assertAlmostEqual(out.points[0].vRel, -2.5, delta=0.02)
out = radar.update((1_100_000_000, [trigger]))
self.assertEqual(len(out.points), 0)
self.assertIsNone(radar.update((1_125_000_000, [track, (trigger[0], trigger[1][:8], expected_bus)])))
self.assertIsNone(radar.update((1_150_000_000, [track])))
out = radar.update((1_250_000_000, [trigger]))
self.assertEqual(len(out.points), 0)
def test_corner_bits_are_received_without_status_transmission(self):
self.assertEqual(decode_ioniq_6_corner_bsm(0x02), (False, False))
self.assertEqual(decode_ioniq_6_corner_bsm(0x0a), (False, True))
self.assertEqual(decode_ioniq_6_corner_bsm(0x12), (True, False))
self.assertEqual(decode_ioniq_6_corner_bsm(0x1a), (True, True))
for topology in ("camera", "lka", "lka_alt"):
with self.subTest(topology=topology):
cp = params(topology, bsm=True)
self.assertTrue(cp.deprecated.enableBsm)
can_bus = CanBus(cp)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
parser = parsers[Bus.pt]
self.assertEqual(parser.message_states[0x1ba].frequency, 0)
self.assertEqual(parser.message_states[0x36a].frequency, 0)
state.out = state.update(parsers)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
rear = packer.make_can_msg("ADAS_CMD_50_50ms", can_bus.ECAN, {"BCW_LtIndSta": 1})
front = packer.make_can_msg("BLINDSPOTS_FRONT_CORNER_2", can_bus.ECAN, {"SIDE_DETECT_STATE": 0x08})
parser.update((1_000_000_000, [rear, front]))
state.out = state.update(parsers)
self.assertTrue(state.out.leftBlindspot)
self.assertTrue(state.out.rightBlindspot)
control = structs.CarControl()
control.enabled = control.latActive = True
_, sent = CarController(DBC[cp.carFingerprint], cp).update(control.as_reader(), state, 1_000_000_000)
self.assertFalse({0x1ba, 0x1e5, 0x36a} & {address for address, _, _ in sent})
wrong_bus_front = (front[0], front[1], 1 - can_bus.ECAN)
parser.update((1_100_000_000, [wrong_bus_front]))
state.out = state.update(parsers)
self.assertTrue(state.out.rightBlindspot)
# Rear and front sources expire independently as normal fuel traffic
# advances the parser clock; neither can advertise indefinitely.
fuel = packer.make_can_msg("ACCELERATOR", can_bus.ECAN, {})
fresh_front = packer.make_can_msg("BLINDSPOTS_FRONT_CORNER_2", can_bus.ECAN, {"SIDE_DETECT_STATE": 0x10})
parser.update((1_250_000_000, [fuel, fresh_front]))
state.out = state.update(parsers)
self.assertTrue(state.out.leftBlindspot)
self.assertFalse(state.out.rightBlindspot)
parser.update((1_500_000_000, [fuel]))
state.out = state.update(parsers)
self.assertFalse(state.out.leftBlindspot)
self.assertFalse(state.out.rightBlindspot)
def test_front_source_without_rear_detection_does_not_enable_bsm(self):
cp = params("lka")
self.assertFalse(cp.deprecated.enableBsm)
state = CarState(cp)
parser = state.get_can_parsers(cp)[Bus.pt]
self.assertNotIn(0x36a, parser.message_states)
fingerprint = gen_empty_fingerprint()
fingerprint[2][0x50] = 16
fingerprint[1][0x1ba] = 16
cp = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fingerprint, [], False, False, False)
self.assertFalse(cp.deprecated.enableBsm)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,119 @@
"""Synthetic stock CAN graph through actual CarState, CANParser and CI aggregation."""
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.interfaces import CarInterfaceBase
from opendbc.car.hyundai.ioniq6_handoff import (build_ioniq6_hda2_long_candidate, finalize_ioniq6_prepublication,
HandoffOutcome, HandoffResult)
from opendbc.car.hyundai.values import CAR, DBC
def params(alternate):
fingerprint = gen_empty_fingerprint()
fingerprint[2].update({0x110 if alternate else 0x50: 32 if alternate else 16,
0x362 if alternate else 0x2A4: 32 if alternate else 24})
fingerprint[1].update({0x1CF: 8, 0x35: 32, 0x175: 24, 0xA0: 24, 0xEA: 24,
0x1BA: 24, 0x1E5: 16, 0x36A: 16})
fingerprint[0][0x3A5] = 24
cp = CarInterface.get_params(CAR.HYUNDAI_IONIQ_6, fingerprint, [], False, False, False)
return cp, fingerprint
class StockParserTests(unittest.TestCase):
def host(self, cp):
cs = CarState(cp)
ci = SimpleNamespace(CS=cs, CP=cp, can_parsers=cs.get_can_parsers(cp), v_ego_cluster_seen=False)
ci.update = lambda frames: CarInterfaceBase.update(ci, frames)
ci.update([]) # register every lazy field the real CarState consumes
return ci
def feed(self, ci, *, omit=(), alt_units=None, count=200):
packer = CANPacker(DBC[ci.CP.carFingerprint][Bus.pt])
out = None
for index in range(1, count + 1):
frames = []
ci.test_time_ns = getattr(ci, 'test_time_ns', 1_000_000_000) + 10_000_000
for parser in ci.can_parsers.values():
for state in list(parser.message_states.values()):
if state.name in omit or (state.ignore_alive and not (state.name == 'CRUISE_BUTTONS_ALT' and alt_units is not None)):
continue
values = {'COUNTER': index % (16 if state.name == 'CRUISE_BUTTONS' else 256)} if any(signal.name == 'COUNTER' for signal in state.signals) else {}
if state.name == 'ACCELERATOR': values.update(GEAR=5)
elif state.name == 'DOORS_SEATBELTS': values.update(DRIVER_SEATBELT=1)
elif state.name == 'SCC_CONTROL': values.update(ACCMode=1, VSetDis=60)
elif state.name == 'CRUISE_BUTTONS_ALT': values.update(DISTANCE_UNIT=alt_units)
frame = packer.make_can_msg(state.name, parser.bus, values)
frames.append((frame[0], frame[1], frame[2]))
out = ci.update([(ci.test_time_ns, frames)])
return out
def test_alpha_off_and_alpha_preflight_stock_fallback_without_optional_alt(self):
for alternate in (False, True):
for enabled in (False, True):
with self.subTest(alternate=alternate, alpha_requested=enabled):
stock, fingerprint = params(alternate)
candidate = build_ioniq6_hda2_long_candidate(stock, fingerprint)
exchanges = []
with patch('opendbc.car.hyundai.ioniq6_handoff.run_ioniq6_handoff',
return_value=HandoffResult(HandoffOutcome.STOCK, 'preflight_scc_period')):
cp, selected, _ = finalize_ioniq6_prepublication(stock, candidate, lambda **kw: [], exchanges.append,
enabled=enabled, is_release=False)
self.assertIs(cp, stock)
self.assertFalse(selected)
self.assertEqual(exchanges, [])
self.assertTrue(cp.pcmCruise)
self.assertFalse(cp.openpilotLongitudinalControl)
ci = self.host(cp)
parser = ci.can_parsers[Bus.pt]
self.assertTrue(parser.message_states[parser.dbc.name_to_msg['CRUISE_BUTTONS_ALT'].address].ignore_alive)
out = self.feed(ci)
self.assertTrue(out.canValid)
self.assertFalse(out.canTimeout)
self.assertTrue(out.cruiseState.enabled)
self.assertTrue(ci.CS.is_metric) # existing absent-units default, not current units proof
def test_present_optional_alt_units_still_decode(self):
for alternate in (False, True):
cp, _ = params(alternate)
ci = self.host(cp)
out = self.feed(ci, alt_units=1)
self.assertTrue(out.canValid)
self.assertFalse(ci.CS.is_metric)
def test_stock_required_inputs_remain_required(self):
for alternate in (False, True):
for missing in ('ACCELERATOR', 'TCS', 'WHEEL_SPEEDS', 'MDPS', 'CRUISE_BUTTONS', 'SCC_CONTROL'):
with self.subTest(alternate=alternate, missing=missing):
cp, _ = params(alternate)
ci = self.host(cp)
self.assertTrue(self.feed(ci).canValid)
parser = ci.can_parsers[Bus.pt]
state = parser.message_states[parser.dbc.name_to_msg[missing].address]
self.assertFalse(state.ignore_alive)
# Advance past the actual retained parser timeout, keep all siblings healthy.
out = self.feed(ci, omit=(missing,), count=int(state.timeout_threshold / 10_000_000) + 20)
self.assertFalse(out.canValid)
def test_existing_long_parser_subscription_contract_is_unchanged(self):
for alternate in (False, True):
stock, fingerprint = params(alternate)
cp = build_ioniq6_hda2_long_candidate(stock, fingerprint)
self.assertIsNotNone(cp)
ci = self.host(cp)
pt = ci.can_parsers[Bus.pt]
for name in ('ACCELERATOR', 'TCS', 'WHEEL_SPEEDS', 'MDPS', 'CRUISE_BUTTONS'):
self.assertFalse(pt.message_states[pt.dbc.name_to_msg[name].address].ignore_alive)
self.assertTrue(pt.message_states[pt.dbc.name_to_msg['CRUISE_BUTTONS_ALT'].address].ignore_alive)
self.assertNotIn(pt.dbc.name_to_msg['SCC_CONTROL'].address, pt.message_states)
camera_name = 'CAM_0x362' if alternate else 'CAM_0x2a4'
cam = ci.can_parsers[Bus.cam]
self.assertEqual(cam.message_states[cam.dbc.name_to_msg[camera_name].address].frequency, 20)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,182 @@
import unittest
from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.fw_versions import match_fw_to_car
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.carstate import CarState, get_non_scc_cruise_signals
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.radar_interface import RadarInterface, get_radar_can_parser
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags, HyundaiSafetyFlags
from opendbc.car.hyundai.fingerprints import FW_VERSIONS
from opendbc.car.structs import CarParams
NON_SCC_CARS = (
CAR.GENESIS_G70_2021_NON_SCC, CAR.HYUNDAI_BAYON_1ST_GEN_NON_SCC,
CAR.HYUNDAI_ELANTRA_2022_NON_SCC, CAR.HYUNDAI_ELANTRA_HEV_2022_NON_SCC,
CAR.HYUNDAI_KONA_EV_NON_SCC, CAR.HYUNDAI_KONA_NON_SCC,
CAR.KIA_CEED_PHEV_2022_NON_SCC, CAR.KIA_FORTE_2019_NON_SCC,
CAR.KIA_FORTE_2021_NON_SCC, CAR.KIA_SELTOS_2023_NON_SCC,
)
def params(candidate, fingerprint=None, car_fw=None, alpha_long=True):
return CarInterface.get_params(candidate, fingerprint or gen_empty_fingerprint(), car_fw or [], alpha_long, False, False)
class TestNonSccFamily(unittest.TestCase):
def test_forte_sender_preserves_original_torque_scale_and_sibling_limits(self):
cases = ((CAR.KIA_FORTE_2019_NON_SCC, 255), (CAR.KIA_FORTE_2021_NON_SCC, 255),
(CAR.KIA_FORTE, 255), (CAR.HYUNDAI_KONA_NON_SCC, 270), (CAR.KIA_SELTOS_2023_NON_SCC, 384))
for candidate, maximum in cases:
for request in (-1., -.5, .5, 1.):
with self.subTest(candidate=candidate, request=request):
cp = params(candidate, alpha_long=False)
state = CarState(cp)
state.out = state.update(state.get_can_parsers(cp))
controller = CarController(DBC[candidate], cp)
parser = CANParser(DBC[candidate][Bus.pt], [('LKAS11', 100)], 0)
control = structs.CarControl()
control.latActive = True
control.actuators.torque = request
previous = 0
for frame in range(180):
stamp = 1_000_000_000 + frame * 10_000_000
_, sent = controller.update(control.as_reader(), state, stamp)
parser.update((stamp, sent))
torque = parser.vl['LKAS11']['CR_Lkas_StrToqReq']
self.assertLessEqual(abs(torque), maximum)
self.assertGreaterEqual(torque * request, 0)
self.assertLessEqual(abs(torque - previous), 3)
previous = torque
self.assertEqual(torque, round(request * maximum))
self.assertTrue(parser.vl['LKAS11']['CF_Lkas_ActToi'])
control.latActive = False
_, sent = controller.update(control.as_reader(), state, stamp + 10_000_000)
parser.update((stamp + 10_000_000, sent))
self.assertEqual(parser.vl['LKAS11']['CR_Lkas_StrToqReq'], 0)
self.assertFalse(parser.vl['LKAS11']['CF_Lkas_ActToi'])
def test_all_ten_have_stock_longitudinal_ownership_and_lateral_only_controller(self):
for candidate in NON_SCC_CARS:
for alpha_long in (False, True):
with self.subTest(candidate=candidate, alpha_long=alpha_long):
cp = params(candidate, alpha_long=alpha_long)
self.assertTrue(cp.flags & HyundaiFlags.NON_SCC)
self.assertFalse(cp.alphaLongitudinalAvailable)
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertTrue(cp.pcmCruise)
self.assertTrue(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.NON_SCC)
self.assertFalse(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.LONG)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
state.out = state.update(parsers)
controller = CarController(DBC[cp.carFingerprint], cp)
control = structs.CarControl()
control.enabled = control.latActive = control.longActive = True
_, sent = controller.update(control.as_reader(), state, 1_000_000_000)
self.assertFalse({0x420, 0x421, 0x50A, 0x389, 0x38D, 0x483} & {addr for addr, _, _ in sent})
def test_fuel_architectures_decode_actual_cruise_frames(self):
families: tuple[tuple[str, dict[str, dict[str, float]]], ...] = (
(CAR.KIA_FORTE_2021_NON_SCC, {"EMS16": {"CRUISE_LAMP_M": 1, "CRUISE_LAMP_S": 1},
"LVR12": {"CF_Lvr_CruiseSet": 72}}),
(CAR.HYUNDAI_ELANTRA_HEV_2022_NON_SCC,
{"E_CRUISE_CONTROL": {"CRUISE_LAMP_M": 1, "CRUISE_LAMP_S": 1}, "ELECT_GEAR": {"SLC_SET_SPEED": 72}}),
(CAR.HYUNDAI_KONA_EV_NON_SCC,
{"LABEL11": {"CC_React": 1}, "EMS12": {"ACC_ACT": 1}, "E_EMS11": {"Cruise_Limit_Target": 72}}),
)
for candidate, messages in families:
with self.subTest(candidate=candidate):
cp = params(candidate)
state = CarState(cp)
parsers = state.get_can_parsers(cp)
pt = parsers[Bus.pt]
names = set(get_non_scc_cruise_signals(cp.flags)[::2])
self.assertTrue(names <= set(pt.vl))
self.assertNotIn("SCC12", names)
packer = CANPacker(DBC[candidate][Bus.pt])
frames = [packer.make_can_msg(name, 0, values) for name, values in messages.items()]
self.assertEqual(pt.update((1_000_000_000, frames)), {frame[0] for frame in frames})
out = state.update(parsers)
self.assertTrue(out.cruiseState.available)
self.assertTrue(out.cruiseState.enabled)
self.assertAlmostEqual(out.cruiseState.speed, 20.0, places=4)
self.assertFalse(out.accFaulted)
self.assertFalse(out.cruiseState.standstill)
self.assertFalse(out.cruiseState.nonAdaptive)
def test_fca_source_and_lda_require_observed_configuration(self):
fingerprint = gen_empty_fingerprint()
fingerprint[1][0x602] = 8
fingerprint[0][0x391] = 8
kona = params(CAR.HYUNDAI_KONA_NON_SCC, fingerprint)
self.assertTrue(kona.flags & HyundaiFlags.NON_SCC_RADAR_FCA)
self.assertFalse(kona.radarUnavailable)
self.assertTrue(kona.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.HAS_LDA_BUTTON)
parsers = CarState(kona).get_can_parsers(kona)
self.assertIn("FCA11", parsers[Bus.pt].vl)
self.assertNotIn("FCA11", parsers[Bus.cam].vl)
state = CarState(kona)
state.update(parsers)
self.assertIsNotNone(state.forte_lkas_sources)
self.assertNotIn("BCM_PO_11", parsers[Bus.pt].vl)
plain = params(CAR.HYUNDAI_KONA_NON_SCC)
self.assertTrue(plain.radarUnavailable)
self.assertFalse(plain.flags & HyundaiFlags.NON_SCC_RADAR_FCA)
self.assertFalse(plain.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.HAS_LDA_BUTTON)
camera = params(CAR.KIA_FORTE_2021_NON_SCC)
self.assertIn("FCA11", CarState(camera).get_can_parsers(camera)[Bus.cam].vl)
no_fca = params(CAR.KIA_FORTE_2019_NON_SCC)
self.assertNotIn("FCA11", CarState(no_fca).get_can_parsers(no_fca)[Bus.cam].vl)
def test_exact_firmware_and_unidentified_manual_kona_ev(self):
self.assertNotIn(CAR.HYUNDAI_KONA_EV_NON_SCC, FW_VERSIONS)
for candidate in NON_SCC_CARS:
if candidate == CAR.HYUNDAI_KONA_EV_NON_SCC:
continue
with self.subTest(candidate=candidate):
fw = [CarParams.CarFw(ecu=ecu, address=addr, subAddress=sub or 0, fwVersion=versions[0], brand="hyundai")
for (ecu, addr, sub), versions in FW_VERSIONS[candidate].items()]
exact, matches = match_fw_to_car(fw, "", allow_exact=True, allow_fuzzy=False, log=False)
self.assertTrue(exact)
self.assertEqual(matches, {candidate})
# A shared partial ECU response cannot be promoted to either Kona sibling.
key = (CarParams.Ecu.eps, 0x7d4, None)
shared = set(FW_VERSIONS[CAR.HYUNDAI_KONA_NON_SCC][key]) & set(FW_VERSIONS[CAR.HYUNDAI_KONA][key])
self.assertTrue(shared)
partial = [CarParams.CarFw(ecu=key[0], address=key[1], subAddress=0, fwVersion=next(iter(shared)), brand="hyundai")]
exact, matches = match_fw_to_car(partial, "", allow_exact=True, allow_fuzzy=False, log=False)
self.assertTrue(exact)
self.assertFalse(matches)
# Unknown and contradictory firmware must not falsely identify the new variant.
partial[0].fwVersion = b"unknown firmware"
self.assertNotIn(CAR.HYUNDAI_KONA_NON_SCC,
match_fw_to_car(partial, "", allow_exact=True, allow_fuzzy=False, log=False)[1])
def test_kona_receive_only_radar_tracks_and_freshness(self):
fingerprint = gen_empty_fingerprint()
fingerprint[1][0x602] = 8
cp = params(CAR.HYUNDAI_KONA_NON_SCC, fingerprint)
radar = RadarInterface(cp)
parser = get_radar_can_parser(cp)
self.assertIn("RADAR_TRACK_602", parser.vl)
self.assertIn("RADAR_TRACK_611", parser.vl)
self.assertNotIn("RADAR_TRACK_500", parser.vl)
packer = CANPacker(DBC[cp.carFingerprint][Bus.radar])
first = packer.make_can_msg("RADAR_TRACK_602", 1, {"1_DISTANCE": 25, "1_LATERAL": 1.5, "1_SPEED": -2,
"2_DISTANCE": 255.75})
trigger = packer.make_can_msg("RADAR_TRACK_611", 1, {"1_DISTANCE": 255.75, "2_DISTANCE": 255.75})
out = radar.update((1_000_000_000, [first, trigger]))
self.assertEqual(len(out.points), 1)
self.assertAlmostEqual(out.points[0].dRel, 25)
self.assertAlmostEqual(out.points[0].yRel, 1.5, delta=0.02) # 0.03 m DBC resolution
self.assertAlmostEqual(out.points[0].vRel, -2)
# A fresh trigger without a fresh 0x602 must withdraw the old point.
out = radar.update((1_050_000_000, [trigger]))
self.assertEqual(len(out.points), 0)
self.assertIsNone(radar.update((1_100_000_000, [first])))
out = radar.update((1_200_000_000, [trigger]))
self.assertEqual(len(out.points), 0) # old source cannot revive at a delayed trigger
@@ -0,0 +1,161 @@
"""Forte receive topology and startup state."""
import itertools
import unittest
from unittest.mock import patch
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.non_scc_aol import aol_word, qualified, AOL_EXPERIENCE
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags
from opendbc.safety.tests.test_hyundai import checksum
FORTE_IDS = (CAR.KIA_FORTE_2019_NON_SCC, CAR.KIA_FORTE_2021_NON_SCC)
# Fixed classic source set, independent of the production parser's subscriptions.
PT_RATES = {'MDPS12': 50, 'TCS11': 100, 'TCS13': 50, 'TCS15': 10, 'CLU11': 50,
'CLU15': 5, 'ESP12': 100, 'CGW1': 10, 'CGW2': 5, 'WHL_SPD11': 50,
'SAS11': 100, 'EMS12': 100, 'EMS16': 100, 'LVR12': 100}
def params(identity, *, source=0x391, alpha=False, aol=False, fca=False, bsm=False):
fp = gen_empty_fingerprint()
if source:
fp[0][source] = 8
if fca:
fp[2][0x38d] = 8
if bsm:
fp[0][0x58b] = 8
cp = CarInterface.get_params(identity, fp, [], alpha, False, False)
if aol:
cp.safetyConfigs[0].safetyParam = aol_word(cp)
cp.alternativeExperience = AOL_EXPERIENCE
return cp
class ForteReceiveStream:
def __init__(self, cp, *, source=0x391):
self.ci = CarInterface(cp)
self.source = source
self.packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
self.tick = 0
self.now = 1_000_000_000
self.gear = next(code for code, name in self.ci.CS.shifter_values.items() if name == 'D')
def step(self, *, missing=None, wrong_bus=None, held=False, fca=True):
n = self.tick
self.tick += 1
self.now += 10_000_000
values = {'EMS16': {'CRUISE_LAMP_M': 1, 'CRUISE_LAMP_S': 1, 'AliveCounter': n % 4},
'WHL_SPD11': {'WHL_SPD_FL': 72, 'WHL_SPD_FR': 72, 'WHL_SPD_RL': 72, 'WHL_SPD_RR': 72,
'WHL_SPD_AliveCounter_LSB': n % 4, 'WHL_SPD_AliveCounter_MSB': n // 4 % 4},
'TCS13': {'AliveCounterTCS': n % 8}, 'CLU11': {'CF_Clu_AliveCnt1': n % 16},
'CGW1': {'CF_Gway_DrvSeatBeltSw': 1}, 'LVR12': {'CF_Lvr_Gear': self.gear}}
frames = []
sources = [(name, 0, rate) for name, rate in PT_RATES.items()]
sources += [('LKAS11', 2, 100)]
if self.source:
sources += [('BCM_PO_11' if self.source == 0x391 else 'CLU13', 0, 10)]
if fca and not self.ci.CP.flags & HyundaiFlags.NON_SCC_NO_FCA:
sources += [('FCA11', 2, 50)]
if self.ci.CP.deprecated.enableBsm:
sources += [('LCA11', 0, 20)]
for name, bus, rate in sources:
if name == missing or n and n * rate // 100 == (n - 1) * rate // 100:
continue
signal = {'BCM_PO_11': {'LDA_BTN': int(held)}, 'CLU13': {'CF_Clu_LdwsLkasSW': int(held)},
'LKAS11': {'CF_Lkas_MsgCount': n % 16}}.get(name, values.get(name, {}))
frame = self.packer.make_can_msg(name, bus, signal)
if name in ('EMS16', 'WHL_SPD11', 'TCS13'):
frame = checksum(frame)
if name == wrong_bus:
frame = (frame[0], frame[1], 2 if bus == 0 else 0)
frames.append(frame)
with patch('opendbc.car.hyundai.non_scc_aol.time.clock_gettime_ns', return_value=self.now):
return self.ci.update([(self.now, frames)])
def warm(self, **kwargs):
for _ in range(220):
state = self.step(**kwargs)
return state
class TestNonSccStartup(unittest.TestCase):
def test_complete_forte_final_profiles_and_lazy_requirements(self):
for identity, source, alpha, aol, bsm, fca in itertools.product(FORTE_IDS, (0x391, 0x50c),
(False, True), (False, True), (False, True), (False, True)):
with self.subTest(identity=identity, source=source, alpha=alpha, aol=aol, bsm=bsm, fca=fca):
cp = params(identity, source=source, alpha=alpha, aol=aol, bsm=bsm, fca=fca)
self.assertTrue(qualified(cp))
self.assertTrue(cp.pcmCruise)
self.assertFalse(cp.openpilotLongitudinalControl or cp.alphaLongitudinalAvailable)
stream = ForteReceiveStream(cp, source=source)
state = stream.warm()
self.assertTrue(state.canValid)
self.assertFalse(state.canTimeout)
self.assertEqual(state.gearShifter, structs.CarState.GearShifter.drive)
self.assertFalse(state.doorOpen or state.seatbeltUnlatched)
self.assertTrue(state.cruiseState.available and state.cruiseState.enabled)
self.assertNotIn('SCC11', stream.ci.can_parsers[Bus.pt].vl)
self.assertNotIn('SCC12', stream.ci.can_parsers[Bus.pt].vl)
stream.warm(fca=False)
self.assertTrue(stream.step(fca=False).canValid)
def test_all_ten_selected_cruise_sources_require_arrival_and_expire(self):
from opendbc.car.hyundai.tests.test_non_scc import NON_SCC_CARS
from opendbc.car.hyundai.carstate import get_non_scc_cruise_signals
for identity in NON_SCC_CARS:
cp = params(identity, source=0)
names = get_non_scc_cruise_signals(cp.flags, cp.carFingerprint)[::2]
for name in set(names):
with self.subTest(identity=identity, source=name):
ci = CarInterface(cp)
state = ci.update([])
self.assertFalse(state.canValid, (identity, name, 'empty'))
parser = ci.can_parsers[Bus.pt]
message = parser.message_states[parser.dbc.name_to_msg[name].address]
self.assertFalse(message.ignore_alive, (identity, name))
self.assertFalse(message.timestamps, (identity, name))
self.assertFalse(message.valid(1_000_000_000, False), (identity, name, 'never received'))
packer = CANPacker(DBC[identity][Bus.pt])
packet = packer.make_can_msg(name, 0, {})
if name == 'EMS16':
packet = checksum(packet)
ci.update([(1_000_000_000, [packet])])
self.assertTrue(message.timestamps, (identity, name, 'packed arrival'))
self.assertTrue(message.valid(1_000_000_000, False), (identity, name, 'arrived'))
expired = 1_000_000_000 + int(message.timeout_threshold) + 1
self.assertFalse(message.valid(expired, False), (identity, name, 'expired'))
self.assertFalse(ci.update([(expired, [])]).canValid, (identity, name, 'incomplete stream'))
def test_required_sources_loss_wrong_bus_and_recovery(self):
for identity, source, missing, mode in itertools.product(FORTE_IDS, (0x391, 0x50c),
('MDPS12', 'WHL_SPD11', 'EMS16', 'TCS13', 'CLU11', 'LKAS11'), ('missing', 'wrong_bus')):
with self.subTest(identity=identity, source=source, missing=missing, mode=mode):
stream = ForteReceiveStream(params(identity, source=source, aol=True), source=source)
case = (identity, source, missing, mode)
self.assertTrue(stream.warm().canValid, case)
state = stream.warm(**{mode: missing})
self.assertFalse(state.canValid, case)
self.assertTrue(stream.warm().canValid, case)
def test_alternative_lda_source_expiry_needs_new_neutral_gesture(self):
for identity, source in itertools.product(FORTE_IDS, (0x391, 0x50c)):
with self.subTest(identity=identity, source=source):
stream = ForteReceiveStream(params(identity, source=source, aol=True), source=source)
self.assertTrue(stream.warm().canValid)
stream.warm(held=True)
self.assertTrue(stream.ci.CS.forte_lkas_sources.held)
name = 'BCM_PO_11' if source == 0x391 else 'CLU13'
self.assertTrue(stream.warm(missing=name).canValid)
self.assertFalse(stream.ci.CS.forte_lkas_sources.held)
for _ in range(220):
stream.step(held=True)
self.assertNotIn(True, stream.ci.CS.forte_lkas_sources.edges)
stream.warm(held=False)
presses = []
for _ in range(220):
stream.step(held=True)
presses.extend(stream.ci.CS.forte_lkas_sources.edges)
self.assertIn(True, presses)
self.assertTrue(stream.ci.CS.forte_lkas_sources.held)
@@ -0,0 +1,208 @@
import unittest
from unittest.mock import patch
from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.hyundaican import hyundai_checksum
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC, CarControllerParams, HyundaiFlags
def params(topology='hdai', *, adas=False, alpha=False, release=False):
fp = gen_empty_fingerprint()
if topology == 'hdaii':
fp[2][0x50] = 16
elif topology == '110':
fp[2][0x110] = 32
fw = []
if adas:
item = structs.CarParams.CarFw()
item.ecu = structs.CarParams.Ecu.adas
fw.append(item)
return CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, fp, fw, alpha, release, False)
def feed(ci, tick, *, main=True, cruise=1, display=0, gas=False, brake=False, torque=0):
packer = CANPacker(DBC[ci.CP.carFingerprint][Bus.pt])
messages = []
for parser in ci.can_parsers.values():
for address in parser.addresses:
message = parser.dbc.addr_to_msg[address]
values = {'COUNTER': tick % 16} if 'COUNTER' in message.sigs else {}
if message.name == 'SCC12':
values.update(MainMode_ACC=int(main), ACCMode=cruise, SCCInfoDisplay=display, VSetDis=90)
elif message.name == 'EMS16':
values['CF_Ems_AclAct'] = int(gas)
elif message.name == 'TCS13':
values['DriverOverride'] = 2 if brake else 0
elif message.name == 'MDPS12':
values['CR_Mdps_StrColTq'] = torque
elif message.name == 'CGW1':
values['CF_Gway_DrvSeatBeltSw'] = 1
elif message.name == 'ALERTS_364':
values.update(BYTE2=0x52, BYTE3=0x31, BYTE5=0x75, BYTE6=0xA6, BYTE7=0x17, DAW_Warning=1)
messages.append(packer.make_can_msg(message.name, parser.bus, values))
return ci.update([(1_000_000_000 + tick * 10_000_000, messages)])
class TestPalisade2023(unittest.TestCase):
def test_exact_topology_stock_cp_and_original_driver_limits(self):
for topology, adas, hdaii in [('hdai', False, False), ('110', False, False), ('hdaii', False, True), ('hdai', True, True)]:
for alpha in (False, True):
for release in (False, True):
cp = params(topology, adas=adas, alpha=alpha, release=release)
self.assertEqual(cp.safetyConfigs[-1].safetyParam, 0x2010 if hdaii else 0x2000)
self.assertEqual(cp.safetyConfigs[-1].safetyModel, structs.CarParams.SafetyModel.hyundai)
self.assertEqual(cp.steerControlType, structs.CarParams.SteerControlType.torque)
self.assertEqual(cp.alphaLongitudinalAvailable, not release)
self.assertFalse(cp.openpilotLongitudinalControl or cp.dashcamOnly)
self.assertTrue(cp.pcmCruise)
self.assertAlmostEqual(cp.stopAccel, -.85, places=6)
self.assertEqual((CanBus(cp).ECAN, CanBus(cp).ACAN, CanBus(cp).CAM), (1, 0, 2) if hdaii else (0, 1, 2))
limits = CarControllerParams(cp)
self.assertEqual((limits.STEER_MAX, limits.STEER_DELTA_UP, limits.STEER_DELTA_DOWN, limits.STEER_DRIVER_ALLOWANCE),
(384, 3, 7, 50) if hdaii else (404, 2, 3, 50))
ci = CarInterface(cp)
with patch('opendbc.car.hyundai.interface.disable_ecu') as disable:
ci.init(cp, list, lambda _: None)
disable.assert_not_called()
parser = ci.can_parsers[Bus.pt]
self.assertEqual(parser.bus, int(hdaii))
self.assertEqual(parser.message_states[0x421].frequency, 50)
if hdaii:
self.assertEqual(ci.can_parsers[Bus.cam].message_states[0x2A4].frequency, 20)
def test_actual_parser_stock_cruise_gas_brake_and_mixed_camera_sources(self):
for topology in ('hdai', 'hdaii'):
ci = CarInterface(params(topology))
for tick in range(12):
result = feed(ci, tick, main=True, cruise=1, display=4, gas=True, brake=True, torque=175)
self.assertTrue(result.canValid)
self.assertTrue(result.cruiseState.available and result.cruiseState.enabled and result.cruiseState.standstill)
self.assertAlmostEqual(result.cruiseState.speed, 25., places=6)
self.assertTrue(result.gasPressed and result.brakePressed and result.steeringPressed)
self.assertFalse(result.stockAeb or result.stockFcw)
result = feed(ci, 12, main=False, cruise=0, display=2)
self.assertFalse(result.cruiseState.available or result.cruiseState.enabled or result.cruiseState.standstill)
self.assertTrue(result.cruiseState.nonAdaptive)
self.assertFalse(result.gasPressed or result.brakePressed)
self.assertEqual(bool(ci.CS.lkas11), topology == 'hdai')
def test_actual_controller_stock_frames_crc_opaque_alerts_and_no_longitudinal_tx(self):
for topology in ('hdai', 'hdaii'):
ci = CarInterface(params(topology))
for tick in range(12):
feed(ci, tick)
command = structs.CarControl()
command.enabled = command.latActive = True
command.actuators.torque = 1.
command.hudControl.setSpeed = 25.
previous = 0
for tick in range(20):
actuators, messages = ci.apply(command.as_reader(), 1_200_000_000 + tick * 10_000_000)
self.assertEqual(actuators.torqueOutputCan - previous, 3 if topology == 'hdaii' else 2)
previous = actuators.torqueOutputCan
addresses = {a for a, _, _ in messages}
self.assertFalse(addresses & {0x420, 0x421, 0x389, 0x7D0, 0x730})
if topology == 'hdaii':
self.assertEqual(addresses, {0x50, 0x2A4} if tick % 5 == 0 else {0x50})
self.assertTrue(all(bus == 0 for _, _, bus in messages))
else:
self.assertEqual(addresses, {0x340, 0x364})
for _, data, bus in messages:
self.assertEqual(bus, 0)
self.assertEqual(data[0], hyundai_checksum(data[1:8]))
alert = next(m for m in messages if m[0] == 0x364)
decoder = CANParser(DBC[ci.CP.carFingerprint][Bus.pt], [('ALERTS_364', 0)], 0)
decoder.update([(1_200_000_000 + tick * 10_000_000, [alert])])
self.assertEqual([decoder.vl['ALERTS_364'][k] for k in ('BYTE2', 'BYTE3', 'BYTE5', 'BYTE6', 'BYTE7')],
[0x52, 0x31, 0x75, 0xA6, 0x17])
self.assertEqual(decoder.vl['ALERTS_364']['DAW_Warning'], 0)
command.latActive = False
actuators, _ = ci.apply(command.as_reader(), 1_500_000_000)
self.assertEqual(actuators.torqueOutputCan, 0)
def test_cancel_resume_route_to_exact_pt_bus_and_sibling_params_unchanged(self):
for topology in ('hdai', 'hdaii'):
ci = CarInterface(params(topology))
for tick in range(12):
feed(ci, tick)
command = structs.CarControl()
command.cruiseControl.cancel = True
for tick in range(11):
_, messages = ci.apply(command.as_reader(), 1_200_000_000 + tick * 10_000_000)
buttons = [m for m in messages if m[0] == 0x4F1]
self.assertEqual(len(buttons), 1)
self.assertEqual(buttons[0][2], int(topology == 'hdaii'))
command.cruiseControl.cancel = False
command.cruiseControl.resume = True
ci.CC.frame = 20
_, messages = ci.apply(command.as_reader(), 1_500_000_000)
buttons = [m for m in messages if m[0] == 0x4F1]
self.assertEqual(len(buttons), 25)
self.assertTrue(all(m[2] == int(topology == 'hdaii') for m in buttons))
cp = CarInterface.get_params(CAR.HYUNDAI_PALISADE, gen_empty_fingerprint(), [], False, False, False)
self.assertEqual(cp.safetyConfigs[-1].safetyParam, 0)
self.assertFalse(cp.flags & HyundaiFlags.CANFD_LKA_STEER_MSG)
self.assertEqual(DBC[cp.carFingerprint][Bus.pt], 'hyundai_can_generated')
def test_required_source_loss_wrong_bus_and_optional_blended_lfa_payload(self):
for topology in ('hdai', 'hdaii'):
cp = params(topology)
cp.flags |= HyundaiFlags.SEND_LFA.value
ci = CarInterface(cp)
for tick in range(12):
result = feed(ci, tick)
self.assertTrue(result.canValid)
command = structs.CarControl()
command.latActive = True
command.hudControl.setSpeed = 25.
_, messages = ci.apply(command.as_reader(), 1_200_000_000)
lfa = next(m for m in messages if m[0] == 0x485)
self.assertEqual(lfa[2], int(topology == 'hdaii'))
self.assertEqual(lfa[1][0], hyundai_checksum(lfa[1][1:8]))
decoder = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LFAHDA_MFC', 0)], lfa[2])
decoder.update([(1_200_000_000, [lfa])])
self.assertEqual(decoder.vl['LFAHDA_MFC']['LFA_Icon_State'], 2)
pt = ci.can_parsers[Bus.pt]
old = dict(pt.vl['SCC12'])
bad_bus = 0 if topology == 'hdaii' else 1
frame = CANPacker(DBC[cp.carFingerprint][Bus.pt]).make_can_msg('SCC12', bad_bus, {'MainMode_ACC': 0})
# Required sources have timed out; parser validity debounces five invalid updates.
for invalid_tick in range(5):
result = ci.update([(2_000_000_000 + invalid_tick * 10_000_000, [frame])])
self.assertEqual(result.canValid, invalid_tick < 4)
self.assertFalse(result.canValid)
self.assertEqual(dict(pt.vl['SCC12']), old)
def test_actual_mixed_topology_metadata_correct_bus_and_wrong_bus(self):
for hdaii in (False, True):
for observation_bus in (0, 1, 2):
with self.subTest(hdaii=hdaii, observation_bus=observation_bus):
fingerprint = gen_empty_fingerprint()
if hdaii:
fingerprint[2][0x50] = 16
fingerprint[observation_bus][0x38D] = 8
fingerprint[observation_bus][0x58B] = 8
stock = CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, fingerprint, [], False, False, False)
expected_fca = observation_bus in (int(hdaii), 2)
self.assertEqual(bool(stock.flags & HyundaiFlags.USE_FCA), expected_fca)
self.assertEqual(stock.deprecated.enableBsm, observation_bus == int(hdaii))
def test_mixed_standstill_metadata_retains_original_bus_zero(self):
for hdaii in (False, True):
for observation_bus in (0, 1):
with self.subTest(hdaii=hdaii, observation_bus=observation_bus):
fingerprint = gen_empty_fingerprint()
if hdaii:
fingerprint[2][0x50] = 16
fingerprint[observation_bus][0x2AA] = 8
stock = CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, fingerprint, [], False, False, False)
self.assertEqual(stock.steerAtStandstill, observation_bus == 0)
# Exercise the reached flag clear with a real mutable CP, not a fake field bag.
stock.flags |= HyundaiFlags.MIN_STEER_32_MPH.value
stock.minSteerSpeed = 10.0
CarInterface._get_params(stock, CAR.HYUNDAI_PALISADE_2023, fingerprint, [], False, False, False)
self.assertEqual(bool(stock.flags & HyundaiFlags.MIN_STEER_32_MPH), observation_bus != 0)
self.assertEqual(stock.minSteerSpeed, 0.0 if observation_bus == 0 else 10.0)
@@ -0,0 +1,110 @@
"""Mixed Palisade radar qualifies through the actual factory/parser/ownership transaction."""
import pytest
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.radar_interface import RadarInterface, get_radar_can_parser, radar_dbc
from opendbc.car.hyundai.hyundaicanfd import CanBus
from opendbc.car.hyundai.values import CAR, DBC, HYUNDAI_GV70_RADAR_DBC
from opendbc.car.hyundai.blended_longitudinal import candidate_from_stock, BlendedLongitudinalOwner, Outcome, DISABLE, ENABLE
def params(bank=None, lka=True, bus=0):
fp = gen_empty_fingerprint()
if lka:
fp[2][0x50] = 32
if bank is not None:
fp[bus].update(bank)
return CarInterface.get_params(CAR.HYUNDAI_PALISADE_2023, fp, [], False, False, False)
def test_exact_full_bank_factory_availability_does_not_grant_long_control():
full = dict.fromkeys(range(0x210, 0x220), 32)
absent = params()
stock = params(full)
assert absent.radarUnavailable and not stock.radarUnavailable
assert stock.flags == absent.flags
assert [(r.safetyModel, r.safetyParam) for r in stock.safetyConfigs] == [(r.safetyModel, r.safetyParam) for r in absent.safetyConfigs]
assert not stock.openpilotLongitudinalControl and not absent.openpilotLongitudinalControl
assert stock.pcmCruise and absent.pcmCruise
assert radar_dbc(stock) == HYUNDAI_GV70_RADAR_DBC
assert Bus.radar not in DBC[CAR.HYUNDAI_PALISADE_2023] # Static siblings/config untouched.
for addr in full:
missing = full.copy()
missing.pop(addr)
assert params(missing).radarUnavailable
short = full.copy()
short[addr] = 24
assert params(short).radarUnavailable
assert params(full, bus=1).radarUnavailable
assert params(full, lka=False).radarUnavailable
def frames():
packer = CANPacker(HYUNDAI_GV70_RADAR_DBC)
result = []
for ordinal, addr in enumerate(range(0x210, 0x220)):
values = {}
for slot in (1, 2):
count = ordinal * 2 + slot
values.update({f"{slot}_STATE": 3, f"{slot}_LONG_DIST": count, f"{slot}_LAT_DIST": -0.05 * count, f"{slot}_REL_SPEED": -0.01 * count})
result.append(packer.make_can_msg(f"RADAR_TRACK_{addr:x}", 0, values))
return result
def test_actual_parser_full_32_slots_exact_wire_and_fresh_source():
stock = params(dict.fromkeys(range(0x210, 0x220), 32))
radar = RadarInterface(stock)
assert (radar.start_addr, radar.msg_count, radar.rcp.bus) == (0x210, 16, 0)
packed = frames()
assert all(len(data) == 32 and bus == 0 for _, data, bus in packed)
result = radar.update((1_000_000_000, packed))
assert result is not None and not result.errors.canError
assert len(result.points) == len({p.trackId for p in result.points}) == 32
for index, point in enumerate(result.points, 1):
assert point.dRel == pytest.approx(index, abs=0.051)
assert point.yRel == pytest.approx(-0.05 * index, abs=0.026)
assert point.vRel == pytest.approx(-0.01 * index, abs=0.051)
# One trigger-only frame cannot resurrect any missing older slot after50ms.
result = radar.update((1_100_000_000, [packed[-1]]))
assert all(point.dRel >= 31.0 for point in result.points)
wrong_bus = [(addr, data, 1) for addr, data, _ in packed]
fresh = RadarInterface(stock)
assert fresh.update((1_000_000_000, wrong_bus)) is None
short = [(addr, data[:-1], bus) for addr, data, bus in packed]
fresh = RadarInterface(stock)
assert fresh.update((1_000_000_000, short)) is None
def test_current_owner_requires_native_admission_and_ack_before_candidate_publication():
stock = params(dict.fromkeys(range(0x210, 0x220), 32))
assert candidate_from_stock(stock, alpha_requested=True, native_qualified=False) is None
assert candidate_from_stock(stock, alpha_requested=False, native_qualified=True) is None
candidate = candidate_from_stock(stock, alpha_requested=True, native_qualified=True)
assert candidate is not None and not candidate.radarUnavailable
assert candidate.openpilotLongitudinalControl and not candidate.pcmCruise
calls = []
def exchange(*args, **kwargs):
calls.append(kwargs["com_cont_req"])
return True
def restore(*args, **kwargs):
calls.append(ENABLE)
return True
owner = BlendedLongitudinalOwner(candidate, CanBus(candidate), exchange, restore_exchange=restore)
with pytest.raises(RuntimeError):
owner.begin(None, None, unpublished=False, admission=True)
assert calls == []
result = owner.begin(None, None, unpublished=True, admission=True)
assert result.outcome == Outcome.OWNED and owner.active() and calls == [DISABLE]
assert owner.restore(None, None)
assert not owner.active() and calls == [DISABLE, ENABLE]
def test_missing_radar_remains_unavailable_in_admitted_candidate():
candidate = candidate_from_stock(params(), alpha_requested=True, native_qualified=True)
assert candidate is not None and candidate.radarUnavailable
assert get_radar_can_parser(candidate) is not None
@@ -0,0 +1,171 @@
import unittest
from opendbc.can import CANPacker, CANParser
from opendbc.can.dbc import DBC as CANDBC, SignalType
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.ray_pedal import create_ray_pedal_command, ray_pedal_gas, ray_pedal_enabled
from opendbc.car.hyundai.values import CAR, DBC, HyundaiSafetyFlags
def ray_fingerprint(sensor=6, lfa=8):
fp = gen_empty_fingerprint()
fp[0].update({0x201: sensor, 0x391: 8})
fp[2][0x485] = lfa
return fp
def ray_controller():
cp = CarInterface.get_params(CAR.KIA_RAY_EV, ray_fingerprint(), [], False, False, False)
controller = CarController(DBC[cp.carFingerprint], cp)
state = CarState(cp)
state.out = structs.CarState()
state.out.vEgo = state.out.vEgoRaw = 12
state.out.gearShifter = structs.CarState.GearShifter.drive
state.ray_pedal_valid = True
state.ray_pedal_state = 0
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LKAS11', 0), ('CLU11', 0)], 0)
state.lkas11, state.clu11 = parser.vl['LKAS11'], parser.vl['CLU11']
command = structs.CarControl()
command.enabled = command.latActive = command.longActive = True
command.hudControl.setSpeed = 20
command.actuators.accel = 1.5
return cp, controller, command, state
class TestRayPedal(unittest.TestCase):
def test_actual_cp_signature_is_unique_and_requires_exact_sensor_layout(self):
signature = int(HyundaiSafetyFlags.EV_GAS | HyundaiSafetyFlags.LONG | HyundaiSafetyFlags.NON_SCC |
HyundaiSafetyFlags.HAS_LDA_BUTTON | HyundaiSafetyFlags.CAN_REFRESH_MSGS)
self.assertEqual(signature, 0x9805)
for car in CAR:
for alpha in (False, True):
cp = CarInterface.get_params(car, ray_fingerprint(), [], alpha, False, False)
self.assertEqual(ray_pedal_enabled(cp), car == CAR.KIA_RAY_EV)
self.assertEqual(cp.safetyConfigs[-1].safetyParam == signature, car == CAR.KIA_RAY_EV)
for fp in (ray_fingerprint(sensor=8), ray_fingerprint(lfa=4)):
cp = CarInterface.get_params(CAR.KIA_RAY_EV, fp, [], False, False, False)
self.assertFalse(ray_pedal_enabled(cp))
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertTrue(cp.pcmCruise)
cp, _, _, _ = ray_controller()
self.assertTrue(cp.openpilotLongitudinalControl)
self.assertFalse(cp.pcmCruise)
self.assertFalse(cp.autoResumeSng)
self.assertEqual(cp.minEnableSpeed, -1)
def test_sensor_real_route_crc_fault_and_physical_override(self):
cp, _, _, state = ray_controller()
parsers = state.get_can_parsers(cp)
state.update(parsers)
samples = ['01f403d55de8', '01f603d55ef1', '01f403d55f51', '01f603d3503f', '01f903d551ab', '01f903d552a4', '01f703d55370']
for index, sample in enumerate(samples):
frame = (0x201, bytes.fromhex(sample), 0)
parsers[Bus.party].update([(1_000_000_000 + index * 20_000_000, [frame])])
result = state.update(parsers)
self.assertTrue(state.ray_pedal_valid)
self.assertEqual(state.ray_pedal_state, 5)
self.assertTrue(result.accFaulted)
prior = dict(parsers[Bus.party].vl['GAS_SENSOR'])
bad = bytearray(bytes.fromhex(samples[-1]))
bad[-1] ^= 1
parsers[Bus.party].update([(1_160_000_000, [(0x201, bytes(bad), 0)])])
self.assertEqual(dict(parsers[Bus.party].vl['GAS_SENSOR']), prior)
packer = CANPacker('hyundai_kia_ray_pedal')
for counter, tracks, pressed in ((4, (0, 0), False), (5, ((310-264)*.672, (593-497)*.332), True)):
sensor = packer.make_can_msg('GAS_SENSOR', 0, {'INTERCEPTOR_GAS': tracks[0], 'INTERCEPTOR_GAS2': tracks[1],
'STATE': 0, 'COUNTER_PEDAL': counter})
parsers[Bus.party].update([(1_200_000_000 + counter * 20_000_000, [sensor])])
result = state.update(parsers)
self.assertFalse(result.accFaulted)
self.assertEqual(result.gasPressed, pressed)
camera = CANPacker(DBC[cp.carFingerprint][Bus.pt]).make_can_msg('LABEL11', 0, {'CC_React': 1, 'CC_Engaged': 1})
parsers[Bus.pt].update([(1_500_000_000, [camera])])
result = state.update(parsers)
self.assertTrue(result.cruiseState.enabled)
self.assertTrue(result.cruiseState.available)
def test_checksum_extension_is_scoped_to_ray_dbc(self):
for name in ('honda_accord_2017_can_ext_generated', 'toyota_nodsu_pt_generated'):
db = CANDBC(name)
for msg in db.name_to_msg.values():
for sig in msg.sigs.values():
if sig.name in ('CHECKSUM_PEDAL', 'COUNTER_PEDAL'):
self.assertEqual(sig.type, SignalType.DEFAULT)
self.assertIsNone(sig.calc_checksum)
db = CANDBC('hyundai_kia_ray_pedal')
self.assertEqual(db.name_to_msg['GAS_SENSOR'].sigs['CHECKSUM_PEDAL'].type, SignalType.RAY_PEDAL_CHECKSUM)
packet = create_ray_pedal_command(CANPacker('hyundai_kia_ray_pedal'), 0, 15)
self.assertEqual(packet[1][:4], bytes(4))
self.assertEqual(packet[1][4] & 15, 15)
def test_controller_cadence_override_cancellation_and_no_scc_commands(self):
cp, controller, command, state = ray_controller()
def messages(frame):
controller.frame = frame
return controller.update(command.as_reader(), state, frame * 10_000_000)[1]
first = messages(0)
self.assertFalse(any(frame[0] == 0x340 for frame in first))
self.assertTrue(any(frame[0] == 0x485 and len(frame[1]) == 8 for frame in first))
self.assertFalse(any(frame[0] in (0x420, 0x421, 0x50A, 0x389, 0x7D0) for frame in first))
self.assertTrue(any(frame[0] == 0x340 for frame in messages(1)))
self.assertFalse(any(frame[0] == 0x200 for frame in messages(2)))
for frame in range(4, 160, 4):
pedal = next(m for m in messages(frame) if m[0] == 0x200)
self.assertEqual(pedal[1][4] & 15, frame // 4 & 15)
self.assertAlmostEqual(controller._ray_pedal_gas_last, .55)
for cause in ('brake', 'gas', 'override', 'inactive', 'invalid', 'fault'):
with self.subTest(cause=cause):
cp, controller, command, state = ray_controller()
if cause == 'brake':
state.out.brakePressed = True
elif cause == 'gas':
state.out.gasPressed = True
elif cause == 'override':
command.cruiseControl.override = True
elif cause == 'inactive':
command.longActive = False
elif cause == 'invalid':
state.ray_pedal_valid = False
else:
state.ray_pedal_state = 5
pedal = next(m for m in messages(20) if m[0] == 0x200)
self.assertEqual(pedal[1][:4], bytes(4))
self.assertFalse(pedal[1][4] & 0x80)
state.out.cruiseState.enabled = True
state.out.gasPressed = True
msgs = messages(24)
self.assertTrue(any(m[0] == 0x4F1 and m[1][0] & 7 == 4 for m in msgs))
self.assertFalse(any(m[0] == 0x4F1 for m in messages(25)))
def test_source_ray_dashboard_icons_active_and_disengaging(self):
cp, controller, command, state = ray_controller()
parser = CANParser('hyundai_kia_ray_lfa', [('LFAHDA_MFC', 0)], 0)
lkas = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LKAS11', 0)], 0)
for frame in range(106):
if frame == 5:
command.enabled = command.latActive = command.longActive = False
sends = controller.update(command.as_reader(), state, frame*10_000_000)[1]
parser.update([(frame+1, sends)])
lkas.update([(frame+1, sends)])
if frame in (0, 5, 100, 105):
expected = 2 if frame == 0 else 3 if frame < 104 else 0
self.assertEqual(parser.vl['LFAHDA_MFC']['LFA_Icon_State'], expected)
if frame:
self.assertEqual(lkas.vl['LKAS11']['CF_Lkas_FcwOpt_USM'], expected or 1)
def test_exact_latest_taper_rate_and_regen_behavior(self):
gas = 0
for _ in range(40):
gas = ray_pedal_gas(gas, 12, 1.5, 20)
self.assertEqual(gas, .55)
gas = ray_pedal_gas(gas, 12, 1.5, 12)
self.assertAlmostEqual(gas, .55 * .65)
gas = ray_pedal_gas(gas, 12, 1.5, 11.8)
self.assertAlmostEqual(gas, .55 * .65 * .6)
for speed in (11.4, float('nan'), .9, 40.1):
self.assertEqual(ray_pedal_gas(gas, 12, 1.5, speed), 0)
self.assertEqual(ray_pedal_gas(.55, 12, -1.5, 20), 0)
@@ -0,0 +1,52 @@
"""Private qualification: execute only after root assembles source and native candidate."""
import math
import pytest
from opendbc.car.hyundai.ray_pedal import get_ray_pedal_target, ray_pedal_gas, pedal_checksum
from opendbc.car.hyundai.tests.test_ray_pedal import ray_controller
from opendbc.safety.tests.test_hyundai_ray_pedal import TestHyundaiRayPedal
@pytest.mark.parametrize("speed,cap", [(0, 0.55), (12, 0.55), (20, 0.65), (25, 0.70), (35, 0.70)])
def test_speed_cap_and_slew(speed, cap):
assert get_ray_pedal_target(10, speed, min(speed + 2, 40)) == pytest.approx(cap)
assert ray_pedal_gas(0, speed, 10, min(speed + 2, 40)) == pytest.approx(0.02)
@pytest.mark.parametrize("field", ["accel", "speed", "set_speed"])
@pytest.mark.parametrize("bad", [math.nan, math.inf, -math.inf])
def test_nonfinite_target_is_zero(field, bad):
values = dict(accel=0.5, speed=20, set_speed=25)
values[field] = bad
assert get_ray_pedal_target(**values) == 0
@pytest.mark.parametrize("gate", ["gasPressed", "brakePressed", "override", "longActive", "fault", "invalid"])
def test_actual_controller_disabled_frames_on_gates(gate):
cp, controller, cc, cs = ray_controller()
controller._ray_pedal_gas_last = 0.7
if gate in ("gasPressed", "brakePressed"):
setattr(cs.out, gate, True)
elif gate == "override":
cc.cruiseControl.override = True
elif gate == "longActive":
cc.longActive = False
elif gate == "fault":
cs.ray_pedal_state = 1
else:
cs.ray_pedal_valid = False
controller.frame = 4
_, frames = controller.update(cc.as_reader(), cs, 1_000_000_000)
data = next(f[1] for f in frames if f[0] == 0x200)
assert data[:4] == bytes(4)
assert data[4] & 0x80 == 0
assert controller._ray_pedal_gas_last == 0
class TestNativeWafflesBoundaries(TestHyundaiRayPedal):
def test_each_raw_track_boundary(self):
self.arm()
for a, b, allowed in [(530, 1035, True), (531, 1035, False), (530, 1036, False), (263, 497, False), (264, 496, False), (530, 980, False)]:
data = bytearray(a.to_bytes(2, "big") + b.to_bytes(2, "big") + bytes([0x81, 0]))
data[-1] = pedal_checksum(0, None, data)
self.assertEqual(self.safety.safety_tx_hook(self.packet((0x200, bytes(data), 0))), allowed)
@@ -0,0 +1,68 @@
import itertools
import unittest
from opendbc.can import CANParser
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.values import CAR, DBC
def controller(car, alpha):
cp = CarInterface.get_params(car, gen_empty_fingerprint(), [], alpha, False, False)
cc = CarController(DBC[cp.carFingerprint], cp)
cs = CarState(cp)
cs.out = structs.CarState.new_message(vEgo=15., vEgoRaw=15.)
cs.out.gearShifter = structs.CarState.GearShifter.drive
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LKAS11', 0), ('CLU11', 0)], 0)
cs.lkas11, cs.clu11 = parser.vl['LKAS11'], parser.vl['CLU11']
return cc, cs, parser
class TestStingerHud(unittest.TestCase):
def test_lkas_cluster_status_matches_active_lateral(self):
for car, alpha, enabled, lateral in itertools.product(
(CAR.KIA_STINGER_2022, CAR.KIA_STINGER, CAR.HYUNDAI_SONATA), (False, True), (False, True), (False, True)):
with self.subTest(car=car, alpha=alpha, enabled=enabled, lateral=lateral):
cc, cs, parser = controller(car, alpha)
command = structs.CarControl.new_message(enabled=enabled, latActive=lateral)
command.hudControl.leftLaneVisible = command.hudControl.rightLaneVisible = True
command.hudControl.setSpeed = 25.
command.actuators.torque = .3
for tick in range(2):
_, frames = cc.update(command.as_reader(), cs, (tick + 1) * 10_000_000)
parser.update([((tick + 1) * 10_000_000, frames)])
hud_active = enabled or (car == CAR.KIA_STINGER_2022 and lateral)
values = parser.vl['LKAS11']
self.assertEqual(values['CF_Lkas_LdwsSysState'], 3 if hud_active else 4)
if car != CAR.KIA_STINGER:
self.assertEqual(values['CF_Lkas_FcwOpt_USM'], 2 if hud_active else 1)
self.assertEqual(values['CF_Lkas_ActToi'], int(lateral))
self.assertEqual(values['CF_Lkas_ToiFlt'], 0)
self.assertEqual(values['CF_Lkas_MsgCount'], 1)
if not lateral:
self.assertEqual(values['CR_Lkas_StrToqReq'], 0)
def test_lateral_hud_keeps_lane_warnings_and_driver_alerts(self):
for left, right, alert in itertools.product((False, True), (False, True),
(structs.CarControl.HUDControl.VisualAlert.none,
structs.CarControl.HUDControl.VisualAlert.steerRequired)):
cc, cs, parser = controller(CAR.KIA_STINGER_2022, False)
command = structs.CarControl.new_message(enabled=False, latActive=True)
command.hudControl.leftLaneVisible, command.hudControl.rightLaneVisible = left, right
command.hudControl.leftLaneDepart, command.hudControl.rightLaneDepart = True, True
command.hudControl.visualAlert = alert
_, frames = cc.update(command.as_reader(), cs, 10_000_000)
parser.update([(10_000_000, frames)])
values = parser.vl['LKAS11']
warning = alert == structs.CarControl.HUDControl.VisualAlert.steerRequired
self.assertEqual(values['CF_Lkas_LdwsSysState'], 3 if (left and right) or warning else 5 if left else 6 if right else 1)
self.assertEqual(values['CF_Lkas_SysWarning'], 4 if warning else 0)
self.assertEqual(values['CF_Lkas_LdwsLHWarning'], 2)
self.assertEqual(values['CF_Lkas_LdwsRHWarning'], 2)
self.assertEqual(values['CF_Lkas_LdwsActivemode'], int(left) + 2 * int(right))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,74 @@
import unittest
from opendbc.can import CANParser
from opendbc.car import Bus, gen_empty_fingerprint, structs
from opendbc.car.fw_versions import match_fw_to_car
from opendbc.car.hyundai.carcontroller import CarController
from opendbc.car.hyundai.carstate import CarState
from opendbc.car.hyundai.fingerprints import FW_VERSIONS
from opendbc.car.hyundai.interface import CarInterface
from opendbc.car.hyundai.radar_interface import RADAR_START_ADDR, get_radar_can_parser
from opendbc.car.hyundai.values import CAR, DBC, HyundaiFlags, HyundaiSafetyFlags
from opendbc.safety.tests import common
from opendbc.safety.tests.libsafety import libsafety_py
class TestXceedPhevPort(unittest.TestCase):
@staticmethod
def params(alpha_long=False, *, release=False):
fingerprint = gen_empty_fingerprint()
fingerprint[1][RADAR_START_ADDR] = 8
return CarInterface.get_params(CAR.KIA_XCEED_PHEV, fingerprint, [], alpha_long, release, False)
def test_saved_firmware_and_stock_legacy_hybrid_configuration(self):
versions = FW_VERSIONS[CAR.KIA_XCEED_PHEV]
self.assertEqual(len(versions), 3)
observed = [structs.CarParams.CarFw(ecu=ecu, address=addr, subAddress=0,
fwVersion=values[0], brand='hyundai')
for (ecu, addr, _), values in versions.items()]
exact, matches = match_fw_to_car(observed, '', allow_exact=True, allow_fuzzy=False, log=False)
self.assertTrue(exact)
self.assertEqual(matches, {CAR.KIA_XCEED_PHEV, CAR.KIA_CEED_PHEV})
for alpha_long, release in ((False, False), (True, False), (False, True), (True, True)):
cp = self.params(alpha_long, release=release)
self.assertTrue(cp.flags & HyundaiFlags.LEGACY)
self.assertTrue(cp.flags & HyundaiFlags.HYBRID)
self.assertTrue(cp.flags & HyundaiFlags.MANDO_RADAR)
self.assertEqual(cp.alphaLongitudinalAvailable, not release)
self.assertFalse(cp.openpilotLongitudinalControl)
self.assertTrue(cp.pcmCruise)
self.assertFalse(cp.radarUnavailable)
self.assertEqual(cp.safetyConfigs[-1].safetyModel, structs.CarParams.SafetyModel.hyundaiLegacy)
self.assertTrue(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.HYBRID_GAS)
self.assertFalse(cp.safetyConfigs[-1].safetyParam & HyundaiSafetyFlags.LONG)
self.assertEqual(DBC[CAR.KIA_XCEED_PHEV][Bus.radar], 'hyundai_kia_mando_front_radar_generated')
self.assertEqual(get_radar_can_parser(cp).bus, 1)
def test_actual_controller_and_native_safety_keep_scc_with_stock(self):
cp = self.params()
state = CarState(cp)
parsers = state.get_can_parsers(cp)
state.update(parsers)
control = structs.CarControl()
control.enabled = True
control.latActive = True
control.longActive = False
controller = CarController(DBC[CAR.KIA_XCEED_PHEV], cp)
_, sends = controller.update(control.as_reader(), state, 1_000_000_000)
self.assertIn(0x340, [addr for addr, _, _ in sends])
self.assertNotIn(0x420, [addr for addr, _, _ in sends])
self.assertNotIn(0x421, [addr for addr, _, _ in sends])
safety = libsafety_py.libsafety
safety.set_safety_hooks(int(cp.safetyConfigs[-1].safetyModel.raw), cp.safetyConfigs[-1].safetyParam)
safety.init_tests()
self.assertFalse(safety.safety_tx_hook(common.make_msg(0, 0x420, 8)))
lkas = next(frame for frame in sends if frame[0] == 0x340)
self.assertTrue(safety.safety_tx_hook(libsafety_py.make_CANPacket(lkas[0], lkas[2], lkas[1])))
parser = CANParser(DBC[CAR.KIA_XCEED_PHEV][Bus.pt], [('LKAS11', 0)], 0)
parser.update([(1_000_000_000, sends)])
self.assertEqual(parser.vl['LKAS11']['CR_Lkas_StrToqReq'], 0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,22 @@
# Ioniq 6 reference fixtures
These synthetic fixtures check source behavior and CAN payloads. They contain
no recorded routes, VINs, or location data.
- `ioniq6_long_calibration.json` records 100 successive calls to the reference
acceleration calibration. The test also checks intentional
disengagement and override resets separately.
- `ioniq6_heartbeat.json` records the reference 24-byte radar heartbeat at
counters 0, 1, 255, and 256 for all four physical pedal combinations.
It checks payload integrity and counter rollover.
- `ioniq6_bsm_frozen.json` records all 16 left/right presence and blinker
combinations from the reference blind-spot encoder, using a
synthetic neutral base and counters 0–15. The frozen indicator mirror bits
remain in the raw payload even though the current DBC omits their names; one
alias is decoded as a sound-warning field, so this fixture proves bytes,
not audible behavior.
- `scc_parent_frames.json` records the default SCC packer's output before the
Ioniq-specific options were added. Other vehicles retain these defaults.
These are source and wire-format regression checks. They do not establish
device startup timing, ECU takeover behavior, or on-road qualification.
@@ -0,0 +1,149 @@
{
"frozenSourceCommit": "678af78347d9656bc2f5dacd4204b012db861fa7",
"cases": [
{
"left": false,
"right": false,
"leftBlinker": false,
"rightBlinker": false,
"counter": 0,
"rear": "b3f200000000008000000000000000000000000000000000",
"front": "1a430000000000000000000100000000"
},
{
"left": false,
"right": false,
"leftBlinker": false,
"rightBlinker": true,
"counter": 1,
"rear": "b0c501000000008000000000000000000000000000000000",
"front": "3eeb0100000000000000000100000000"
},
{
"left": false,
"right": false,
"leftBlinker": true,
"rightBlinker": false,
"counter": 2,
"rear": "b59c02000000008000000000000000000000000000000000",
"front": "73030200000000000000000100000000"
},
{
"left": false,
"right": false,
"leftBlinker": true,
"rightBlinker": true,
"counter": 3,
"rear": "b6ab03000000008000000000000000000000000000000000",
"front": "57ab0300000000000000000100000000"
},
{
"left": false,
"right": true,
"leftBlinker": false,
"rightBlinker": false,
"counter": 4,
"rear": "c1cd04010100208800000000000000000400000000000000",
"front": "c8c30400000000000000000100000000"
},
{
"left": false,
"right": true,
"leftBlinker": false,
"rightBlinker": true,
"counter": 5,
"rear": "7c6005010200409000000000000000000800000000000000",
"front": "ec6b0500000000000000000100000000"
},
{
"left": false,
"right": true,
"leftBlinker": true,
"rightBlinker": false,
"counter": 6,
"rear": "c7a306010100208800000000000000000400000000000000",
"front": "a1830600000000000000000100000000"
},
{
"left": false,
"right": true,
"leftBlinker": true,
"rightBlinker": true,
"counter": 7,
"rear": "7a0e07010200409000000000000000000800000000000000",
"front": "852b0700000000000000000100000000"
},
{
"left": true,
"right": false,
"leftBlinker": false,
"rightBlinker": false,
"counter": 8,
"rear": "efba08410002008800000000000000000100000000000000",
"front": "9f520800000000000000000100000000"
},
{
"left": true,
"right": false,
"leftBlinker": false,
"rightBlinker": true,
"counter": 9,
"rear": "ec8d09410002008800000000000000000100000000000000",
"front": "bbfa0900000000000000000100000000"
},
{
"left": true,
"right": false,
"leftBlinker": true,
"rightBlinker": false,
"counter": 10,
"rear": "7a4b0a810004009000000000000000000200000000000000",
"front": "f6120a00000000000000000100000000"
},
{
"left": true,
"right": false,
"leftBlinker": true,
"rightBlinker": true,
"counter": 11,
"rear": "797c0b810004009000000000000000000200000000000000",
"front": "d2ba0b00000000000000000100000000"
},
{
"left": true,
"right": true,
"leftBlinker": false,
"rightBlinker": false,
"counter": 12,
"rear": "a8700c410102208800000000000000000500000000000000",
"front": "4dd20c00000000000000000100000000"
},
{
"left": true,
"right": true,
"leftBlinker": false,
"rightBlinker": true,
"counter": 13,
"rear": "15dd0d410202409000000000000000000900000000000000",
"front": "697a0d00000000000000000100000000"
},
{
"left": true,
"right": true,
"leftBlinker": true,
"rightBlinker": false,
"counter": 14,
"rear": "3d810e810104209000000000000000000600000000000000",
"front": "24920e00000000000000000100000000"
},
{
"left": true,
"right": true,
"leftBlinker": true,
"rightBlinker": true,
"counter": 15,
"rear": "e38c0f810204409000000000000000000a00000000000000",
"front": "003a0f00000000000000000100000000"
}
]
}
@@ -0,0 +1,101 @@
{
"source": "678af78347d9656bc2f5dacd4204b012db861fa7",
"cases": [
{
"counter": 0,
"brake": false,
"gas": false,
"payload": "342400020000fcff000000000020000055ff000068000000"
},
{
"counter": 0,
"brake": true,
"gas": false,
"payload": "ec4900020100fcff000000000020000055ff000068000000"
},
{
"counter": 0,
"brake": false,
"gas": true,
"payload": "805200020000fcff000000000020000055ff000068000100"
},
{
"counter": 0,
"brake": true,
"gas": true,
"payload": "583f00020100fcff000000000020000055ff000068000100"
},
{
"counter": 1,
"brake": false,
"gas": false,
"payload": "371301020000fcff000000000020000055ff000068000000"
},
{
"counter": 1,
"brake": true,
"gas": false,
"payload": "ef7e01020100fcff000000000020000055ff000068000000"
},
{
"counter": 1,
"brake": false,
"gas": true,
"payload": "836501020000fcff000000000020000055ff000068000100"
},
{
"counter": 1,
"brake": true,
"gas": true,
"payload": "5b0801020100fcff000000000020000055ff000068000100"
},
{
"counter": 255,
"brake": false,
"gas": false,
"payload": "46faff020000fcff000000000020000055ff000068000000"
},
{
"counter": 255,
"brake": true,
"gas": false,
"payload": "9e97ff020100fcff000000000020000055ff000068000000"
},
{
"counter": 255,
"brake": false,
"gas": true,
"payload": "f28cff020000fcff000000000020000055ff000068000100"
},
{
"counter": 255,
"brake": true,
"gas": true,
"payload": "2ae1ff020100fcff000000000020000055ff000068000100"
},
{
"counter": 256,
"brake": false,
"gas": false,
"payload": "342400020000fcff000000000020000055ff000068000000"
},
{
"counter": 256,
"brake": true,
"gas": false,
"payload": "ec4900020100fcff000000000020000055ff000068000000"
},
{
"counter": 256,
"brake": false,
"gas": true,
"payload": "805200020000fcff000000000020000055ff000068000100"
},
{
"counter": 256,
"brake": true,
"gas": true,
"payload": "583f00020100fcff000000000020000055ff000068000100"
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,102 @@
{"baseSourceSha256":"31a7ba0d96c4b8bcc75ee2e28ce173067bfb8569cd70db37ad78b2473e6dbe7b","originalTraceSha256":"3823b567e6df8f78c2e396e22d02f703b14b42eaab05a6981d96e71ef42c42ee","method":"Committed prechange calibration, one approved accel-below-speed-dependent-floor launch revocation guard","outputs":[
[0.5,0.03,0.03,0.6,0.6,false,false,3],
[0.5,0.06,0.06,0.6,1.1403636363636365,false,false,3],
[0.5,0.09,0.09,0.6,1.1752727272727275,false,false,3],
[0.5,0.12,0.12,0.6,1.2101818181818182,false,false,3],
[0.5,0.15,0.15,0.6,1.2450909090909092,false,false,3],
[0.5,0.18,0.18,0.6,1.28,false,false,3],
[0.5,0.21,0.21,0.6,1.314909090909091,false,false,3],
[0.5,0.24,0.24,0.6,1.3498181818181818,false,false,3],
[1.2,0.4896,0.4896,4.992,1.1520000000000001,true,false,1],
[1.2,0.7392,0.7392,4.992,1.4147956363636365,true,false,1],
[1.2,0.9887999999999999,0.9887999999999999,4.992,1.530973090909091,true,false,1],
[1.2,1.2,1.2,4.992,1.6471505454545456,true,false,1],
[1.2,1.2,1.2,4.992,1.7454545454545456,true,false,1],
[1.2,1.2,1.2,4.992,1.7454545454545456,true,false,1],
[1.2,1.2,1.2,4.992,1.7454545454545456,true,false,1],
[1.2,1.2,1.2,4.992,1.7454545454545456,true,false,1],
[1.2,1.2,1.2,4.992,1.7454545454545456,true,false,1],
[1.2,1.2,1.2,4.992,1.7454545454545456,true,false,1],
[1.2,1.2,1.2,4.992,1.7454545454545456,true,false,1],
[1.2,1.2,1.2,4.992,1.7454545454545456,true,false,1],
[1.5,1.2375,1.2375,0.7500000000000001,1.6058181818181818,false,false,1],
[1.5,1.2703125,1.2703125,0.6562499999999999,1.6232727272727274,false,false,1],
[1.5,1.3003125,1.3003125,0.6,1.6385454545454547,false,false,1],
[1.5,1.3303125,1.3303125,0.6,1.652509090909091,false,false,1],
[1.5,1.3603125,1.3603125,0.6,1.6664727272727273,false,false,1],
[1.5,1.3903125,1.3903125,0.6,1.6804363636363637,false,false,1],
[1.5,1.4203125,1.4203125,0.6,1.6944000000000001,false,false,1],
[1.5,1.4503125000000001,1.4503125000000001,0.6,1.7083636363636365,false,false,1],
[1.5,1.4803125000000001,1.4803125000000001,0.6,1.722327272727273,false,false,1],
[1.5,1.5,1.5,0.6,1.7362909090909093,false,false,1],
[1.5,1.5,1.5,0.6,1.7454545454545456,false,false,1],
[1.5,1.5,1.5,0.6,1.7454545454545456,false,false,1],
[-1.5,1.32,1.32,0.6,3.5999999999999996,false,false,1],
[-1.5,1.1828072727272727,1.1828072727272727,0.6,2.7438545454545453,false,false,1],
[-1.5,1.0621259107438017,1.0621259107438017,0.6,2.4136272396694216,false,false,1],
[-1.5,0.9519767403516154,0.9519767403516154,0.6,2.2029834078437265,false,false,1],
[-1.5,0.8514405884663835,0.8514405884663835,0.6,2.010723037704638,false,false,1],
[-1.5,0.7596785007456809,0.7596785007456809,0.6,1.8352417544140514,false,false,1],
[-1.5,0.6733441647283268,0.6733441647283268,0.6,1.7266867203470808,false,false,1],
[-1.5,0.5890190641673767,0.5890190641673767,0.6,1.6865020112190032,false,false,1],
[-1.5,0.5066564386740269,0.5066564386740269,0.6,1.6472525098669972,false,false,1],
[-1.5,0.42621061610124955,0.42621061610124955,0.6,1.6089164514555472,false,false,1],
[-1.5,0.34763698721743863,0.34763698721743863,0.6,1.5714725776762182,false,false,1],
[-1.5,0.27089198096946915,0.27089198096946915,0.6,1.5349001249593897,false,false,1],
[-3.0,0.15997777172122457,0.15997777172122457,0.6,2.2182841849648915,false,true,2],
[-3.0,0.058743348007372256,0.058743348007372256,0.6,2.0246884742770463,false,true,2],
[-3.0,-0.03365607145508934,-0.03365607145508934,0.6,1.8479883892492317,false,true,2],
[-3.0,-0.1201455301557709,-0.1201455301557709,0.6,1.7297891740136313,false,true,2],
[-3.0,-0.20462214327214567,-0.20462214327214567,0.6,1.6895322623274958,false,true,2],
[-3.0,-0.2871327552105394,-0.2871327552105394,0.6,1.6502122387678742,false,true,2],
[-3.0,-0.36772312018018505,-0.36772312018018505,0.6,1.6118072993929127,false,true,2],
[-3.0,-0.4464379275650826,-0.4464379275650826,0.6,1.5742961476979505,false,true,2],
[-3.0,-0.5233208267053862,-0.5233208267053862,0.6,1.5376579828060708,false,true,2],
[-3.0,-0.5984144511020608,-0.5984144511020608,0.6,1.501872487933493,false,true,2],
[-3.0,-0.671760442058231,-0.671760442058231,0.6,1.4669198191234045,false,true,2],
[-3.0,-0.7433994717703304,-0.7433994717703304,0.6,1.4327805942419871,false,true,2],
[-0.66,-0.7133994717703304,-0.7133994717703304,0.6,0.6,false,true,2],
[-0.66,-0.6833994717703303,-0.6833994717703303,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.66,-0.66,-0.66,0.6,0.6,false,true,2],
[-0.15,-0.639,-0.639,0.42,0.6,false,true,2],
[-0.15,-0.618,-0.618,0.42,0.6,false,true,2],
[-0.15,-0.597,-0.597,0.42,0.6,false,true,2],
[-0.15,-0.576,-0.576,0.42,0.6,false,true,2],
[-0.15,-0.5549999999999999,-0.5549999999999999,0.42,0.6,false,true,2],
[-0.15,-0.5339999999999999,-0.5339999999999999,0.42,0.6,false,true,2],
[-0.15,-0.5129999999999999,-0.5129999999999999,0.42,0.6,false,true,2],
[-0.15,-0.4919999999999999,-0.4919999999999999,0.42,0.6,false,true,2],
[0.5,-0.46199999999999986,-0.46199999999999986,0.6,0.6,false,false,3],
[0.5,-0.43199999999999983,-0.43199999999999983,0.6,0.6,false,false,3],
[0.5,-0.4019999999999998,-0.4019999999999998,0.6,0.6,false,false,3],
[0.5,-0.3719999999999998,-0.3719999999999998,0.6,0.6,false,false,3],
[0.5,-0.34199999999999975,-0.34199999999999975,0.6,0.6,false,false,3],
[0.5,-0.3119999999999997,-0.3119999999999997,0.6,0.6,false,false,3],
[0.5,-0.2819999999999997,-0.2819999999999997,0.6,0.6,false,false,3],
[0.5,-0.25199999999999967,-0.25199999999999967,0.6,0.6,false,false,3],
[1.0,-0.04079999999999967,-0.04079999999999967,4.224,1.0,true,false,1],
[1.0,0.17040000000000033,0.17040000000000033,4.224,1.0,true,false,1],
[1.0,0.38160000000000033,0.38160000000000033,4.224,1.0,true,false,1],
[1.0,0.5928000000000003,0.5928000000000003,4.224,1.0,true,false,1],
[1.0,0.8040000000000003,0.8040000000000003,4.224,1.3298036363636367,true,false,1],
[1.0,1.0,1.0,4.224,1.4680436363636367,true,false,1],
[1.0,1.0,1.0,4.224,1.5592727272727274,true,false,1],
[1.0,1.0,1.0,4.224,1.5592727272727274,true,false,1],
[0.0,0.9150545454545455,0.9150545454545455,0.6,1.6989090909090911,false,false,1],
[0.0,0.8320860033057851,0.8320860033057851,0.6,1.6593708429752068,false,false,1],
[0.0,0.7510483654106687,0.7510483654106687,0.6,1.620752757902329,false,false,1],
[0.0,0.6718966943611113,0.6718966943611113,0.6,1.5830334209911476,false,false,1],
[0.0,0.5945870985650709,0.5945870985650709,0.6,1.5461919159208084,false,false,1],
[0.0,0.5190767079075564,0.5190767079075564,0.6,1.5102078131502876,false,false,1],
[0.0,0.4453236499780715,0.4453236499780715,0.6,1.475061158589699,false,false,1],
[0.0,0.3732870268513091,0.3732870268513091,0.6,1.440732462535248,false,false,1]
]}

Some files were not shown because too many files have changed in this diff Show More