Compare commits

..

7 Commits

Author SHA1 Message Date
firestar5683 a9a80d34e7 Build matching Polestar status relay firmware 2026-11-07 18:02:56 -06:00
firestar5683 3093e5b799 Preserve Polestar stock status relay 2026-11-07 17:58:51 -06:00
firestar5683 f633c05b32 Gate Ioniq 6 blind-spot warnings on queued lane changes 2026-11-07 17:33:23 -06:00
firestar5683 1d30dc3434 Align HKG default-controller regression expectations 2026-11-06 22:18:55 -06:00
firestar5683 e1e3be94fe Enable driving in learning regression fixtures 2026-11-06 21:46:34 -06:00
firestar5683 31695937b3 Fix inherited static-analysis failures 2026-11-06 21:35:36 -06:00
firestar5683 7056dcb1fa Preserve HKG angle arming through temporary refusals 2026-11-06 21:23:09 -06:00
46 changed files with 402 additions and 111 deletions
@@ -1,59 +0,0 @@
name: HKG angle recovery qualification
on:
push:
branches: [codex/hkg-angle-retention-20261106]
permissions:
contents: read
jobs:
recovery:
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
PYTHONPATH: ${{ github.workspace }}
SCALE: '1'
RAYLIB_BACKEND: headless
steps:
- uses: actions/checkout@v7
- name: Fetch exact baseline source
run: git -c gc.auto=0 -c maintenance.auto=false fetch --no-tags --depth=1 --filter=blob:none origin 68434cf39c501604fe962e9488a03bde0fed0d73
- uses: actions/setup-go@v7
with:
go-version: '1.25.1'
cache-dependency-path: mapd_repo/go.sum
- name: Fetch pinned map provider dependencies
working-directory: mapd_repo
env:
GOTOOLCHAIN: local
run: go mod download
- run: ./tools/op.sh setup
- name: Build matching Linux dependencies
run: >-
.venv/bin/scons -j2
openpilot/starpilot/ui/native/_raylib_extra.so
openpilot/common/libparams_c.so
msgq_repo/msgq/_ipc.so
msgq_repo/msgq/visionipc/_visionipc.so
opendbc_repo/opendbc/can/_raw_value.so
opendbc_repo/opendbc/dbc
openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so
openpilot/selfdrive/locationd/models/generated/libcar.so
- name: Run steering retention and independent safety veto regressions
run: |
.venv/bin/python -m pytest -q --junitxml=qualification.xml openpilot/starpilot/aol/tests/test_hkg_angle_eps_retention.py openpilot/starpilot/aol/tests/test_pe_combined_profile_contract.py openpilot/starpilot/aol/tests/test_stock_eps_recovery.py openpilot/starpilot/aol/tests/test_runtime.py openpilot/starpilot/aol/tests/test_source_pause_driver_monitoring.py openpilot/system/hardware/tests openpilot/starpilot/drive_state/tests/test_hardware_lifecycle.py openpilot/starpilot/ui/tests/test_weather.py openpilot/starpilot/flm/tests/test_pid_global_capture.py openpilot/starpilot/flm/tests/test_registered_controls_global.py::test_actual_controls_source_freshness_calibration_and_override_rejection openpilot/starpilot/lateral/tests/test_ev6_default.py openpilot/starpilot/lateral/tests/test_ioniq6_policy.py tools/ci/tests/test_replay_references.py
- name: Check owned Python files
run: |
.venv/bin/ruff check openpilot/starpilot/aol/temporary_steering.py openpilot/starpilot/aol/tests/test_hkg_angle_eps_retention.py openpilot/starpilot/aol/transport_pause.py openpilot/system/hardware/hardwared.py openpilot/system/hardware/hardware_timing.py openpilot/system/hardware/tests/test_hardware_timing.py openpilot/starpilot/ui/tests/test_weather.py openpilot/starpilot/lateral/tests/test_ev6_default.py openpilot/starpilot/lateral/tests/test_ioniq6_policy.py
.venv/bin/ty check openpilot/starpilot/aol/temporary_steering.py openpilot/starpilot/aol/tests/test_hkg_angle_eps_retention.py openpilot/starpilot/aol/transport_pause.py openpilot/system/hardware/hardwared.py openpilot/system/hardware/hardware_timing.py openpilot/system/hardware/tests/test_hardware_timing.py openpilot/starpilot/ui/tests/test_weather.py openpilot/starpilot/lateral/tests/test_ev6_default.py openpilot/starpilot/lateral/tests/test_ioniq6_policy.py
- name: Compare both reviewed default-controller recordings
run: .venv/bin/python openpilot/selfdrive/test/process_replay/test_processes.py --whitelist-cars HYUNDAI HYUNDAI2 --whitelist-procs controlsd --output reviewed-defaults -j2
- name: Save exact results
if: always()
uses: actions/upload-artifact@v4
with:
name: hkg-angle-recovery-results
path: |
baseline.xml
baseline-result.json
qualification.xml
reviewed-defaults/coverage.json
reviewed-defaults/diff.txt
@@ -597,8 +597,10 @@ class CarController(CarControllerBase):
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:
# Neutralize only the injected dashboard indication below 5 mph.
if CS.out.vEgoRaw < 5 * CV.MPH_TO_MS:
left_queued = CC.leftBlinker and not CC.rightBlinker
right_queued = CC.rightBlinker and not CC.leftBlinker
if (CS.out.vEgoRaw < 5 * CV.MPH_TO_MS or
status.left == 2 and not left_queued or status.right == 2 and not right_queued):
status = BlindspotStatus(0, 0)
can_sends.extend(hyundaicanfd.create_ioniq6_blindspot_status(self.ioniq6_bsm_counter, status))
self.ioniq6_bsm_counter = (self.ioniq6_bsm_counter + 1) & 0xff
@@ -168,10 +168,12 @@ class TestIoniq6BlindspotStatus(unittest.TestCase):
lamp_source_ns=t, left_lamp=left_lamp, right_lamp=right_lamp)
ci.CS.ioniq6_bsm_sources = sources
source_status = sources.status(t)
assert source_status is not None
counter = controller.ioniq6_bsm_counter
_, frames = controller.update(control.as_reader(), ci.CS, t)
status_frames = [(addr, payload, bus) for addr, payload, bus in frames if addr in (0x1BA, 0x1E5)]
expected = BlindspotStatus(0, 0) if speed < threshold else source_status
expected = (BlindspotStatus(0, 0) if speed < threshold or 2 in (source_status.left, source_status.right)
else source_status)
self.assertEqual(status_frames, create_ioniq6_blindspot_status(counter, expected))
self.assertEqual(sources.status(t), source_status)
for addr, payload, _ in status_frames:
@@ -182,3 +184,89 @@ class TestIoniq6BlindspotStatus(unittest.TestCase):
_, skipped = controller.update(control.as_reader(), ci.CS, t + i * 10_000_000)
self.assertFalse(any(addr in (0x1BA, 0x1E5) for addr, _, _ in skipped))
ci.CS.out = out
def test_controller_requires_a_matching_queued_lane_change_for_escalation(self):
cp = long_params()
ci = CarInterface(cp)
controller = CarController(DBC[cp.carFingerprint], cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
control = structs.CarControl()
controller.ioniq6_bsm_counter = 250
for physical_left in (True, False):
for request in ('matching', 'none', 'opposite', 'hazards', 'matching', 'none'):
with self.subTest(left=physical_left, request=request):
t = 10_000_000_000 + controller.frame * 10_000_000
ci.update([(t, [packer.make_can_msg('BLINDSPOTS_FRONT_CORNER_2', 1,
{'SIDE_DETECT_STATE': 0x10 if physical_left else 0x08}),
packer.make_can_msg('BLINKERS', 1, {})])])
ci.CS.out.vEgoRaw = 10.0
sources = Ioniq6BlindspotSources()
sources.observe(corner_source_ns=t, corner_state=0x10 if physical_left else 0x08,
lamp_source_ns=t, left_lamp=physical_left, right_lamp=not physical_left)
ci.CS.ioniq6_bsm_sources = sources
control.leftBlinker = request == 'hazards' or request == ('matching' if physical_left else 'opposite')
control.rightBlinker = request == 'hazards' or request == ('opposite' if physical_left else 'matching')
counter = controller.ioniq6_bsm_counter
_, frames = controller.update(control.as_reader(), ci.CS, t)
actual = [(addr, payload, bus) for addr, payload, bus in frames if addr in (0x1BA, 0x1E5)]
source_status = sources.status(t)
assert source_status is not None
expected = source_status if request == 'matching' else BlindspotStatus(0, 0)
self.assertEqual(actual, create_ioniq6_blindspot_status(counter, expected), 'UNQUEUED_BLINDSPOT_WARNING')
self.assertEqual(sources.status(t), BlindspotStatus(2, 0) if physical_left else BlindspotStatus(0, 2))
for offset in range(1, 5):
_, skipped = controller.update(control.as_reader(), ci.CS, t + offset * 10_000_000)
self.assertFalse(any(addr in (0x1BA, 0x1E5) for addr, _, _ in skipped))
def test_actual_lane_change_request_completion_and_turn_with_held_stalk(self):
from openpilot.cereal import log
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.starpilot.lateral.lane_change_preferences import LaneChangePolicy
for left in (True, False):
cp = long_params()
ci = CarInterface(cp)
controller = CarController(DBC[cp.carFingerprint], cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
state = structs.CarState(vEgo=20.0, leftBlinker=left, rightBlinker=not left)
helper = DesireHelper(LaneChangePolicy(auto_lane_change=True))
def check(expected, helper, *, controller=controller, ci=ci, packer=packer, left=left):
control = structs.CarControl()
t = 10_000_000_000 + controller.frame * 10_000_000
ci.update([(t, [packer.make_can_msg('BLINDSPOTS_FRONT_CORNER_2', 1,
{'SIDE_DETECT_STATE': 0x10 if left else 0x08}),
packer.make_can_msg('BLINKERS', 1, {'LEFT_LAMP': int(left), 'RIGHT_LAMP': int(not left)})])])
ci.CS.out.vEgoRaw = 20.0
queued = helper.lane_change_state != log.LaneChangeState.off
control.leftBlinker = queued and helper.lane_change_direction == log.LaneChangeDirection.left
control.rightBlinker = queued and helper.lane_change_direction == log.LaneChangeDirection.right
counter = controller.ioniq6_bsm_counter
_, frames = controller.update(control.as_reader(), ci.CS, t)
self.assertEqual([frame for frame in frames if frame[0] in (0x1BA, 0x1E5)],
create_ioniq6_blindspot_status(counter, expected))
for offset in range(1, 5):
controller.update(control.as_reader(), ci.CS, t + offset * 10_000_000)
# Missing automatic-lane evidence does not cancel a manual pending request.
helper.update(state, True, 1.0, auto_evidence=False, engaged=True)
helper.update(state, True, 1.0, auto_evidence=False, engaged=True)
self.assertEqual(helper.auto_status, 'laneUnavailable')
self.assertEqual(helper.lane_change_state, log.LaneChangeState.preLaneChange)
check(BlindspotStatus(2, 0) if left else BlindspotStatus(0, 2), helper)
state.steeringPressed = True
state.steeringTorque = 1.0 if left else -1.0
helper.update(state, True, 1.0, engaged=True)
self.assertEqual(helper.lane_change_state, log.LaneChangeState.laneChangeStarting)
check(BlindspotStatus(2, 0) if left else BlindspotStatus(0, 2), helper)
state.steeringPressed = False
for _ in range(12):
helper.update(state, True, 0.0, engaged=True)
self.assertEqual(helper.lane_change_state, log.LaneChangeState.off)
check(BlindspotStatus(0, 0), helper)
helper.update(state, True, 0.0, engaged=True)
check(BlindspotStatus(0, 0), helper)
helper = DesireHelper()
helper.update(state, True, 1.0, navigation_turn=True)
self.assertEqual(helper.lane_change_state, log.LaneChangeState.off)
check(BlindspotStatus(0, 0), helper)
@@ -225,7 +225,7 @@ static bool hyundai_canfd_gv60_heartbeat_seen = false;
static uint8_t hyundai_canfd_gv60_heartbeat_counter = 0U;
static bool hyundai_canfd_ioniq6_heartbeat_seen = false;
static uint8_t hyundai_canfd_ioniq6_heartbeat_counter = 0U;
static bool hyundai_canfd_ioniq6_bsm_low_speed = false;
static bool hyundai_canfd_ioniq6_bsm_wheel_valid = false;
static bool hyundai_canfd_ioniq6_bsm_wheel_seen = false;
static uint8_t hyundai_canfd_ioniq6_bsm_wheel_counter = 0U;
static uint32_t hyundai_canfd_ioniq6_bsm_wheel_ts = 0U;
@@ -763,11 +763,10 @@ static void hyundai_canfd_rx_hook(const CANPacket_t *msg) {
// average of all 4 wheel speeds. Conversion: raw * 0.03125 / 3.6 = m/s
UPDATE_VEHICLE_SPEED((fr + rr + rl + fl) / 4.0 * 0.03125 * KPH_TO_MS);
if (hyundai_canfd_ioniq6_long) {
// Mean wheel speed < 5 mph: raw sum < 2.2352 * 4 * 3.6 / 0.03125.
// This hook runs only for valid required RX; TX also checks its freshness.
const bool fresh_counter = !hyundai_canfd_ioniq6_bsm_wheel_seen ||
(msg->data[2] == (uint8_t)(hyundai_canfd_ioniq6_bsm_wheel_counter + 1U));
hyundai_canfd_ioniq6_bsm_low_speed = fresh_counter && ((fr + rr + rl + fl) < 1030U);
hyundai_canfd_ioniq6_bsm_wheel_valid = fresh_counter;
if (fresh_counter) {
hyundai_canfd_ioniq6_bsm_wheel_ts = microsecond_timer_get();
}
@@ -1037,7 +1036,7 @@ static bool hyundai_canfd_tx_hook_valid(const CANPacket_t *msg) {
body[7] = 0x80U | (SAFETY_MAX(left, right) << 3U);
body[16] = left | (right << 2U);
bool physical_body = true;
bool neutral_body = hyundai_canfd_ioniq6_bsm_low_speed &&
bool neutral_body = hyundai_canfd_ioniq6_bsm_wheel_valid &&
(safety_get_ts_elapsed(now, hyundai_canfd_ioniq6_bsm_wheel_ts) <= 100000U);
for (int i = 3; i < 24; i++) {
physical_body &= msg->data[i] == body[i];
@@ -1314,7 +1313,7 @@ static void hyundai_canfd_reset_ioniq6_state(void) {
hyundai_canfd_gv60_heartbeat_counter = 0U;
hyundai_canfd_ioniq6_heartbeat_seen = false;
hyundai_canfd_ioniq6_heartbeat_counter = 0U;
hyundai_canfd_ioniq6_bsm_low_speed = false;
hyundai_canfd_ioniq6_bsm_wheel_valid = false;
hyundai_canfd_ioniq6_bsm_wheel_seen = false;
hyundai_canfd_ioniq6_bsm_wheel_counter = 0U;
hyundai_canfd_ioniq6_bsm_wheel_ts = 0U;
+66 -2
View File
@@ -280,6 +280,65 @@ static bool volvo_cma_receiver_quality(const CANPacket_t *msg) {
return volvo_cma_quality(msg) && volvo_cma_lca2_quality(msg);
}
// Unchanged native status is transport evidence, never valid control evidence.
static uint8_t volvo_cma_pscm_status_data[4][8];
static uint32_t volvo_cma_pscm_status_ts[4];
static bool volvo_cma_pscm_status_valid[4];
static uint8_t volvo_cma_pscm_status_index;
static uint8_t volvo_cma_pscm_status_nibble;
static bool volvo_cma_pscm_status_checksum_valid;
static bool volvo_cma_pscm_status_quality_valid;
static void volvo_cma_clear_pscm_status(void) {
for (uint8_t i = 0U; i < 4U; i++) {
volvo_cma_pscm_status_valid[i] = false;
}
volvo_cma_pscm_status_index = 0U;
}
static bool volvo_cma_receiver_quality_rx(const CANPacket_t *msg) {
const bool quality_valid = volvo_cma_receiver_quality(msg);
if (volvo_cma_lca2_mod15 && msg_matches(msg, 0x17U, 2U, 8U) &&
!msg->returned && !msg->rejected && !msg->extended && !msg->fd) {
const uint8_t nibble = msg->data[2] >> 4U;
const bool checksum_valid = volvo_cma_get_checksum(msg) == volvo_cma_compute_checksum(msg);
// Retain the existing four-frame transport tolerance, but never replay an
// earlier status/validation class after a newly observed native transition.
if ((nibble != volvo_cma_pscm_status_nibble) ||
(checksum_valid != volvo_cma_pscm_status_checksum_valid) ||
(quality_valid != volvo_cma_pscm_status_quality_valid)) {
volvo_cma_clear_pscm_status();
}
volvo_cma_pscm_status_nibble = nibble;
volvo_cma_pscm_status_checksum_valid = checksum_valid;
volvo_cma_pscm_status_quality_valid = quality_valid;
const uint8_t slot = volvo_cma_pscm_status_index;
for (uint8_t i = 0U; i < 8U; i++) {
volvo_cma_pscm_status_data[slot][i] = msg->data[i];
}
volvo_cma_pscm_status_ts[slot] = microsecond_timer_get();
volvo_cma_pscm_status_valid[slot] = true;
volvo_cma_pscm_status_index = (slot + 1U) % 4U;
}
return quality_valid;
}
static bool volvo_cma_exact_pscm_status(const CANPacket_t *msg) {
bool matches = false;
if (msg_matches(msg, 0x17U, 0U, 8U) &&
!msg->returned && !msg->rejected && !msg->extended && !msg->fd) {
for (uint8_t slot = 0U; slot < 4U; slot++) {
bool same = volvo_cma_pscm_status_valid[slot] &&
(safety_get_ts_elapsed(microsecond_timer_get(), volvo_cma_pscm_status_ts[slot]) <= 100000U);
for (uint8_t i = 0U; i < 8U; i++) {
same &= msg->data[i] == volvo_cma_pscm_status_data[slot][i];
}
matches |= same;
}
}
return matches;
}
// The Polestar source clocks advance nominally2/4 per50Hz frame, with
// observed sampling jitter of1..3/3..5. Frozen/jumped counters retain the shared tolerance.
static uint8_t volvo_cma_lca2_counter(const CANPacket_t *msg) {
@@ -383,7 +442,8 @@ static bool volvo_cma_tx_hook(const CANPacket_t *msg) {
// policy or intent and retains the existing health deadlines/tolerances.
bool tx;
if (!controls_allowed || !volvo_cma_drive_current() || !aol_rx_healthy()) {
tx = volvo_cma_exact_stock(msg);
tx = (volvo_cma_lca2_mod15 && (msg->addr == 0x17U)) ?
volvo_cma_exact_pscm_status(msg) : volvo_cma_exact_stock(msg);
} else {
tx = volvo_cma_receiver_quality(msg) && volvo_cma_source_projection(msg);
if ((msg->addr == 0x69U) || (msg->addr == 0x67U) || (msg->addr == 0x17U)) {
@@ -514,6 +574,10 @@ static RxCheck *volvo_cma_rx_checks_workspace(void) {
static safety_config volvo_cma_init(uint16_t param) {
RxCheck *const rx_workspace = volvo_cma_rx_checks_workspace();
volvo_cma_lca2_mod15 = param == 5U;
volvo_cma_clear_pscm_status();
volvo_cma_pscm_status_nibble = 0U;
volvo_cma_pscm_status_checksum_valid = false;
volvo_cma_pscm_status_quality_valid = false;
volvo_cma_lca2_second_seen = false;
volvo_cma_lca2_second_last = 0U;
volvo_cma_lca2_second_wrong = 0;
@@ -635,6 +699,6 @@ const safety_hooks volvo_cma_hooks = {
.get_counter = volvo_cma_lca2_counter,
.get_checksum = volvo_cma_get_checksum,
.compute_checksum = volvo_cma_compute_checksum,
.get_quality_flag_valid = volvo_cma_receiver_quality,
.get_quality_flag_valid = volvo_cma_receiver_quality_rx,
// No custom fwd hook - stock LCA always blocked by .check_relay = true
};
@@ -29,7 +29,7 @@ static uint32_t volvo_dispatch_compute_checksum(const CANPacket_t *msg) {
return volvo_dispatch_c1 ? 0U : volvo_cma_compute_checksum(msg);
}
static bool volvo_dispatch_quality(const CANPacket_t *msg) {
return volvo_dispatch_c1 || volvo_cma_receiver_quality(msg);
return volvo_dispatch_c1 || volvo_cma_receiver_quality_rx(msg);
}
const safety_hooks volvo_dispatch_hooks = {.init = volvo_dispatch_init, .rx = volvo_dispatch_rx, .tx = volvo_dispatch_tx,
.get_counter = volvo_dispatch_get_counter,
@@ -351,11 +351,11 @@ class TestHyundaiIoniq6Long(unittest.TestCase):
("WHL_SpdFLVal", "WHL_SpdFRVal", "WHL_SpdRLVal", "WHL_SpdRRVal"), raw_speeds, strict=True)}
return packer.make_can_msg("WHEEL_SPEEDS", 1, {**values, "COUNTER": counter})
def test_bsm_neutral_only_below_physical_speed_boundary(self):
def test_bsm_neutral_advisory_requires_fresh_wheels_at_all_speeds(self):
if self.release:
self.skipTest("RELEASE denies the Ioniq 6 LONG profiles")
# 5 mph is between representable sums 1029 and 1030; test unequal wheels too.
samples = ((0, 0, 0, 0), (257, 257, 257, 258), (257, 257, 258, 258), (258, 258, 258, 258))
# Keep the old speed boundary and highway speeds in the advisory matrix.
samples = ((0, 0, 0, 0), (257, 257, 257, 258), (257, 257, 258, 258), (258, 258, 258, 258), (4000, 4000, 4000, 4000))
for raw in (*RAW_LONG.values(), *RAW_AOL.values()):
_, cp = params("lkas_alt" if raw & 0x80 else "lkas")
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
@@ -365,9 +365,8 @@ class TestHyundaiIoniq6Long(unittest.TestCase):
with self.subTest(profile=hex(raw), speeds=speeds):
self.assertTrue(self.safety.safety_rx_hook(self.packet(self.bsm_wheel_frame(packer, speeds, counter))))
rear, front = create_ioniq6_blindspot_status(counter, BlindspotStatus(0, 0))
permitted = sum(speeds) < 1030
self.assertEqual(self.safety.safety_tx_hook(self.packet(rear)), permitted)
self.assertEqual(self.safety.safety_tx_hook(self.packet(front)), permitted)
self.assertTrue(self.safety.safety_tx_hook(self.packet(rear)))
self.assertTrue(self.safety.safety_tx_hook(self.packet(front)))
# Original physical indications remain valid, independently of speed.
for frame in create_ioniq6_blindspot_status(counter + 30, BlindspotStatus(2, 2)):
self.assertTrue(self.safety.safety_tx_hook(self.packet(frame)))
@@ -429,10 +428,10 @@ class TestHyundaiIoniq6Long(unittest.TestCase):
self.assertTrue(self.safety.safety_tx_hook(self.packet(rear)))
self.safety.set_timer(1_200_002)
self.assertFalse(self.safety.safety_tx_hook(self.packet(front)))
# A low-speed packet on the wrong route or with the wrong length cannot
# replace a valid above-threshold physical wheel observation.
# Wrong-route and short packets cannot refresh expired wheel evidence.
other_sources(5, 1_210_000)
self.assertTrue(self.safety.safety_rx_hook(self.packet(self.bsm_wheel_frame(packer, (300, 300, 300, 300), 5))))
other_sources(6, 1_310_001)
low = self.bsm_wheel_frame(packer, (0, 0, 0, 0), 6)
for invalid in ((low[0], low[1], 0), (low[0], low[1][:8], 1)):
self.safety.safety_rx_hook(self.packet(invalid))
@@ -478,36 +477,38 @@ class TestHyundaiIoniq6Long(unittest.TestCase):
for topology in ("lkas", "lkas_alt"):
for aol in (False, True):
for raw_speeds in ((0, 0, 0, 0), (257, 257, 257, 258), (257, 257, 258, 258), (300, 300, 300, 300)):
with self.subTest(topology=topology, aol=aol, speeds=raw_speeds):
_, cp = params(topology)
raw = (RAW_AOL if aol else RAW_LONG)[topology]
cp.safetyConfigs[-1].safetyParam = raw
ci = CarInterface(cp)
controller = CarController(DBC[cp.carFingerprint], 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, "COUNTER": 1}), ("TCS", {"COUNTER": 1}),
("WHEEL_SPEEDS", {"COUNTER": 1}), ("MDPS", {"COUNTER": 1}),
("CRUISE_BUTTONS", {"COUNTER": 1}),
("BLINDSPOTS_FRONT_CORNER_2", {"COUNTER": 1, "SIDE_DETECT_STATE": 0x18}),
("BLINKERS", {"LEFT_LAMP": 1}),
)]
inputs = [frame for frame in inputs if frame[0] != 0xA0]
inputs.append(self.bsm_wheel_frame(packer, raw_speeds, 1))
inputs.append(packer.make_can_msg("CAM_0x362" if topology == "lkas_alt" else "CAM_0x2a4", bus.CAM, {}))
ci.update([(1_000_000_000, inputs)])
self.mode(raw)
for frame in inputs:
if frame[2] == bus.ECAN:
self.assertTrue(self.safety.safety_rx_hook(self.packet(frame)), hex(frame[0]))
control = structs.CarControl()
_, sent = controller.update(control.as_reader(), ci.CS, 1_000_000_000)
pair = [frame for frame in sent if frame[0] in (0x1BA, 0x1E5)]
expected = BlindspotStatus(0, 0) if sum(raw_speeds) < 1030 else BlindspotStatus(2, 1)
self.assertEqual(pair, create_ioniq6_blindspot_status(0, expected))
for frame in pair:
self.assertTrue(self.safety.safety_tx_hook(self.packet(frame)), (topology, aol, hex(frame[0])))
for queued in (False, True):
with self.subTest(topology=topology, aol=aol, speeds=raw_speeds, queued=queued):
_, cp = params(topology)
raw = (RAW_AOL if aol else RAW_LONG)[topology]
cp.safetyConfigs[-1].safetyParam = raw
ci = CarInterface(cp)
controller = CarController(DBC[cp.carFingerprint], 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, "COUNTER": 1}), ("TCS", {"COUNTER": 1}),
("WHEEL_SPEEDS", {"COUNTER": 1}), ("MDPS", {"COUNTER": 1}),
("CRUISE_BUTTONS", {"COUNTER": 1}),
("BLINDSPOTS_FRONT_CORNER_2", {"COUNTER": 1, "SIDE_DETECT_STATE": 0x18}),
("BLINKERS", {"LEFT_LAMP": 1}),
)]
inputs = [frame for frame in inputs if frame[0] != 0xA0]
inputs.append(self.bsm_wheel_frame(packer, raw_speeds, 1))
inputs.append(packer.make_can_msg("CAM_0x362" if topology == "lkas_alt" else "CAM_0x2a4", bus.CAM, {}))
ci.update([(1_000_000_000, inputs)])
self.mode(raw)
for frame in inputs:
if frame[2] == bus.ECAN:
self.assertTrue(self.safety.safety_rx_hook(self.packet(frame)), hex(frame[0]))
control = structs.CarControl()
control.leftBlinker = queued
_, sent = controller.update(control.as_reader(), ci.CS, 1_000_000_000)
pair = [frame for frame in sent if frame[0] in (0x1BA, 0x1E5)]
expected = BlindspotStatus(0, 0) if not queued or sum(raw_speeds) < 1030 else BlindspotStatus(2, 1)
self.assertEqual(pair, create_ioniq6_blindspot_status(0, expected))
for frame in pair:
self.assertTrue(self.safety.safety_tx_hook(self.packet(frame)), (topology, aol, hex(frame[0])))
def test_exhaustive_angle_bit_namespace_and_release_denial(self):
for low in range(0x8000):
@@ -86,3 +86,199 @@ class TestPolestarCounters(unittest.TestCase):
self.assertTrue(self.safety.safety_config_valid())
self.assertTrue(self.safety.get_controls_allowed())
self.assertTrue(self.tx(self.lca_command()))
def unknown_pscm_status(self):
return 0x17, bytes.fromhex('d400a0858000ffff'), 2
def test_unknown_status_relays_without_becoming_control_evidence(self):
for cruise in (False, True):
with self.subTest(cruise=cruise):
self.reset()
incoming = self.feed(cruise=cruise)
status = self.unknown_pscm_status()
self.assertFalse(self.rx(status))
self.assertFalse(self.safety.get_controls_allowed())
self.safety.safety_tick()
self.assertFalse(self.safety.safety_config_valid())
self.assertTrue(self.tx(self.relay(status)))
self.assertFalse(self.tx(self.relay(incoming[0x17])))
self.assertFalse(self.tx(self.lca_command()))
self.safety.set_controls_allowed(True)
self.assertTrue(self.tx(self.relay(status)))
self.assertFalse(self.tx(self.lca_command()))
for address in cma.RELAY_BUSES:
if address != 0x17:
self.assertTrue(self.tx(self.relay(incoming[address])))
self.assertEqual(self.safety.safety_fwd_hook(2, 0x17), -1)
def test_unknown_status_requires_exact_native_bytes(self):
self.feed()
status = self.unknown_pscm_status()
self.assertFalse(self.rx(status))
for bit in range(64):
data = bytearray(status[1])
data[bit // 8] ^= 1 << (bit % 8)
self.assertFalse(self.tx((0x17, data, 0)), bit)
self.assertTrue(self.tx(self.relay(status)))
def test_unknown_status_source_identity_cannot_be_forged(self):
status = self.unknown_pscm_status()
for field in ('returned', 'rejected', 'extended', 'fd'):
with self.subTest(field=field):
self.reset()
self.feed(cruise=False)
packet = cma.libsafety_py.make_CANPacket(status[0], 2, status[1])
setattr(packet[0], field, 1)
self.safety.safety_rx_hook(packet)
self.assertFalse(self.tx(self.relay(status)))
for bus in (0, 1, 3, 4, 5, 6, 7):
self.reset()
self.feed(cruise=False)
self.rx((0x17, status[1], bus))
self.assertFalse(self.tx(self.relay(status)))
for length in (*range(8), 12):
self.reset()
self.feed(cruise=False)
self.rx((0x17, (status[1] + bytes(4))[:length], 2))
self.assertFalse(self.tx(self.relay(status)))
def test_unknown_status_tx_cannot_seed_or_refresh_source(self):
self.feed(cruise=False)
status = self.unknown_pscm_status()
self.assertFalse(self.tx(self.relay(status)))
self.assertFalse(self.rx(status))
self.safety.set_timer(110000)
self.assertTrue(self.tx(self.relay(status)))
self.safety.set_timer(110001)
self.assertFalse(self.tx(self.relay(status)))
self.assertFalse(self.tx(self.relay(status)))
def test_unknown_status_original_age_survives_timer_wrap(self):
self.feed(cruise=False)
start = 0xFFFFFF00
self.safety.set_timer(start)
status = self.unknown_pscm_status()
self.assertFalse(self.rx(status))
for age, accepted in ((90000, True), (100000, True), (100001, False)):
self.safety.set_timer((start + age) & 0xFFFFFFFF)
self.assertEqual(bool(self.tx(self.relay(status))), accepted)
def test_status_preserves_four_frame_transport_tolerance(self):
for unknown in (False, True):
with self.subTest(unknown=unknown):
self.reset()
incoming = self.feed(cruise=False)
source = self.unknown_pscm_status() if unknown else incoming[0x17]
frames = [(0x17, source[1][:-1] + bytes([i]), 2) for i in range(5)]
for i, frame in enumerate(frames):
self.safety.set_timer(20000 + i * 10000)
self.assertEqual(bool(self.rx(frame)), not unknown)
for retained in frames[max(0, i - 3):i + 1]:
self.assertTrue(self.tx(self.relay(retained)))
self.assertFalse(self.tx(self.relay(frames[0])))
# A new native arrival never refreshes another slot's original age.
self.safety.set_timer(130000)
self.assertTrue(self.tx(self.relay(frames[1])))
self.safety.set_timer(130001)
self.assertFalse(self.tx(self.relay(frames[1])))
self.assertTrue(self.tx(self.relay(frames[2])))
def test_native_status_transition_flushes_old_transport_evidence(self):
incoming = self.feed(cruise=False)
healthy = incoming[0x17]
first = self.unknown_pscm_status()
second = first[0], first[1][:-1] + b'\xfe', first[2]
restored = healthy[0], healthy[1][:-1] + b'\x01', healthy[2]
self.assertFalse(self.rx(first))
self.assertTrue(self.tx(self.relay(first)))
self.assertFalse(self.tx(self.relay(healthy)))
self.assertFalse(self.rx(second))
self.assertTrue(self.tx(self.relay(first)))
self.assertTrue(self.tx(self.relay(second)))
self.assertTrue(self.rx(restored))
for stale in (healthy, first, second):
self.assertFalse(self.tx(self.relay(stale)))
self.assertTrue(self.tx(self.relay(restored)))
self.assertFalse(self.safety.get_controls_allowed())
def test_each_native_validation_class_change_flushes_transport_evidence(self):
healthy = self.feed(cruise=False)[0x17]
# Keep the high nibble at 8 and change one validation boolean at a time:
# (checksum, quality): TT -> FT -> FF -> TF -> TT.
bad_checksum = bytearray(healthy[1])
bad_checksum[0] ^= 1
bad_both = bytearray(bad_checksum)
bad_both[1] |= 8 # This quality bit is unconstrained by the checksum.
bad_quality = bytearray(healthy[1])
bad_quality[1] |= 8
frames = [healthy, (0x17, bad_checksum, 2), (0x17, bad_both, 2),
(0x17, bad_quality, 2), (0x17, healthy[1][:-1] + b'\x01', 2)]
for i, frame in enumerate(frames):
self.assertEqual(bool(self.rx(frame)), i in (0, 4))
self.assertTrue(self.tx(self.relay(frame)))
for stale in frames[:i]:
self.assertFalse(self.tx(self.relay(stale)))
self.assertFalse(self.safety.get_controls_allowed())
def test_unknown_status_cache_does_not_cross_mode_reset(self):
self.feed(cruise=False)
status = self.unknown_pscm_status()
self.assertFalse(self.rx(status))
self.assertTrue(self.tx(self.relay(status)))
self.reset()
self.assertFalse(self.tx(self.relay(status)))
self.feed(cruise=False)
self.assertFalse(self.tx(self.relay(status)))
def test_unknown_status_does_not_expand_other_profiles(self):
status = self.unknown_pscm_status()
for profile in (0, 1):
self.assertEqual(self.safety.set_safety_hooks(36, profile), 0)
self.safety.init_tests()
self.safety.set_timer(10000)
self.assertFalse(self.rx(status))
self.assertFalse(self.tx(self.relay(status)))
self.assertNotEqual(self.safety.set_safety_hooks(36, 4), 0)
def test_unknown_status_remains_blocked_on_relay_malfunction(self):
self.feed(cruise=False)
status = self.unknown_pscm_status()
self.assertFalse(self.rx(status))
self.assertTrue(self.tx(self.relay(status)))
self.rx((0x58, bytes(8), 2))
self.assertTrue(self.safety.get_relay_malfunction())
self.assertFalse(self.tx(self.relay(status)))
def test_unknown_status_tx_identity_matches_native_framing(self):
self.feed(cruise=False)
status = self.unknown_pscm_status()
self.assertFalse(self.rx(status))
for field in ('returned', 'rejected', 'extended', 'fd'):
packet = cma.libsafety_py.make_CANPacket(0x17, 0, status[1])
setattr(packet[0], field, 1)
self.assertFalse(self.safety.safety_tx_hook(packet), field)
self.assertTrue(self.tx(self.relay(status)))
for bus in range(1, 8):
self.assertFalse(self.tx((0x17, status[1], bus)))
for length in (*range(8), 12):
self.assertFalse(self.tx((0x17, (status[1] + bytes(4))[:length], 0)))
self.assertFalse(self.tx((0x16, status[1], 0)))
self.assertTrue(self.tx(self.relay(status)))
def test_modified_status_is_rejected_until_controller_returns_to_stock(self):
self.feed()
status = self.unknown_pscm_status()
stock = {
'CHECKSUM_1': 0xD4, 'LCA_ENABLED_ECHO': 0, 'SIG1_BYTE_1_HI_NIBBLE': 0,
'SIG1_REPLICA_BYTE_2_LO_NIBLE': 0, 'NEW_SIGNAL_2': 10, 'CHECKSUM_2': 0x85,
'BYTE_4_5': 0x8000, 'BYTE_6': 0xFF, 'BYTE_7': 0xFF,
}
self.assertFalse(self.rx(status))
self.assertFalse(self.safety.get_controls_allowed())
modified = cma.cma_can.create_pscm_related_message(self.mid, True, False, stock, 1)
self.assertNotEqual(modified[1], status[1])
self.assertFalse(self.tx(modified))
unchanged = cma.cma_can.create_pscm_related_message(self.mid, False, False, stock, 1)
self.assertEqual(unchanged[1], status[1])
self.assertTrue(self.tx(unchanged))
self.assertFalse(self.safety.get_controls_allowed())
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
#pragma once
extern const uint8_t gitversion[29];
const uint8_t gitversion[29] = "DEV-5d6e6afc-Pf38f504f-DEBUG";
const uint8_t gitversion[29] = "DEV-3093e5b7-Pf38f504f-DEBUG";
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
DEV-5d6e6afc-Pf38f504f-DEBUG
DEV-3093e5b7-Pf38f504f-DEBUG
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "b1b32d57b53f664878ae381bd92018b4d4b6b57d6d7f92e465c5ed15aaa09728", "schema_version": 1}
{"protected_inventory_sha256": "f1741daeb7b18776c77b38c839c9a6e97bfb97229851365246afdf6e28936f60", "schema_version": 1}