Native Safety

Extend native safety and health contracts with explicit AOL state and bounded message handling. Keep vehicle-specific admissions outside the generic layer.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
This commit is contained in:
firestar5683
2026-09-25 14:53:18 -05:00
parent 5a01c2bacb
commit d874c9258c
21 changed files with 1081 additions and 98 deletions
+2
View File
@@ -8,3 +8,5 @@ class ALTERNATIVE_EXPERIENCE:
DISABLE_STOCK_AEB = 2
RAISE_LONGITUDINAL_LIMITS_TO_ISO_MAX = 8
ALLOW_AEB = 16
TOYOTA_AUTO_HOLD = 128
TOYOTA_AEB_HOLD = 256
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "opendbc/safety/can.h"
void can_set_checksum(CANPacket_t *packet);
void can_send(CANPacket_t *to_push, uint8_t bus_number, bool skip_tx_hook);
+38 -9
View File
@@ -35,7 +35,9 @@
#define SAFETY_RIVIAN 33U
#define SAFETY_VOLKSWAGEN_MEB 34U
#define SAFETY_BYD 35U
#define SAFETY_VOLVO 36U
#define SAFETY_MG 38U
#define SAFETY_TESLA_PREAP 39U
#define GET_BIT(msg, b) ((bool)!!(((msg)->data[((b) / 8U)] >> ((b) % 8U)) & 0x1U))
#define GET_FLAG(value, mask) (((value) & (mask)) == (mask))
@@ -92,6 +94,7 @@ typedef struct {
int len;
bool check_relay; // if true, trigger relay malfunction if existence on destination bus and block forwarding to destination bus
bool disable_static_blocking; // if true, static blocking is disabled so safety mode can dynamically handle it (e.g. selective AEB pass-through)
bool check_relay_immediately; // if true, skip the usual transition grace for this exact TX address and bus
} CanMsg;
typedef enum {
@@ -175,14 +178,14 @@ typedef struct {
} LongitudinalLimits;
typedef struct {
const int addr;
const unsigned int bus;
const int len;
const uint32_t frequency; // expected frequency of the message [Hz]
const bool ignore_checksum; // checksum check is not performed when set to true
const bool ignore_counter; // counter check is not performed when set to true
const uint8_t max_counter; // maximum value of the counter. 0 means that the counter check is skipped
const bool ignore_quality_flag; // true if quality flag check is skipped
int addr;
unsigned int bus;
int len;
uint32_t frequency; // expected frequency of the message [Hz]
bool ignore_checksum; // checksum check is not performed when set to true
bool ignore_counter; // counter check is not performed when set to true
uint8_t max_counter; // maximum value of the counter. 0 means that the counter check is skipped
bool ignore_quality_flag; // true if quality flag check is skipped
} CanMsgCheck;
typedef struct {
@@ -199,7 +202,7 @@ typedef struct {
// params and flags about checksum, counter and frequency checks for each monitored address
typedef struct {
const CanMsgCheck msg[MAX_ADDR_CHECK_MSGS]; // check either messages (e.g. honda steer)
CanMsgCheck msg[MAX_ADDR_CHECK_MSGS]; // check either messages (e.g. honda steer)
RxStatus status;
} RxCheck;
@@ -224,6 +227,7 @@ typedef bool (*fwd_hook)(int bus_num, int addr); // returns true if the mes
typedef struct {
safety_hook_init init;
rx_hook rx;
rx_hook optional_rx;
tx_hook tx;
fwd_hook fwd;
get_checksum_t get_checksum;
@@ -256,6 +260,25 @@ void safety_tick(void);
// This can be set by the safety hooks
extern bool controls_allowed;
// Vehicle modes register a policy only for their qualified AOL safety profile.
typedef struct {
void (*reset)(void);
void (*host_request)(uint8_t axis_mask);
uint8_t (*request_mask)(void);
uint8_t (*permission_mask)(void);
void (*rx_invalid)(void);
} AolSafetyPolicy;
extern const AolSafetyPolicy *aol_policy;
extern uint8_t aol_host_axis_mask;
extern uint32_t aol_host_request_ts;
#define AOL_HOST_REQUEST_TIMEOUT_US 300000U
bool aol_rx_healthy(void);
bool lateral_controls_allowed(void);
bool longitudinal_controls_allowed(void);
void aol_set_host_request(uint8_t axis_mask);
uint8_t aol_get_permission_mask(void);
extern bool aol_optional_release_capable;
uint8_t aol_get_request_mask(void);
extern bool relay_malfunction;
extern bool gas_pressed;
extern bool gas_pressed_prev;
@@ -321,6 +344,8 @@ extern CurvatureSteeringState curvature_state;
// This flag allows AEB to be commanded from openpilot.
#define ALT_EXP_ALLOW_AEB 16
#define ALT_EXP_TOYOTA_AUTO_HOLD 128
#define ALT_EXP_TOYOTA_AEB_HOLD 256
extern int alternative_experience;
@@ -357,6 +382,8 @@ extern const safety_hooks nissan_hooks;
extern const safety_hooks subaru_hooks;
extern const safety_hooks subaru_preglobal_hooks;
extern const safety_hooks tesla_hooks;
extern const safety_hooks tesla_hw1_hooks;
extern const safety_hooks tesla_preap_hooks;
extern const safety_hooks toyota_hooks;
extern const safety_hooks volkswagen_mlb_hooks;
extern const safety_hooks volkswagen_mqb_hooks;
@@ -366,3 +393,5 @@ extern const safety_hooks rivian_hooks;
extern const safety_hooks psa_hooks;
extern const safety_hooks byd_hooks;
extern const safety_hooks mg_hooks;
extern const safety_hooks volvo_c1_hooks;
+12 -12
View File
@@ -61,7 +61,7 @@ bool steer_torque_cmd_checks(int desired_torque, int steer_req, const TorqueStee
bool violation = false;
uint32_t ts = microsecond_timer_get();
if (controls_allowed) {
if (lateral_controls_allowed()) {
// Some safety models support variable torque limit based on vehicle speed
int max_torque = limits.max_torque;
if (limits.dynamic_max_torque) {
@@ -96,7 +96,7 @@ bool steer_torque_cmd_checks(int desired_torque, int steer_req, const TorqueStee
}
bool torque_requested = desired_torque != 0;
if (!controls_allowed && torque_requested) {
if (!lateral_controls_allowed() && torque_requested) {
violation = true;
}
@@ -138,7 +138,7 @@ bool steer_torque_cmd_checks(int desired_torque, int steer_req, const TorqueStee
}
// reset to 0 if either controls is not allowed or there's a violation
if (violation || !controls_allowed) {
if (violation || !lateral_controls_allowed()) {
valid_steer_req_count = 0;
invalid_steer_req_count = 0;
desired_torque_last = 0;
@@ -204,7 +204,7 @@ static bool steer_angle_cmd_inactive_check(int desired_angle, int max_angle) {
bool steer_angle_cmd_checks(int desired_angle, bool steer_control_enabled, const AngleSteeringLimits limits) {
bool violation = false;
if (controls_allowed && steer_control_enabled) {
if (lateral_controls_allowed() && steer_control_enabled) {
// convert floating point angle rate limits to integers in the scale of the desired angle on CAN,
// add 1 to not false trigger the violation. also fudge the speed by 1 m/s so rate limits are
// always slightly above openpilot's in case we read an updated speed in between angle commands
@@ -229,12 +229,12 @@ bool steer_angle_cmd_checks(int desired_angle, bool steer_control_enabled, const
}
// No angle control allowed when controls are not allowed
if (!controls_allowed) {
if (!lateral_controls_allowed()) {
violation |= steer_control_enabled;
}
// reset to current angle if either controls is not allowed or there's a violation
if (violation || !controls_allowed) {
if (violation || !lateral_controls_allowed()) {
desired_angle_last = SAFETY_CLAMP(angle_meas.values[0], -limits.max_angle, limits.max_angle);
}
@@ -253,7 +253,7 @@ bool steer_curvature_cmd_checks(int desired_curvature, int steer_power, bool ste
speed_mismatch_check((float)vehicle_speed_2.values[0] / VEHICLE_SPEED_FACTOR);
if (controls_allowed && steer_control_enabled) {
if (lateral_controls_allowed() && steer_control_enabled) {
// *** absolute curvature cap ***
violation |= safety_max_limit_check(desired_curvature, limits.max_curvature, -limits.max_curvature);
@@ -322,11 +322,11 @@ bool steer_curvature_cmd_checks(int desired_curvature, int steer_power, bool ste
if (limits.max_steer_power != 0) {
violation |= safety_max_limit_check(steer_power, limits.max_steer_power, 0);
violation |= (steer_power != 0) && !steer_control_enabled;
violation |= !controls_allowed && (steer_power != 0) && (steer_power >= curvature_state.steer_power_last);
violation |= !lateral_controls_allowed() && (steer_power != 0) && (steer_power >= curvature_state.steer_power_last);
curvature_state.steer_power_last = steer_power;
} else {
// No curvature control allowed when controls are not allowed
violation |= !controls_allowed && steer_control_enabled;
violation |= !lateral_controls_allowed() && steer_control_enabled;
}
if (violation) {
@@ -362,7 +362,7 @@ bool steer_angle_cmd_checks_vm(int desired_angle, bool steer_control_enabled, co
bool violation = false;
if (controls_allowed && steer_control_enabled) {
if (lateral_controls_allowed() && steer_control_enabled) {
// *** ISO lateral jerk limit ***
// calculate maximum angle rate per second
const float max_curvature_rate_sec = MAX_LATERAL_JERK / (fudged_speed * fudged_speed);
@@ -396,12 +396,12 @@ bool steer_angle_cmd_checks_vm(int desired_angle, bool steer_control_enabled, co
}
// No angle control allowed when controls are not allowed
if (!controls_allowed) {
if (!lateral_controls_allowed()) {
violation |= steer_control_enabled;
}
// reset to current angle if either controls is not allowed or there's a violation
if (violation || !controls_allowed) {
if (violation || !lateral_controls_allowed()) {
desired_angle_last = SAFETY_CLAMP(angle_meas.values[0], -limits.max_angle, limits.max_angle);
}
+1 -1
View File
@@ -1,7 +1,7 @@
#include "opendbc/safety/declarations.h"
bool get_longitudinal_allowed(void) {
return controls_allowed && !gas_pressed_prev;
return longitudinal_controls_allowed() && !gas_pressed_prev;
}
// Safety checks for longitudinal actuation
+1 -1
View File
@@ -165,7 +165,7 @@ static safety_config rivian_init(uint16_t param) {
SAFETY_UNUSED(param);
#ifdef ALLOW_DEBUG
const int FLAG_RIVIAN_LONG_CONTROL = 1;
const uint16_t FLAG_RIVIAN_LONG_CONTROL = 1U;
rivian_longitudinal = GET_FLAG(param, FLAG_RIVIAN_LONG_CONTROL);
#endif
+88 -4
View File
@@ -55,6 +55,67 @@ const int MAX_WRONG_COUNTERS = 5;
// This can be set by the safety hooks
bool controls_allowed = false;
const AolSafetyPolicy *aol_policy = NULL;
bool aol_optional_release_capable = false;
uint8_t aol_host_axis_mask = 0U;
uint32_t aol_host_request_ts = 0U;
void aol_set_host_request(uint8_t axis_mask) {
if (aol_policy != NULL) {
aol_host_axis_mask = axis_mask & 0x3U;
aol_host_request_ts = microsecond_timer_get();
if (aol_policy->host_request != NULL) {
aol_policy->host_request(aol_host_axis_mask | (aol_optional_release_capable ? (axis_mask & 0x4U) : 0U));
}
} else {
aol_host_axis_mask = 0U;
}
}
uint8_t aol_get_request_mask(void) {
return ((aol_policy != NULL) && (aol_policy->request_mask != NULL)) ? aol_policy->request_mask() : 0U;
}
bool aol_rx_healthy(void) {
const uint32_t now = microsecond_timer_get();
bool healthy = (current_safety_config.rx_checks_len > 0) && (current_safety_config.rx_checks != NULL);
for (int i = 0; healthy && (i < current_safety_config.rx_checks_len); i++) {
const RxCheck *check = &current_safety_config.rx_checks[i];
if (!check->status.msg_seen || check->status.lagging || !check->status.valid_checksum ||
!check->status.valid_quality_flag || (check->status.wrong_counters >= MAX_WRONG_COUNTERS)) {
healthy = false;
} else {
const uint32_t frequency = check->msg[check->status.index].frequency;
if (frequency < 10U) {
healthy = false;
} else {
const uint32_t expected_age = 10000000U / frequency;
const uint32_t max_age = (expected_age > 100000U) ? expected_age : 100000U;
healthy = safety_get_ts_elapsed(now, check->status.last_timestamp) <= max_age;
}
}
}
return healthy;
}
uint8_t aol_get_permission_mask(void) {
return ((aol_policy != NULL) && (aol_policy->permission_mask != NULL)) ? aol_policy->permission_mask() : 0U;
}
bool lateral_controls_allowed(void) {
bool allowed = controls_allowed;
if (aol_policy != NULL) {
allowed = (aol_get_permission_mask() & 0x1U) != 0U;
}
return allowed;
}
bool longitudinal_controls_allowed(void) {
bool allowed = controls_allowed;
if (aol_policy != NULL) {
allowed = (aol_get_permission_mask() & 0x2U) != 0U;
}
return allowed;
}
bool relay_malfunction = false;
bool gas_pressed = false;
bool gas_pressed_prev = false;
@@ -107,7 +168,7 @@ static const safety_hooks *current_hooks = &nooutput_hooks;
safety_config current_safety_config;
static void generic_rx_checks(void);
static void stock_ecu_check(bool stock_ecu_detected);
static void stock_ecu_check(bool stock_ecu_detected, bool immediate);
static bool is_msg_valid(RxCheck addr_list[], int index) {
bool valid = true;
@@ -115,6 +176,10 @@ static bool is_msg_valid(RxCheck addr_list[], int index) {
if (!addr_list[index].status.valid_checksum || !addr_list[index].status.valid_quality_flag || (addr_list[index].status.wrong_counters >= MAX_WRONG_COUNTERS)) {
valid = false;
controls_allowed = false;
aol_host_axis_mask = 0U;
if ((aol_policy != NULL) && (aol_policy->rx_invalid != NULL)) {
aol_policy->rx_invalid();
}
}
}
return valid;
@@ -204,6 +269,11 @@ bool safety_rx_hook(const CANPacket_t *msg) {
if (valid && whitelisted) {
current_hooks->rx(msg);
}
if (!whitelisted && (current_hooks->optional_rx != NULL)) {
// Optional ingress is interpreted by the exact mode-owned callback. It
// cannot satisfy or weaken the required control RX health checks above.
current_hooks->optional_rx(msg);
}
// Handles gas, brake, and regen paddle
generic_rx_checks();
@@ -215,7 +285,7 @@ bool safety_rx_hook(const CANPacket_t *msg) {
for (int i = 0; i < current_safety_config.tx_msgs_len; i++) {
const CanMsg *m = &current_safety_config.tx_msgs[i];
if (m->check_relay) {
stock_ecu_check((m->addr == addr) && (m->bus == msg->bus));
stock_ecu_check((m->addr == addr) && (m->bus == msg->bus), m->check_relay_immediately);
}
}
@@ -337,6 +407,7 @@ void safety_tick(void) {
if (lagging || frequency_invalid || !is_msg_valid(current_safety_config.rx_checks, i)) {
rx_checks_invalid = true;
controls_allowed = false;
aol_host_axis_mask = 0U;
}
}
@@ -369,12 +440,12 @@ static void generic_rx_checks(void) {
steering_disengage_prev = steering_disengage;
}
static void stock_ecu_check(bool stock_ecu_detected) {
static void stock_ecu_check(bool stock_ecu_detected, bool immediate) {
// allow 1s of transition timeout after relay changes state before assessing malfunctioning
const uint32_t RELAY_TRNS_TIMEOUT = 1U;
// check if stock ECU is on bus broken by car harness
if ((safety_mode_cnt > RELAY_TRNS_TIMEOUT) && stock_ecu_detected) {
if ((immediate || (safety_mode_cnt > RELAY_TRNS_TIMEOUT)) && stock_ecu_detected) {
relay_malfunction_set();
}
}
@@ -456,6 +527,13 @@ int set_safety_hooks(uint16_t mode, uint16_t param) {
reset_sample(&angle_meas);
controls_allowed = false;
if ((aol_policy != NULL) && (aol_policy->reset != NULL)) {
aol_policy->reset();
}
aol_policy = NULL;
aol_optional_release_capable = false;
aol_host_axis_mask = 0U;
aol_host_request_ts = 0U;
relay_malfunction_reset();
safety_rx_checks_invalid = false;
@@ -465,6 +543,12 @@ int set_safety_hooks(uint16_t mode, uint16_t param) {
int hook_config_count = sizeof(safety_hook_registry) / sizeof(safety_hook_config);
for (int i = 0; i < hook_config_count; i++) {
if (safety_hook_registry[i].id == mode) {
if ((mode == SAFETY_TESLA) && ((param & ~1U) != 0U)) {
current_hooks = &nooutput_hooks;
current_safety_mode = SAFETY_NOOUTPUT;
current_safety_param = 0;
continue;
}
current_hooks = safety_hook_registry[i].hooks;
current_safety_mode = mode;
current_safety_param = param;
+24 -8
View File
@@ -984,16 +984,14 @@ class SafetyTest(SafetyTestBase):
continue
if attr.startswith('TestHyundaiCanfd') and current_test.startswith('TestHyundaiCanfd'):
continue
if {attr, current_test}.issubset({'TestHyundaiLongitudinalSafety', 'TestHyundaiLongitudinalSafetyCameraSCC', 'TestHyundaiSafetyFCEVLong'}):
if {attr, current_test}.issubset({'TestHyundaiLongitudinalSafety', 'TestHyundaiLongitudinalSafetyCameraSCC',
'TestHyundaiLongitudinalSafetyCameraSCCRefresh',
'TestHyundaiLongitudinalSafetyCameraSCCRefreshHybrid', 'TestHyundaiSafetyFCEVLong'}):
continue
volkswagen_shared = ('TestVolkswagenMqb', 'TestVolkswagenMlb', 'TestVolkswagenMeb')
if attr.startswith(volkswagen_shared) and current_test.startswith(volkswagen_shared):
continue
# overlapping TX addrs, but they're not actuating messages for either car
if attr == 'TestHyundaiCanfdLKASteeringLongEV' and current_test.startswith('TestToyota'):
tx = list(filter(lambda m: m[0] not in [0x160, ], tx))
# Volkswagen MQB longitudinal actuating message overlaps with the Subaru lateral actuating message
if attr == 'TestVolkswagenMqbLongSafety' and current_test.startswith('TestSubaru'):
tx = list(filter(lambda m: m[0] not in [0x122, ], tx))
@@ -1014,14 +1012,32 @@ class SafetyTest(SafetyTestBase):
if attr.startswith('TestHyundaiLongitudinal'):
# exceptions for common msgs across different Hyundai CAN platforms
tx = list(filter(lambda m: m[0] not in [0x420, 0x50A, 0x389, 0x4A2], tx))
classic_hyundai = ('TestHyundaiSafety', 'TestHyundaiLongitudinalSafety', 'TestHyundaiLegacySafety')
refresh_hyundai = {'TestHyundaiSafetyCameraSCCRefresh', 'TestHyundaiSafetyCameraSCCRefreshHybrid',
'TestHyundaiLongitudinalSafetyCameraSCCRefresh', 'TestHyundaiLongitudinalSafetyCameraSCCRefreshHybrid'}
if current_test in refresh_hyundai and attr.startswith(classic_hyundai):
# Classic Hyundai modes share 0x485 on bus 0 for LFA/HDA display. The
# generic cross-mode probe fabricates eight bytes, which the refresh
# variant accepts; exact 4/8-byte and bus checks live in Hyundai tests.
tx = list(filter(lambda m: m[:2] != [0x485, 0], tx))
all_tx.append([[m[0], m[1], attr] for m in tx])
# make sure we got all the msgs
self.assertTrue(len(all_tx) >= len(test_files)-1)
# Independent protocol/whole-caller test files need not declare a SafetyTest
# TX table. Count the actual selected cross-mode tables, not Python files;
# every selected address below must still be rejected by this safety mode.
self.assertTrue(any(all_tx), "No cross-mode safety TX probes were discovered")
for tx_msgs in all_tx:
for addr, bus, test_name in tx_msgs:
msg = make_msg(bus, addr)
# CCNC display frames are 32 bytes. An eight-byte probe aliases the
# unrelated Subaru 0x161 command and falsely reports cross-mode TX.
size = 32 if test_name.startswith('TestHyundaiCanfdCCNC') and bus == 0 and addr in (0x161, 0x162) else 8
# HDA2 LONG's ADRV warning is 16 bytes. An eight-byte probe instead
# constructs Toyota's legitimate 0x160/bus1 display frame. Keep the
# foreign command in this test with its actual shape, for both layouts.
if test_name in ('TestHyundaiCanfdLKASteeringLongEV', 'TestHyundaiCanfdLKASteeringAltLongEV') and (addr, bus) == (0x160, 1):
size = 16
msg = make_msg(bus, addr, size)
self.safety.set_controls_allowed(1)
# TODO: this should be blocked
if current_test in ["TestNissanSafety", "TestNissanSafetyAltEpsBus", "TestNissanLeafSafety"] and [addr, bus] in self.TX_MSGS:
@@ -58,19 +58,46 @@ ffi.cdef(cdef_from_file(safety_dir / "can.h"), packed=True)
for path in (safety_dir / "declarations.h", safety_dir / "ignition.h", Path(libsafety_dir) / "safety.c"):
ffi.cdef(cdef_from_file(path))
def _can_packet_alignment() -> int:
source = (safety_dir / "can.h").read_text()
match = re.search(r"__attribute__\(\(\s*packed\s*,\s*aligned\((\d+)\)\s*\)\)\s*CANPacket_t\s*;", source)
if match is None or int(match[1]) != ffi.alignof("unsigned int"):
raise RuntimeError("Unsupported native CANPacket_t alignment")
return int(match[1])
CAN_PACKET_ALIGNMENT = _can_packet_alignment()
def _allocate_can_packet(size: int):
# CFFI keeps the packed field layout but cannot express packed + aligned.
# Its allocator retains this owning buffer until the packet is released.
words = (size + CAN_PACKET_ALIGNMENT - 1) // CAN_PACKET_ALIGNMENT
return ffi.new("unsigned int[]", words)
_can_packet_allocator = ffi.new_allocator(alloc=_allocate_can_packet)
def new_CANPacket():
"""Allocate native-sized/aligned storage while preserving packed field offsets."""
return _can_packet_allocator("CANPacket_t *")
class CANPacket:
pass
# This function is generated by mutation.py rather than defined in the sources above.
ffi.cdef("void mutation_set_active_mutant(int id);")
ffi.cdef("void mutation_set_active_mutant(int id); int mutation_get_active_mutant(void);")
class LibSafety:
pass
libsafety: LibSafety
libpath: str
def load(path):
global libsafety
global libsafety, libpath
libsafety = ffi.dlopen(str(path))
libpath = str(Path(path).resolve())
def __getattr__(name):
if name == "libsafety":
@@ -79,7 +106,7 @@ def __getattr__(name):
raise AttributeError(name)
def make_CANPacket(addr: int, bus: int, dat):
ret = ffi.new('CANPacket_t *')
ret = new_CANPacket()
ret[0].extended = 1 if addr >= 0x800 else 0
ret[0].addr = addr
ret[0].data_len_code = LEN_TO_DLC[len(dat)]
@@ -13,6 +13,41 @@ uint32_t microsecond_timer_get(void) {
#include "opendbc/safety/safety.h"
#include "opendbc/safety/ignition.h"
// Capture checked internally scheduled CAN frames for native safety tests.
#define RECORDED_CAN_CAPACITY 32
static CANPacket_t recorded_can[RECORDED_CAN_CAPACITY];
static unsigned int recorded_can_count = 0U;
void reset_recorded_can(void) {
recorded_can_count = 0U;
}
unsigned int get_recorded_can_count(void) {
return recorded_can_count;
}
bool get_recorded_can(unsigned int index, CANPacket_t *out) {
if (index >= recorded_can_count || out == NULL) {
return false;
}
*out = recorded_can[index];
return true;
}
void can_set_checksum(CANPacket_t *packet) {
// This test harness inspects the CAN payload and exercises the TX hook;
// Panda packet framing checksums are added by the device transport.
packet->checksum = 0U;
}
void can_send(CANPacket_t *to_push, uint8_t bus_number, bool skip_tx_hook) {
CANPacket_t packet = *to_push;
packet.bus = bus_number;
if ((skip_tx_hook || safety_tx_hook(&packet)) && recorded_can_count < RECORDED_CAN_CAPACITY) {
recorded_can[recorded_can_count++] = packet;
}
}
bool safety_config_valid() {
if (current_safety_config.rx_checks_len <= 0) {
printf("missing RX checks\n");
@@ -30,10 +65,35 @@ bool safety_config_valid() {
return true;
}
// Exercise defensive AOL RX guards with configs that production safety init
// cannot construct. Restore the active mode's config before returning.
bool safety_test_rx_health_fixture(unsigned int variant, bool canfd) {
const uint32_t now = microsecond_timer_get();
RxCheck check = {
.msg = {{.addr = 0x123, .bus = 0U, .len = 8,
.frequency = (variant == 2U) ? 9U : 100U,
.ignore_checksum = false, .ignore_counter = false,
.max_counter = 0U, .ignore_quality_flag = false}, {0}, {0}},
.status = {.msg_seen = true, .index = 0, .valid_checksum = true,
.valid_quality_flag = true, .last_timestamp = (variant == 3U) ? now - 100001U : now},
};
const safety_config original = current_safety_config;
current_safety_config.rx_checks = (variant == 1U) ? NULL : &check;
current_safety_config.rx_checks_len = (variant == 0U) ? 0 : 1;
(void)canfd;
const bool healthy = aol_rx_healthy();
current_safety_config = original;
return healthy;
}
void set_controls_allowed(bool c){
controls_allowed = c;
}
void set_aol_test_heartbeat(bool engaged) {
heartbeat_engaged = engaged;
}
void set_alternative_experience(int mode){
alternative_experience = mode;
}
@@ -144,6 +204,10 @@ void set_desired_torque_last(int t){
desired_torque_last = t;
}
int get_desired_angle_last(void){
return desired_angle_last;
}
void set_desired_angle_last(int t){
desired_angle_last = t;
}
@@ -12,3 +12,6 @@ SAFETY_UNUSED(safety_tx_hook);
SAFETY_UNUSED(safety_fwd_hook);
SAFETY_UNUSED(safety_tick);
SAFETY_UNUSED(set_safety_hooks);
// Exported to Panda's USB status handler and the native safety test library.
SAFETY_UNUSED(aol_get_request_mask);
SAFETY_UNUSED(aol_get_permission_mask);
+389 -52
View File
@@ -1,8 +1,13 @@
#!/usr/bin/env python3
import argparse
import hashlib
import io
import json
import multiprocessing
import os
import re
import signal
import shutil
import subprocess
import sys
import tempfile
@@ -19,6 +24,8 @@ import tree_sitter as ts
ROOT = Path(__file__).resolve().parents[3]
SAFETY_DIR = ROOT / "opendbc" / "safety"
BLUEPILOT_SAFETY_DIR = ROOT / "opendbc" / "bluepilot_lateral" / "safety"
SAFETY_SOURCE_DIRS = (SAFETY_DIR, BLUEPILOT_SAFETY_DIR)
SAFETY_TESTS_DIR = ROOT / "opendbc" / "safety" / "tests"
SAFETY_C_REL = Path("opendbc/safety/tests/libsafety/safety.c")
@@ -74,6 +81,30 @@ class MutantResult:
outcome: str # killed | survived | infra_error
test_sec: float
details: str
failure_kind: str | None = None
exit_code: int | None = None
stderr_tail: str = ""
stdout_tail: str = ""
unittest_output_tail: str = ""
@dataclass(frozen=True)
class TestRun:
ran: int
skipped: int
suppressed: int
failures: tuple[str, ...]
errors: tuple[str, ...]
output_tail: str = ""
class IsolatedRunError(RuntimeError):
def __init__(self, kind: str, message: str, *, exit_code: int | None, stderr: str, stdout: str):
super().__init__(message)
self.kind = kind
self.exit_code = exit_code
self.stderr_tail = stderr[-32768:]
self.stdout_tail = stdout[-8192:]
def colorize(text, color):
@@ -105,9 +136,13 @@ def _site_key(site):
def _is_in_constexpr_context(node):
"""Check if a node is inside a static or file-scope variable initializer."""
"""Find contexts that cannot contain the runtime mutant selector."""
current = node.parent
while current is not None:
if current.type == "call_expression":
function = current.child_by_field_name("function")
if function is not None and function.text == b"_Static_assert":
return True
if current.type == "init_declarator":
decl = current.parent
if decl and decl.type == "declaration":
@@ -241,7 +276,7 @@ def enumerate_sites(input_source, preprocessed_file):
if mapped is None:
continue
origin_file, origin_line = mapped
if SAFETY_DIR not in origin_file.parents and origin_file != SAFETY_DIR:
if not any(origin_file.is_relative_to(source_dir) for source_dir in SAFETY_SOURCE_DIRS):
continue
site_id = len(out)
site = MutationSite(
@@ -257,6 +292,138 @@ def enumerate_sites(input_source, preprocessed_file):
return out, counts, build_incompatible_site_ids, txt
def equivalent_division_candidates(source, sites, excluded_ids):
"""Recognize only closed unsigned-int division used directly in a comparison.
Replacing one literal may preserve the entire quotient. An unchanged quotient
AND C type preserves the comparison for every value of its other operand.
This deliberately does not reason about variable expressions or conversions.
"""
parser = ts.Parser(ts.Language(ts_c.language()))
tree = parser.parse(_prepare_for_parsing(source).encode())
literals = {}
stack = [tree.root_node]
while stack:
node = stack.pop()
if node.type == "number_literal":
literals[node.start_byte, node.end_byte] = node
stack.extend(node.children)
def unwrap(node):
while node is not None and node.type == "parenthesized_expression" and len(node.named_children) == 1:
node = node.named_children[0]
return node
def outer(node):
while node.parent is not None and node.parent.type == "parenthesized_expression":
node = node.parent
return node
def unsigned_literal(node):
node = unwrap(node)
if node is None or node.type != "number_literal":
return None
token = source[node.start_byte:node.end_byte]
# Only the U suffix; decimal/hex, with no signed/octal/long/type ambiguity.
if re.fullmatch(r"(?:0|[1-9][0-9]*|0[xX][0-9a-fA-F]+)[uU]", token) is None:
return None
parsed = _parse_int_literal(token)
return (node, parsed[0]) if parsed is not None and parsed[0] <= 0xFFFFFFFF else None
records = []
for site in sites:
if site.mutator != "boundary" or site.site_id in excluded_ids:
continue
literal = literals.get((site.op_start, site.op_end))
if literal is None:
continue
division = outer(literal).parent
if division is None or division.type != "binary_expression":
continue
operator = division.child_by_field_name("operator")
if operator is None or operator.type != "/":
continue
left, right = (unsigned_literal(division.child_by_field_name(field)) for field in ("left", "right"))
comparison = outer(division).parent
if left is None or right is None or comparison is None or comparison.type != "binary_expression":
continue
comparison_op = comparison.child_by_field_name("operator")
if comparison_op is None or comparison_op.type not in COMPARISON_OPERATOR_MAP:
continue
mutated = unsigned_literal(literal)
parsed_mutated = _parse_int_literal(site.mutated_op)
if mutated is None or parsed_mutated is None or parsed_mutated[2].lower() != "u" or parsed_mutated[0] > 0xFFFFFFFF:
continue
numerator, denominator = left[1], right[1]
next_numerator = parsed_mutated[0] if left[0] == literal else numerator
next_denominator = parsed_mutated[0] if right[0] == literal else denominator
if denominator == 0 or next_denominator == 0 or numerator // denominator != next_numerator // next_denominator:
continue
original = source[division.start_byte:division.end_byte]
offset = site.op_start - division.start_byte
if original[offset:offset + len(site.original_op)] != site.original_op:
raise RuntimeError("equivalence literal no longer matches preprocessed source")
changed = original[:offset] + site.mutated_op + original[offset + len(site.original_op):]
records.append({"site_id": site.site_id, "source": str(site.origin_file.relative_to(ROOT)),
"line": site.origin_line, "mutator": site.mutator, "original_op": site.original_op,
"mutated_op": site.mutated_op, "op_start": site.op_start, "op_end": site.op_end,
"expression_start": division.start_byte, "expression_end": division.end_byte,
"comparison_start": comparison.start_byte, "comparison_end": comparison.end_byte,
"comparison_operator": comparison_op.type, "original_expression": original,
"mutated_expression": changed, "c_type": "unsigned int", "value": numerator // denominator,
"proof_kind": "closed-unsigned-division-v1"})
return records
def division_proof_source(records):
lines = ["/* Compiler proof of equal closed unsigned-int division expressions. */"]
for record in records:
original, changed = record["original_expression"], record["mutated_expression"]
for expression in (original, changed):
lines.append(f'_Static_assert(__builtin_types_compatible_p(__typeof__({expression}), unsigned int), "unsigned int type");')
lines.append(f'_Static_assert(({expression}) == {record["value"]}U, "quotient value");')
lines.append(f'_Static_assert(({original}) == ({changed}), "equivalent quotient");')
return "\n".join(lines) + "\n"
def prove_equivalent_divisions(source, sites, excluded_ids):
records = equivalent_division_candidates(source, sites, excluded_ids)
if not records:
return []
compiler = Path(shutil.which("cc") or "").resolve()
if not compiler.is_file():
raise RuntimeError("equivalence proof requires the actual C compiler")
proof = division_proof_source(records)
flags = ["-fPIC", "-g0", "-O0", "-DALLOW_DEBUG", "-Werror", "-std=gnu11", "-fno-builtin", "-fsyntax-only", "-x", "c", "-"]
result = subprocess.run([str(compiler), *flags], input=proof, cwd=ROOT, capture_output=True, text=True, timeout=30)
if result.returncode:
raise RuntimeError(f"equivalence compiler proof failed: {result.stderr[-4096:]}")
version = subprocess.run([str(compiler), "--version"], cwd=ROOT, capture_output=True, text=True, timeout=30)
target = subprocess.run([str(compiler), "-dumpmachine"], cwd=ROOT, capture_output=True, text=True, timeout=30)
if version.returncode or target.returncode or not version.stdout.strip() or not re.fullmatch(r"[a-zA-Z0-9_.+-]+", target.stdout.strip()):
raise RuntimeError("equivalence compiler identity is unavailable")
receipt = {"compiler_argv": [str(compiler), *flags], "compiler_version": version.stdout.strip(),
"compiler_target": target.stdout.strip(), "compiler_sha256": hashlib.sha256(compiler.read_bytes()).hexdigest(),
"compiler_flags": flags, "proof_source_sha256": hashlib.sha256(proof.encode()).hexdigest()}
return [{**record, **receipt} for record in records]
def verify_equivalence_artifact(path):
"""Reconstruct proofs from pinned real inputs, rather than trusting the claims."""
raw = path.read_bytes()
artifact = json.loads(raw)
with tempfile.TemporaryDirectory(prefix="mutation-equivalence-verify-") as directory:
sites, _, excluded, source = enumerate_sites(ROOT / SAFETY_C_REL, Path(directory) / "source.i")
if artifact["discovered"] != len(sites) or artifact["preprocessed_source_sha256"] != hashlib.sha256(source.encode()).hexdigest():
raise RuntimeError("equivalence discovery/source identity changed")
if {item["site_id"] for item in artifact["pruned_build_incompatible"]} != excluded:
raise RuntimeError("constant-context partition changed")
proofs = prove_equivalent_divisions(source, sites, excluded)
if artifact["proven_equivalent"] != proofs:
raise RuntimeError("equivalence partition or compiler proof changed")
return hashlib.sha256(raw).hexdigest()
def _build_core_tests(catalog):
"""Build test ordering for core (non-mode) files.
@@ -273,16 +440,15 @@ def _build_core_tests(catalog):
method_freq[method] = method_freq.get(method, 0) + 1
if method not in method_by_module:
method_by_module[method] = {}
if name not in method_by_module[method]:
method_by_module[method][name] = test_id
method_by_module[method].setdefault(name, []).append(test_id)
# Round-robin: first instance of each method (by freq), then second, etc.
# This ensures diverse early coverage with failfast.
sorted_methods = sorted(method_freq, key=lambda m: -method_freq[m])
ordered = []
max_implementations = max(len(module_map) for module_map in method_by_module.values())
max_implementations = max(sum(len(ids) for ids in module_map.values()) for module_map in method_by_module.values())
for round_idx in range(max_implementations):
for m in sorted_methods:
ids = [method_by_module[m][module] for module in sorted(method_by_module[m])]
ids = [test_id for module in sorted(method_by_module[m]) for test_id in method_by_module[m][module]]
if round_idx < len(ids):
ordered.append(ids[round_idx])
return ordered
@@ -291,16 +457,21 @@ def _build_core_tests(catalog):
def build_priority_tests(site, catalog, core_tests):
"""Build an ordered list of test IDs for a mutation site.
For mode files: all tests from the matching test_<mode>.py module.
For core files: uses the pre-computed core_tests ordering.
For mode files: all tests for the mode's vehicle family, including newer variant suites.
For core files: uses the complete pre-computed core_tests ordering.
"""
src = site.origin_file
rel_parts = src.relative_to(ROOT).parts
is_mode = len(rel_parts) >= 4 and rel_parts[:3] == ("opendbc", "safety", "modes")
is_mode = (len(rel_parts) >= 4 and rel_parts[:3] == ("opendbc", "safety", "modes")) or src.parent == BLUEPILOT_SAFETY_DIR
if is_mode:
mode_file = f"test_{src.stem}.py"
return list(catalog.get(mode_file, []))
family = src.stem.split("_", 1)[0]
family_modules = [name for name in sorted(catalog) if name == f"test_{family}.py" or name.startswith(f"test_{family}_") or
(family == "ford" and name == "test_angle_native.py")]
if family_modules:
return [test_id for name in family_modules for test_id in catalog[name]]
# Unmapped mode files still exercise the full safety suite rather than receive zero targets.
return [test_id for name in sorted(catalog) for test_id in catalog[name]]
return core_tests
@@ -358,7 +529,9 @@ def print_live_status(text, *, final=False):
def _discover_test_catalog():
loader = unittest.TestLoader()
catalog = {}
for test_file in sorted(SAFETY_TESTS_DIR.glob("test_*.py")):
test_files = [*sorted(SAFETY_TESTS_DIR.glob("test_*.py")),
ROOT / "opendbc" / "bluepilot_lateral" / "tests" / "test_angle_native.py"]
for test_file in test_files:
module_name = ".".join(test_file.relative_to(ROOT).with_suffix("").parts)
suite = loader.loadTestsFromName(module_name)
catalog[test_file.name] = [t.id() for group in suite for t in group]
@@ -381,11 +554,25 @@ def run_unittest(targets, lib_path, mutant_id, verbose):
for target in targets:
suite.addTests(loader.loadTestsFromName(target))
result = runner.run(suite)
if result.failures:
return result.failures[0][0].id()
if result.errors:
return result.errors[0][0].id()
return None
suppressed_indices = set()
method_skips = 0
for test, _reason in result.skipped:
skipped_id = test.id()
class_match = re.fullmatch(r"setUpClass \(([^)]+)\)", skipped_id)
module_match = re.fullmatch(r"setUpModule \(([^)]+)\)", skipped_id)
if class_match or module_match:
prefix = (class_match or module_match).group(1) + "."
matched = {index for index, target in enumerate(targets) if target.startswith(prefix)}
if not matched:
raise RuntimeError(f"unmatched unittest skip holder: {skipped_id}")
suppressed_indices.update(matched)
elif skipped_id in targets:
method_skips += 1
else:
raise RuntimeError(f"unexpected unittest skip holder: {skipped_id}")
return TestRun(result.testsRun, method_skips, len(suppressed_indices),
tuple(test.id() for test, _ in result.failures),
tuple(test.id() for test, _ in result.errors), stream.getvalue()[-32768:])
def _instrument_source(source, sites):
@@ -466,16 +653,158 @@ def compile_mutated_library(preprocessed_source, sites, output_so):
], cwd=ROOT, check=True)
def eval_mutant(site, targets, lib_path, verbose):
def _run_isolated_unittest(site_id, targets, lib_path, timeout_sec):
script = """import faulthandler, json, sys
from pathlib import Path
from dataclasses import asdict
from opendbc.safety.tests import mutation
faulthandler.enable(all_threads=True)
request = json.loads(sys.stdin.read())
result = mutation.run_unittest(request['targets'], Path(request['library']), request['site_id'], False)
print(json.dumps(asdict(result)), flush=True)
"""
request = json.dumps({"targets": targets, "library": str(lib_path), "site_id": site_id})
child_env = dict(os.environ, PWD=str(ROOT))
proc = subprocess.Popen([sys.executable, "-c", script], cwd=ROOT, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, env=child_env)
try:
stdout, stderr = proc.communicate(request, timeout=timeout_sec)
except subprocess.TimeoutExpired as exc:
os.killpg(proc.pid, signal.SIGKILL)
stdout, stderr = proc.communicate()
raise IsolatedRunError("timeout", f"isolated mutant timed out after {timeout_sec:.1f}s",
exit_code=proc.returncode, stderr=stderr, stdout=stdout) from exc
if proc.returncode != 0:
raise IsolatedRunError("signal" if proc.returncode < 0 else "child_exit",
f"isolated mutant exit={proc.returncode}", exit_code=proc.returncode,
stderr=stderr, stdout=stdout)
try:
data = json.loads(stdout.splitlines()[-1])
return TestRun(**data)
except (IndexError, ValueError, TypeError) as exc:
raise IsolatedRunError("missing_result", "isolated mutant missing result", exit_code=proc.returncode,
stderr=stderr, stdout=stdout) from exc
def eval_mutant(site, targets, lib_path, verbose, timeout_sec):
try:
t0 = time.perf_counter()
failed_test = run_unittest(targets, lib_path, mutant_id=site.site_id, verbose=verbose)
run = _run_isolated_unittest(site.site_id, targets, lib_path, timeout_sec)
duration = time.perf_counter() - t0
if failed_test is not None:
return MutantResult(site, "killed", duration, "")
if run.errors or ((run.ran + run.suppressed != len(targets)) and not run.failures) or run.ran == run.skipped:
return MutantResult(site, "infra_error", duration, f"unittest errors={run.errors} ran={run.ran} skipped={run.skipped} suppressed={run.suppressed}",
failure_kind="unittest_error", unittest_output_tail=run.output_tail)
if run.failures:
return MutantResult(site, "killed", duration, run.failures[0], unittest_output_tail=run.output_tail)
return MutantResult(site, "survived", duration, "")
except IsolatedRunError as exc:
return MutantResult(site, "infra_error", 0.0, str(exc), failure_kind=exc.kind,
exit_code=exc.exit_code, stderr_tail=exc.stderr_tail, stdout_tail=exc.stdout_tail)
except Exception as exc:
return MutantResult(site, "infra_error", 0.0, str(exc))
return MutantResult(site, "infra_error", 0.0, str(exc), failure_kind="worker_exception")
def completed_mutant_result(future, site):
"""A failed worker is infrastructure loss, not proof that a mutant was killed."""
try:
return future.result()
except Exception as exc:
return MutantResult(site, "infra_error", 0.0, f"{type(exc).__name__}: {exc}", failure_kind="pool_exception")
def require_full_baseline(catalog, sites, lib_path, verbose):
"""Validate every discovered test before any mutant is submitted to workers."""
if not catalog or any(not ids for ids in catalog.values()):
raise RuntimeError("empty safety test catalog or module")
baseline_ids = [test_id for module in sorted(catalog) for test_id in catalog[module]]
baseline = run_unittest(baseline_ids, lib_path, mutant_id=-1, verbose=verbose)
if (baseline.failures or baseline.errors or baseline.ran + baseline.suppressed != len(baseline_ids) or
baseline.ran == baseline.skipped):
raise RuntimeError(f"unmutated full safety baseline failed: {baseline}")
core_tests = _build_core_tests(catalog)
targets = {site.site_id: build_priority_tests(site, catalog, core_tests) for site in sites}
missing = [site_id for site_id, ids in targets.items() if not ids]
if missing:
raise RuntimeError(f"mutants lack test targets: {missing[:20]} ({len(missing)} total)")
return targets
def write_results_json(path: Path, *, discovered_sites, pruned_ids, results, site_targets,
preprocessed_source: str, baseline_sec: float, proven_equivalent=()) -> None:
"""Retain complete bounded diagnostics without changing mutation verdicts."""
equivalent_ids = {item["site_id"] for item in proven_equivalent}
if (len(equivalent_ids) != len(proven_equivalent) or equivalent_ids & set(pruned_ids) or
not equivalent_ids <= {site.site_id for site in discovered_sites}):
raise RuntimeError("equivalence partition must contain unique, independently pruned sites")
expected_ids = {site.site_id for site in discovered_sites} - set(pruned_ids) - equivalent_ids
result_ids = [result.site.site_id for result in results]
if len(result_ids) != len(set(result_ids)) or set(result_ids) != expected_ids:
raise RuntimeError("mutation result artifact requires one outcome for every executed site")
target_sets: list[list[str]] = []
target_indexes: dict[tuple[str, ...], int] = {}
records = []
for result in sorted(results, key=lambda item: item.site.site_id):
targets = tuple(site_targets[result.site.site_id])
if targets not in target_indexes:
target_indexes[targets] = len(target_sets)
target_sets.append(list(targets))
site = result.site
records.append({
"site_id": site.site_id,
"source": str(site.origin_file.relative_to(ROOT)),
"line": site.origin_line,
"mutator": site.mutator,
"original_op": site.original_op,
"mutated_op": site.mutated_op,
"selected_test_set": target_indexes[targets],
"selected_test_count": len(targets),
"outcome": result.outcome,
"test_sec": result.test_sec,
"details": result.details,
"failure_kind": result.failure_kind,
"exit_code": result.exit_code,
"stderr_tail": result.stderr_tail,
"stdout_tail": result.stdout_tail,
"unittest_output_tail": result.unittest_output_tail,
})
sources = {str(site.origin_file.relative_to(ROOT)): hashlib.sha256(site.origin_file.read_bytes()).hexdigest()
for site in discovered_sites}
artifact = {
"schema_version": 2,
"proven_equivalent": list(proven_equivalent),
"safety_input_sha256": hashlib.sha256((ROOT / SAFETY_C_REL).read_bytes()).hexdigest(),
"source_sha256": sources,
"preprocessed_source_sha256": hashlib.sha256(preprocessed_source.encode()).hexdigest(),
"mutation_runner_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"baseline_sec": baseline_sec,
"discovered": len(discovered_sites),
"pruned_build_incompatible": [{
"site_id": site.site_id,
"source": str(site.origin_file.relative_to(ROOT)),
"line": site.origin_line,
"mutator": site.mutator,
"original_op": site.original_op,
"mutated_op": site.mutated_op,
} for site in discovered_sites if site.site_id in pruned_ids],
"target_sets": target_sets,
"results": records,
}
path.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", prefix=f".{path.name}.",
suffix=".tmp", dir=path.parent, delete=False) as stream:
temporary = Path(stream.name)
json.dump(artifact, stream, indent=2, sort_keys=True)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
temporary = None
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def main():
@@ -484,7 +813,16 @@ def main():
parser.add_argument("--max-mutants", type=int, default=0, help="optional limit for debugging (0 means all)")
parser.add_argument("--list-only", action="store_true", help="list discovered candidates and exit")
parser.add_argument("--verbose", action="store_true", help="print extra debug output")
parser.add_argument("--results-json", type=Path, help="optional per-candidate result and diagnostic artifact")
parser.add_argument("--verify-equivalence-artifact", type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.verify_equivalence_artifact is not None:
if args.list_only or args.results_json is not None or args.max_mutants:
parser.error("equivalence verification is separate from execution")
print(verify_equivalence_artifact(args.verify_equivalence_artifact), flush=True)
return 0
if args.list_only and args.results_json is not None:
parser.error("--results-json requires a full mutation run")
start = time.perf_counter()
@@ -495,6 +833,7 @@ def main():
if args.max_mutants > 0:
sites = sites[: args.max_mutants]
discovered_sites = sites
mutator_summary = ", ".join(f"{name} ({c})" for name in MUTATOR_FAMILIES if (c := mutator_counts.get(name, 0)) > 0)
print(f"Found {len(sites)} unique candidates: {mutator_summary}", flush=True)
@@ -510,9 +849,13 @@ def main():
selected_site_ids = {s.site_id for s in sites}
build_incompatible_ids &= selected_site_ids
pruned_compile_sites = len(build_incompatible_ids)
proven_equivalent = prove_equivalent_divisions(preprocessed_source, sites, build_incompatible_ids)
equivalent_ids = {record["site_id"] for record in proven_equivalent}
sites = [site for site in sites if site.site_id not in equivalent_ids]
print(f"Proven equivalent: {len(proven_equivalent)} closed unsigned division mutants", flush=True)
if pruned_compile_sites > 0:
sites = [s for s in sites if s.site_id not in build_incompatible_ids]
print(f"Pruned {pruned_compile_sites} build-incompatible mutants from constant-expression initializers", flush=True)
print(f"Pruned {pruned_compile_sites} build-incompatible mutants from constant-expression contexts", flush=True)
if not sites:
print("Failed to build mutation library: all sites were pruned as build-incompatible", flush=True)
return 2
@@ -524,49 +867,50 @@ def main():
# Forked workers inherit these imports, eliminating per-worker import cost.
catalog = _discover_test_catalog()
# Baseline smoke check
baseline_ids = catalog.get("test_defaults.py", [])[:5]
baseline_failed = run_unittest(baseline_ids, mutation_lib, mutant_id=-1, verbose=args.verbose)
if baseline_failed is not None:
print("Baseline smoke failed with mutant_id=-1; aborting to avoid false kill signals.", flush=True)
print(f" failed_test: {baseline_failed}", flush=True)
# A mutant-induced assertion or exception counts as killed only after this
# same complete test catalog has passed against the unmutated library.
try:
baseline_start = time.perf_counter()
site_targets = require_full_baseline(catalog, sites, mutation_lib, args.verbose)
baseline_sec = time.perf_counter() - baseline_start
except RuntimeError as exc:
print(f"Mutation baseline or target selection failed: {exc}", flush=True)
return 2
# Pre-compute test targets per mutation site
core_tests = _build_core_tests(catalog)
site_targets = {site.site_id: build_priority_tests(site, catalog, core_tests) for site in sites}
results = []
counts = Counter()
timeout_sec = max(60.0, min(300.0, baseline_sec * 4.0))
print(f"Isolated mutant timeout: {timeout_sec:.1f}s (unmutated full baseline {baseline_sec:.1f}s)", flush=True)
with ProcessPoolExecutor(max_workers=args.j) as pool:
with ProcessPoolExecutor(max_workers=args.j, mp_context=multiprocessing.get_context("spawn")) as pool:
future_map = {
pool.submit(eval_mutant, site, site_targets[site.site_id], mutation_lib, args.verbose): site for site in sites
pool.submit(eval_mutant, site, site_targets[site.site_id], mutation_lib, args.verbose, timeout_sec): site for site in sites
}
print_live_status(render_progress(0, len(sites), 0, 0, 0, 0.0))
try:
for fut in as_completed(future_map):
try:
res = fut.result()
except Exception:
site = future_map[fut]
res = MutantResult(site, "killed", 0.0, "worker process crashed")
res = completed_mutant_result(fut, future_map[fut])
results.append(res)
counts[res.outcome] += 1
elapsed_now = time.perf_counter() - start
done = len(results) == len(sites)
print_live_status(render_progress(len(results), len(sites), counts["killed"], counts["survived"],
counts["infra_error"], elapsed_now), final=done)
except Exception:
# Pool broken — mark all unfinished mutants as killed (crash = behavioral change detected)
except Exception as exc:
# An interrupted pool cannot establish which mutations changed behavior.
completed_ids = {r.site.site_id for r in results}
for site in sites:
if site.site_id not in completed_ids:
results.append(MutantResult(site, "killed", 0.0, "pool broken"))
counts["killed"] += 1
results.append(MutantResult(site, "infra_error", 0.0, f"pool interrupted: {type(exc).__name__}: {exc}"))
counts["infra_error"] += 1
elapsed_now = time.perf_counter() - start
print_live_status(render_progress(len(results), len(sites), counts["killed"], counts["survived"], counts["infra_error"], elapsed_now), final=True)
if args.results_json is not None:
write_results_json(args.results_json, discovered_sites=discovered_sites,
pruned_ids=build_incompatible_ids, results=results, site_targets=site_targets,
preprocessed_source=preprocessed_source, baseline_sec=baseline_sec, proven_equivalent=proven_equivalent)
survivors = sorted((r for r in results if r.outcome == "survived"), key=lambda r: r.site.site_id)
if survivors:
print("", flush=True)
@@ -592,6 +936,7 @@ def main():
print(colorize("Mutation summary", ANSI_BOLD), flush=True)
print(f" discovered: {discovered_count}", flush=True)
print(f" pruned_build_incompatible: {pruned_compile_sites}", flush=True)
print(f" proven_equivalent: {len(proven_equivalent)}", flush=True)
print(f" total: {len(sites)}", flush=True)
print(f" killed: {colorize(str(counts['killed']), ANSI_GREEN)}", flush=True)
print(f" survived: {colorize(str(counts['survived']), ANSI_RED)}", flush=True)
@@ -604,14 +949,6 @@ def main():
if counts["infra_error"] > 0:
return 2
# TODO: fix these surviving mutants and delete this block
known_survivors = {
("opendbc/safety/lateral.h", 188, "boundary"),
("opendbc/safety/lateral.h", 218, "boundary"),
("opendbc/safety/lateral.h", 219, "boundary"),
}
survivors = [r for r in survivors if (str(r.site.origin_file.relative_to(ROOT)), r.site.origin_line, r.site.mutator) not in known_survivors]
if survivors:
return 1
return 0
@@ -0,0 +1,21 @@
"""Generic AOL health/default-denial checks before vehicle adapters exist."""
import unittest
from opendbc.car.structs import CarParams
from opendbc.safety.tests.libsafety import libsafety_py
class TestAolDefensiveRxConfiguration(unittest.TestCase):
def test_nooutput_has_no_axis_request_or_permission(self):
safety = libsafety_py.libsafety
self.assertEqual(safety.set_safety_hooks(CarParams.SafetyModel.noOutput, 0), 0)
safety.aol_set_host_request(3)
self.assertEqual(safety.aol_get_request_mask(), 0)
self.assertEqual(safety.aol_get_permission_mask(), 0)
def test_empty_null_low_frequency_and_stale_configs_deny_health(self):
safety = libsafety_py.libsafety
safety.set_timer(1_000_000)
self.assertTrue(safety.safety_test_rx_health_fixture(4, False))
for variant in (0, 1, 2, 3):
with self.subTest(variant=variant):
self.assertFalse(safety.safety_test_rx_health_fixture(variant, False))
@@ -0,0 +1,99 @@
import gc
import subprocess
import tempfile
import unittest
import weakref
from pathlib import Path
from unittest.mock import patch
from opendbc.safety.tests.libsafety import libsafety_py
class TestCanPacketNativeAllocation(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.directory = tempfile.TemporaryDirectory()
cls.addClassCleanup(cls.directory.cleanup)
directory = Path(cls.directory.name)
source = directory / "probe.c"
source.write_text('''#include <stddef.h>
#include <string.h>
#include "can.h"
size_t packet_size(void) { return sizeof(CANPacket_t); }
size_t packet_alignment(void) { return _Alignof(CANPacket_t); }
size_t packet_data_offset(void) { return offsetof(CANPacket_t, data); }
void packet_copy(const CANPacket_t *src, CANPacket_t *out) { *out = *src; }
void packet_fill(CANPacket_t *out) {
memset(out, 0x92, sizeof(*out));
out->addr = 0x123; out->bus = 2; out->data_len_code = 15;
for (unsigned int i = 0; i < 64; ++i) out->data[i] = (unsigned char)i;
}
''')
library = directory / "probe.so"
subprocess.run(["cc", "-shared", "-fPIC", "-std=gnu11", "-O0", "-Wall", "-Wextra", "-Werror",
"-I", str(libsafety_py.safety_dir), str(source), "-o", str(library)], check=True)
cls.ffi = libsafety_py.ffi
cls.ffi.cdef('''size_t packet_size(void); size_t packet_alignment(void); size_t packet_data_offset(void);
void packet_copy(const CANPacket_t *src, CANPacket_t *out); void packet_fill(CANPacket_t *out);''')
cls.native = cls.ffi.dlopen(str(library))
def guarded_packet(self):
original_new = self.ffi.new
buffers = []
native_size = self.native.packet_size()
def allocate(cdecl, words):
self.assertEqual(cdecl, "unsigned int[]")
self.assertEqual(words * self.ffi.sizeof("unsigned int"), native_size)
storage = original_new(cdecl, words + 4)
self.ffi.buffer(storage)[native_size:] = b"\xa5" * 16
buffers.append(storage)
return storage
with patch.object(self.ffi, "new", side_effect=allocate):
packet = libsafety_py.new_CANPacket()
return packet, buffers[0]
def test_allocation_matches_native_size_alignment_and_packed_data_offset(self):
packet, storage = self.guarded_packet()
self.assertEqual(self.ffi.sizeof(storage) - 16, self.native.packet_size())
self.assertEqual(libsafety_py.CAN_PACKET_ALIGNMENT, self.native.packet_alignment())
self.assertEqual(int(self.ffi.cast("uintptr_t", packet)) % self.native.packet_alignment(), 0)
self.assertEqual(self.ffi.offsetof("CANPacket_t", "data"), self.native.packet_data_offset())
def test_native_whole_struct_roundtrip_preserves_output_canary_and_payload(self):
source, source_storage = self.guarded_packet()
output, output_storage = self.guarded_packet()
self.native.packet_fill(source)
self.native.packet_copy(source, output)
self.assertEqual((output.addr, output.bus, output.data_len_code), (0x123, 2, 15))
self.assertEqual(bytes(self.ffi.buffer(output.data)), bytes(range(64)))
size = self.native.packet_size()
self.assertEqual(bytes(self.ffi.buffer(source_storage)[size:]), b"\xa5" * 16)
self.assertEqual(bytes(self.ffi.buffer(output_storage)[size:]), b"\xa5" * 16)
def test_allocator_keeps_backing_storage_until_packet_is_released(self):
packet, storage = self.guarded_packet()
reference = weakref.ref(storage)
del storage
gc.collect()
self.assertIsNotNone(reference())
self.native.packet_fill(packet)
self.assertEqual(packet.data[63], 63)
del packet
gc.collect()
self.assertIsNone(reference())
def test_unrounded_packed_allocation_boundary_is_overwritten_by_native_copy(self):
source = libsafety_py.new_CANPacket()
self.native.packet_fill(source)
packed_size = self.ffi.sizeof("CANPacket_t")
self.assertLess(packed_size, self.native.packet_size())
# Allocate enough physical storage: the historical logical boundary is the canary.
storage = self.ffi.new("unsigned int[]", (self.native.packet_size() + 16) // 4)
self.ffi.buffer(storage)[packed_size:] = b"\xa5" * (self.ffi.sizeof(storage) - packed_size)
output = self.ffi.cast("CANPacket_t *", storage)
self.native.packet_copy(source, output)
self.assertNotEqual(bytes(self.ffi.buffer(storage)[packed_size:self.native.packet_size()]),
b"\xa5" * (self.native.packet_size() - packed_size))
self.assertEqual(bytes(self.ffi.buffer(storage)[self.native.packet_size():]), b"\xa5" * 16)
@@ -0,0 +1,192 @@
from concurrent.futures import Future
import ctypes
from concurrent.futures.process import BrokenProcessPool
import importlib.util
from collections import Counter
import json
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import MagicMock, patch
from opendbc.safety.tests.libsafety import libsafety_py
if importlib.util.find_spec("tree_sitter") and importlib.util.find_spec("tree_sitter_c"):
from opendbc.safety.tests import mutation
else:
mutation = None
class _SkippedClassFixture(unittest.TestCase):
@classmethod
def setUpClass(cls):
raise unittest.SkipTest("fixture class unavailable")
def test_one(self):
pass
def test_two(self):
pass
class _PassingFixture(unittest.TestCase):
def test_one(self):
pass
@unittest.skipIf(mutation is None, "mutation parser dependencies unavailable; full mutation gate requires them")
class TestMutationInfrastructureAccounting(unittest.TestCase):
@staticmethod
def site():
return mutation.MutationSite(1, 0, 1, 0, 1, 1, "==", "!=", "comparison", Path("safety.h"), 1)
def test_static_assert_candidates_remain_accounted_without_runtime_instrumentation(self):
source = mutation.SAFETY_DIR / "helpers.h"
code = '\n'.join((f'#line 1 "{source}"',
'_Static_assert(sizeof(int) == 4, "global word size");',
'int permit(int value) {',
' _Static_assert(sizeof(int) == 4, "local word size");',
' return value <= 3;', '}', ''))
with TemporaryDirectory() as directory:
root = Path(directory)
original = root / "input.c"
original.write_text(code)
sites, _counts, pruned, preprocessed = mutation.enumerate_sites(original, root / "input.i")
static_sites = {site.site_id for site in sites if site.origin_line in (1, 3)}
self.assertTrue(static_sites)
self.assertEqual(pruned, static_sites)
self.assertEqual({site.mutator for site in sites if site.site_id in pruned}, {"comparison", "boundary"})
self.assertEqual({site.site_id for site in sites}, set(range(len(sites))))
executable = [site for site in sites if site.site_id not in pruned]
self.assertTrue(executable)
library = root / "constant-context.so"
mutation.compile_mutated_library(preprocessed, executable, library)
native = ctypes.CDLL(str(library))
native.permit.argtypes, native.permit.restype = [ctypes.c_int], ctypes.c_int
native.mutation_set_active_mutant.argtypes = [ctypes.c_int]
native.mutation_set_active_mutant(-1)
self.assertEqual((native.permit(3), native.permit(4)), (1, 0))
comparison = next(site for site in executable if site.mutator == "comparison")
native.mutation_set_active_mutant(comparison.site_id)
self.assertEqual((native.permit(3), native.permit(4)), (0, 1))
def test_worker_crash_is_infrastructure_error(self):
site = self.site()
future = Future()
future.set_exception(BrokenProcessPool("worker exited"))
result = mutation.completed_mutant_result(future, site)
self.assertEqual(result.outcome, "infra_error")
self.assertIn("BrokenProcessPool", result.details)
def test_failed_safety_assertion_remains_killed(self):
site = self.site()
future = Future()
future.set_result(mutation.MutantResult(site, "killed", 0.1, "test assertion"))
self.assertEqual(mutation.completed_mutant_result(future, site).outcome, "killed")
def test_full_baseline_failure_aborts_before_mutant_targets(self):
catalog = {"test_defaults.py": ["test_defaults.TestNoOutput.test_tx_hook"]}
with patch.object(mutation, "run_unittest", return_value=mutation.TestRun(1, 0, 0, (catalog["test_defaults.py"][0],), ())) as baseline, \
patch.object(mutation, "build_priority_tests") as targets:
with self.assertRaisesRegex(RuntimeError, "unmutated full safety baseline failed"):
mutation.require_full_baseline(catalog, [self.site()], Path("libsafety.so"), False)
baseline.assert_called_once_with(catalog["test_defaults.py"], Path("libsafety.so"), mutant_id=-1, verbose=False)
targets.assert_not_called()
def test_empty_target_is_not_treated_as_surviving_mutant(self):
catalog = {"test_defaults.py": ["test_defaults.TestNoOutput.test_tx_hook"]}
with patch.object(mutation, "run_unittest", return_value=mutation.TestRun(1, 0, 0, (), ())), \
patch.object(mutation, "build_priority_tests", return_value=[]):
with self.assertRaisesRegex(RuntimeError, "mutants lack test targets"):
mutation.require_full_baseline(catalog, [self.site()], Path("libsafety.so"), False)
def test_unittest_error_does_not_count_as_kill(self):
with patch.object(mutation, "_run_isolated_unittest", return_value=mutation.TestRun(1, 0, 0, (), ("fixture.ImportError",))):
result = mutation.eval_mutant(self.site(), ["test_defaults.TestNoOutput.test_tx_hook"], Path("libsafety.so"), False, 60)
self.assertEqual(result.outcome, "infra_error")
def test_isolated_worker_timeout_is_infrastructure_error(self):
with patch.object(mutation, "_run_isolated_unittest", side_effect=RuntimeError("isolated mutant timed out")):
result = mutation.eval_mutant(self.site(), ["test_defaults.TestNoOutput.test_tx_hook"], Path("libsafety.so"), False, 60)
self.assertEqual(result.outcome, "infra_error")
self.assertIn("timed out", result.details)
def test_partial_or_all_skipped_baseline_aborts(self):
catalog = {"test_defaults.py": ["test.one", "test.two"]}
for run in (mutation.TestRun(1, 0, 0, (), ()), mutation.TestRun(2, 2, 0, (), ())):
with self.subTest(run=run), patch.object(mutation, "run_unittest", return_value=run):
with self.assertRaisesRegex(RuntimeError, "unmutated full safety baseline failed"):
mutation.require_full_baseline(catalog, [self.site()], Path("libsafety.so"), False)
def test_class_level_skip_suppresses_exact_method_ids(self):
targets = [f"{__name__}._SkippedClassFixture.test_one", f"{__name__}._SkippedClassFixture.test_two",
f"{__name__}._SkippedClassFixture.test_two",
f"{__name__}._PassingFixture.test_one"]
safety = MagicMock()
with patch.object(libsafety_py, "load"), patch.dict(libsafety_py.__dict__, {"libsafety": safety}):
result = mutation.run_unittest(targets, Path("unused.so"), -1, False)
safety.mutation_set_active_mutant.assert_called_once_with(-1)
self.assertEqual((result.ran, result.skipped, result.suppressed, result.failures, result.errors), (1, 0, 3, (), ()))
def test_family_targets_include_new_ioniq_and_carnival_suites(self):
catalog = {"test_hyundai.py": ["base"], "test_hyundai_canfd.py": ["canfd"],
"test_hyundai_ioniq6_long.py": ["ioniq_long"], "test_hyundai_canfd_carnival.py": ["carnival"],
"test_honda_aol_family.py": ["honda_aol"]}
hyundai = mutation.MutationSite(1, 0, 1, 0, 1, 1, "==", "!=", "comparison",
mutation.ROOT / "opendbc/safety/modes/hyundai_canfd.h", 1)
targets = mutation.build_priority_tests(hyundai, catalog, [])
self.assertEqual(set(targets), {"base", "canfd", "ioniq_long", "carnival"})
def test_core_priority_preserves_duplicate_method_implementations(self):
catalog = {"test_one.py": ["A.test_rx", "B.test_rx", "A.test_tx"],
"test_two.py": ["C.test_rx", "D.test_tx"]}
self.assertEqual(Counter(mutation._build_core_tests(catalog)), Counter(test for ids in catalog.values() for test in ids))
def test_isolated_signal_keeps_bounded_full_stderr_and_exit_code(self):
child = MagicMock()
child.returncode = -11
child.communicate.return_value = ("partial child output", "first warning\n" + "native backtrace\n" * 100)
with patch.object(mutation.subprocess, "Popen", return_value=child) as launch:
with self.assertRaises(mutation.IsolatedRunError) as raised:
mutation._run_isolated_unittest(1, ["target.test"], Path("libsafety.so"), 60)
self.assertEqual(launch.call_args.kwargs["env"]["PWD"], str(mutation.ROOT))
self.assertEqual(raised.exception.kind, "signal")
self.assertEqual(raised.exception.exit_code, -11)
self.assertIn("native backtrace\n", raised.exception.stderr_tail)
self.assertEqual(raised.exception.stdout_tail, "partial child output")
with patch.object(mutation, "_run_isolated_unittest", side_effect=raised.exception):
result = mutation.eval_mutant(self.site(), ["target.test"], Path("libsafety.so"), False, 60)
self.assertEqual(result.outcome, "infra_error")
self.assertEqual((result.failure_kind, result.exit_code), ("signal", -11))
self.assertIn("native backtrace\n", result.stderr_tail)
def test_machine_artifact_preserves_source_targets_and_diagnostics(self):
source = mutation.ROOT / "opendbc/safety/safety.h"
site = mutation.MutationSite(7, 0, 1, 0, 1, 1, "++", "--", "increment", source, 36)
pruned = mutation.MutationSite(8, 0, 1, 0, 1, 1, "0", "1", "boundary", source, 37)
result = mutation.MutantResult(site, "infra_error", 0.1, "isolated mutant exit=-11",
failure_kind="signal", exit_code=-11,
stderr_tail="warning\nfull native backtrace\n", stdout_tail="partial")
with TemporaryDirectory() as directory:
path = Path(directory) / "nested" / "results.json"
mutation.write_results_json(path, discovered_sites=[site, pruned], pruned_ids={8}, results=[result],
site_targets={7: ["one.test", "two.test"]},
preprocessed_source="frozen preprocessed C", baseline_sec=2.5)
artifact = json.loads(path.read_text())
self.assertEqual((artifact["discovered"], len(artifact["pruned_build_incompatible"])), (2, 1))
self.assertEqual(artifact["target_sets"][artifact["results"][0]["selected_test_set"]], ["one.test", "two.test"])
self.assertEqual(artifact["results"][0]["source"], "opendbc/safety/safety.h")
self.assertEqual(artifact["results"][0]["exit_code"], -11)
self.assertEqual(artifact["results"][0]["stderr_tail"], "warning\nfull native backtrace\n")
self.assertEqual(artifact["results"][0]["outcome"], "infra_error")
with TemporaryDirectory() as directory:
with self.assertRaisesRegex(RuntimeError, "one outcome for every executed site"):
mutation.write_results_json(Path(directory) / "incomplete.json", discovered_sites=[site],
pruned_ids=set(), results=[], site_targets={},
preprocessed_source="frozen preprocessed C", baseline_sec=2.5)
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -0,0 +1,20 @@
#pragma once
// IRQs: CAN1_TX, CAN1_RX0, CAN1_SCE
// CAN2_TX, CAN2_RX0, CAN2_SCE
// CAN3_TX, CAN3_RX0, CAN3_SCE
#define CAN_ARRAY_SIZE 3
#define CAN_IRQS_ARRAY_SIZE 3
extern CAN_TypeDef *cans[CAN_ARRAY_SIZE];
extern uint8_t can_irq_number[CAN_IRQS_ARRAY_SIZE][CAN_IRQS_ARRAY_SIZE];
bool can_set_speed(uint8_t can_number);
void can_clear_send(CAN_TypeDef *CANx, uint8_t can_number);
void update_can_health_pkt(uint8_t can_number, uint32_t ir_reg);
// ***************************** CAN *****************************
// CANx_TX IRQ Handler
// CANx_RX0 IRQ Handler
// blink blue when we are receiving CAN messages
void can_rx(uint8_t can_number);
+13 -5
View File
@@ -3,7 +3,11 @@
#include "board/can.h"
#include "board/health.h"
#include "board/crc.h"
#ifdef STM32H7
#include "opendbc/safety/can_tx.h"
#ifdef STM32F4
#include "board/stm32f4/lladc_declarations.h"
#include "board/drivers/bxcan_declarations.h"
#elif defined(STM32H7)
#include "board/stm32h7/lladc_declarations.h"
#endif
@@ -11,7 +15,7 @@
extern bool bootkick_reset_triggered;
void bootkick_tick(bool ignition, bool recent_heartbeat);
void bootkick_tick(bool ignition, bool recent_heartbeat, bool wake);
// ******************** can_common ********************
@@ -43,6 +47,12 @@ extern can_health_t can_health[PANDA_CAN_CNT];
// Ignition detected from CAN messages
extern bool ignition_can;
extern uint32_t ignition_can_cnt;
extern bool wake_on_can;
extern uint32_t wake_on_can_cnt;
#ifdef PANDA_HKG_REMOTE_START
extern bool hkg_remote_climate_wake;
extern uint32_t hkg_remote_climate_wake_cnt;
#endif
extern bool can_silent;
extern bool can_loopback;
@@ -71,9 +81,7 @@ extern bus_config_t bus_config[PANDA_CAN_CNT];
void can_init_all(void);
void can_set_orientation(bool flipped);
bool can_tx_check_min_slots_free(uint32_t min);
void can_set_checksum(CANPacket_t *packet);
bool can_check_checksum(CANPacket_t *packet);
void can_send(CANPacket_t *to_push, uint8_t bus_number, bool skip_tx_hook);
bool is_speed_valid(uint32_t speed, const uint32_t *all_speeds, uint8_t len);
// ******************** clock_source ********************
@@ -112,6 +120,7 @@ extern FDCAN_GlobalTypeDef *cans[PANDA_CAN_CNT];
void can_clear_send(FDCAN_GlobalTypeDef *FDCANx, uint8_t can_number);
void update_can_health_pkt(uint8_t can_number, uint32_t ir_reg);
#endif
// ******************** harness ********************
@@ -176,7 +185,6 @@ void handle_interrupt(IRQn_Type irq_type);
void interrupt_timer_handler(void);
void init_interrupts(bool check_rate_limit);
#endif // STM32H7
// ******************** registers ********************
+12
View File
@@ -10,6 +10,18 @@
#define HEALTH_FLAG_NMI_RESET (1U << 7)
#define HEALTH_FLAG_HARDFAULT_RESET ((uint16_t)1U << 8)
#define AOL_SAFETY_PROTOCOL_MAGIC 0x314C4F41U // "AOL1", little endian
#define AOL_SAFETY_PROTOCOL_VERSION 1U
typedef struct __attribute__((packed)) {
uint32_t magic;
uint8_t version;
uint8_t request_mask;
uint8_t permission_mask;
uint8_t safety_mode;
uint16_t safety_param;
uint8_t capability_flags;
} aol_safety_health_t;
struct __attribute__((packed)) health_t {
uint32_t uptime_pkt;
uint16_t voltage_pkt;
+39 -2
View File
@@ -1,5 +1,14 @@
#pragma once
static bool panda_ignition_line(void) {
#ifdef PANDA_IGNORE_IGNITION_LINE
return false;
#else
return harness_check_ignition();
#endif
}
extern int _app_start[0xc000]; // Only first 3 sectors of size 0x4000 are used
// Prototypes
@@ -15,7 +24,7 @@ static int get_health_pkt(void *dat) {
health->current_pkt = (uint16_t)MIN(current_board->read_current_mA(), 0xFFFFU);
health->flags_pkt = 0U;
health->flags_pkt |= harness_check_ignition() ? HEALTH_FLAG_IGNITION_LINE : 0U;
health->flags_pkt |= panda_ignition_line() ? HEALTH_FLAG_IGNITION_LINE : 0U;
health->flags_pkt |= ignition_can ? HEALTH_FLAG_IGNITION_CAN : 0U;
health->flags_pkt |= controls_allowed ? HEALTH_FLAG_CONTROLS_ALLOWED : 0U;
health->flags_pkt |= power_save_enabled ? HEALTH_FLAG_POWER_SAVE_ENABLED : 0U;
@@ -48,8 +57,12 @@ static int get_health_pkt(void *dat) {
health->sound_output_level_pkt = sound_output_level;
#ifdef STM32H7
float temperature_encoded = (CLAMP(dts_get_temperature(), -40.0f, 214.5f) + 40.0f) + 0.5f;
health->temperature_pkt = (uint8_t)temperature_encoded;
#else
health->temperature_pkt = 0U;
#endif
return sizeof(*health);
}
@@ -65,6 +78,25 @@ int comms_control_handler(ControlPacket_t *req, uint8_t *resp) {
uint32_t time;
switch (req->request) {
// Fork-local read-only AOL capability and actual native permissions.
// Older firmware returns no matching packet, so the host fails closed.
case 0xd5:
{
aol_safety_health_t status = {
.magic = AOL_SAFETY_PROTOCOL_MAGIC,
.version = AOL_SAFETY_PROTOCOL_VERSION,
.request_mask = aol_get_request_mask(),
.permission_mask = aol_get_permission_mask(),
.safety_mode = (uint8_t)current_safety_mode,
.safety_param = current_safety_param,
.capability_flags = (aol_policy != NULL) ? (0x1U | (aol_optional_release_capable ? 0x2U : 0U)) : 0U,
};
for (unsigned int i = 0U; i < sizeof(status); i++) {
resp[i] = ((uint8_t *)&status)[i];
}
resp_len = sizeof(status);
}
break;
// **** 0xa8: get microsecond timer
case 0xa8:
time = microsecond_timer_get();
@@ -89,7 +121,7 @@ int comms_control_handler(ControlPacket_t *req, uint8_t *resp) {
resp_len = 2;
break;
// **** 0xb5: request deep sleep, wakes on CAN or SBU
#ifdef ALLOW_DEBUG
#if defined(ALLOW_DEBUG) && defined(STM32H7)
case 0xb5:
set_safety_mode(SAFETY_SILENT, 0U);
set_power_save_state(true);
@@ -293,6 +325,11 @@ int comms_control_handler(ControlPacket_t *req, uint8_t *resp) {
heartbeat_disabled = true;
}
break;
// A separate short-lived axis request leaves the legacy heartbeat protocol
// unchanged for every car without the exact Honda AOL safety parameter.
case 0xf5:
aol_set_host_request((uint8_t)req->param1);
break;
// **** 0xf9: set CAN FD data bitrate
case 0xf9:
if ((req->param1 < PANDA_CAN_CNT) &&
+20
View File
@@ -0,0 +1,20 @@
#pragma once
typedef enum {
SAMPLETIME_3_CYCLES = 0,
SAMPLETIME_15_CYCLES = 1,
SAMPLETIME_28_CYCLES = 2,
SAMPLETIME_56_CYCLES = 3,
SAMPLETIME_84_CYCLES = 4,
SAMPLETIME_112_CYCLES = 5,
SAMPLETIME_144_CYCLES = 6,
SAMPLETIME_480_CYCLES = 7
} adc_sample_time_t;
typedef struct {
ADC_TypeDef *adc;
uint8_t channel;
adc_sample_time_t sample_time;
} adc_signal_t;
#define ADC_CHANNEL_DEFAULT(a, c) {.adc = (a), .channel = (c), .sample_time = SAMPLETIME_480_CYCLES}
+3
View File
@@ -58,5 +58,8 @@ void fault_recovered(uint32_t fault);
// ******************** power_saving ********************
extern bool power_save_enabled;
#if defined(ALLOW_DEBUG) && defined(STM32H7)
extern volatile bool stop_mode_requested;
#endif
void set_power_save_state(bool enable);