diff --git a/opendbc_repo/opendbc/safety/__init__.py b/opendbc_repo/opendbc/safety/__init__.py index 5d4c2464d4..250f943292 100644 --- a/opendbc_repo/opendbc/safety/__init__.py +++ b/opendbc_repo/opendbc/safety/__init__.py @@ -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 diff --git a/opendbc_repo/opendbc/safety/can_tx.h b/opendbc_repo/opendbc/safety/can_tx.h new file mode 100644 index 0000000000..bd6a4446f4 --- /dev/null +++ b/opendbc_repo/opendbc/safety/can_tx.h @@ -0,0 +1,9 @@ +#pragma once + +#include +#include + +#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); diff --git a/opendbc_repo/opendbc/safety/declarations.h b/opendbc_repo/opendbc/safety/declarations.h index ab753cc692..e400a23fc8 100644 --- a/opendbc_repo/opendbc/safety/declarations.h +++ b/opendbc_repo/opendbc/safety/declarations.h @@ -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; diff --git a/opendbc_repo/opendbc/safety/lateral.h b/opendbc_repo/opendbc/safety/lateral.h index 7da95cf457..8803b46089 100644 --- a/opendbc_repo/opendbc/safety/lateral.h +++ b/opendbc_repo/opendbc/safety/lateral.h @@ -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); } diff --git a/opendbc_repo/opendbc/safety/longitudinal.h b/opendbc_repo/opendbc/safety/longitudinal.h index 4abc89085d..fdc35502bd 100644 --- a/opendbc_repo/opendbc/safety/longitudinal.h +++ b/opendbc_repo/opendbc/safety/longitudinal.h @@ -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 diff --git a/opendbc_repo/opendbc/safety/modes/rivian.h b/opendbc_repo/opendbc/safety/modes/rivian.h index 58f317228a..43c8c3df1d 100644 --- a/opendbc_repo/opendbc/safety/modes/rivian.h +++ b/opendbc_repo/opendbc/safety/modes/rivian.h @@ -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 diff --git a/opendbc_repo/opendbc/safety/safety.h b/opendbc_repo/opendbc/safety/safety.h index ae0e298534..762d40db91 100644 --- a/opendbc_repo/opendbc/safety/safety.h +++ b/opendbc_repo/opendbc/safety/safety.h @@ -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 = ¤t_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 = ¤t_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; diff --git a/opendbc_repo/opendbc/safety/tests/common.py b/opendbc_repo/opendbc/safety/tests/common.py index 3c90d6fafc..7a3d71868c 100644 --- a/opendbc_repo/opendbc/safety/tests/common.py +++ b/opendbc_repo/opendbc/safety/tests/common.py @@ -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: diff --git a/opendbc_repo/opendbc/safety/tests/libsafety/libsafety_py.py b/opendbc_repo/opendbc/safety/tests/libsafety/libsafety_py.py index 4910f9b1f0..79f94afc0c 100644 --- a/opendbc_repo/opendbc/safety/tests/libsafety/libsafety_py.py +++ b/opendbc_repo/opendbc/safety/tests/libsafety/libsafety_py.py @@ -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)] diff --git a/opendbc_repo/opendbc/safety/tests/libsafety/safety.c b/opendbc_repo/opendbc/safety/tests/libsafety/safety.c index fabb25e437..d933612c2b 100644 --- a/opendbc_repo/opendbc/safety/tests/libsafety/safety.c +++ b/opendbc_repo/opendbc/safety/tests/libsafety/safety.c @@ -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 : ✓ + 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; } diff --git a/opendbc_repo/opendbc/safety/tests/misra/main.c b/opendbc_repo/opendbc/safety/tests/misra/main.c index 1d4d958372..936a41727b 100644 --- a/opendbc_repo/opendbc/safety/tests/misra/main.c +++ b/opendbc_repo/opendbc/safety/tests/misra/main.c @@ -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); diff --git a/opendbc_repo/opendbc/safety/tests/mutation.py b/opendbc_repo/opendbc/safety/tests/mutation.py index 88d05b61e7..baa2ae124c 100755 --- a/opendbc_repo/opendbc/safety/tests/mutation.py +++ b/opendbc_repo/opendbc/safety/tests/mutation.py @@ -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_.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 diff --git a/opendbc_repo/opendbc/safety/tests/test_aol_defensive_config.py b/opendbc_repo/opendbc/safety/tests/test_aol_defensive_config.py new file mode 100644 index 0000000000..afe644a982 --- /dev/null +++ b/opendbc_repo/opendbc/safety/tests/test_aol_defensive_config.py @@ -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)) diff --git a/opendbc_repo/opendbc/safety/tests/test_canpacket_abi.py b/opendbc_repo/opendbc/safety/tests/test_canpacket_abi.py new file mode 100644 index 0000000000..bdf83f9ba3 --- /dev/null +++ b/opendbc_repo/opendbc/safety/tests/test_canpacket_abi.py @@ -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 +#include +#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) diff --git a/opendbc_repo/opendbc/safety/tests/test_mutation_infra.py b/opendbc_repo/opendbc/safety/tests/test_mutation_infra.py new file mode 100644 index 0000000000..9c07a8eb81 --- /dev/null +++ b/opendbc_repo/opendbc/safety/tests/test_mutation_infra.py @@ -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() diff --git a/panda/board/drivers/bxcan_declarations.h b/panda/board/drivers/bxcan_declarations.h new file mode 100644 index 0000000000..7d560d3c17 --- /dev/null +++ b/panda/board/drivers/bxcan_declarations.h @@ -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); diff --git a/panda/board/drivers/drivers.h b/panda/board/drivers/drivers.h index 4b187a0e52..d8cefe9787 100644 --- a/panda/board/drivers/drivers.h +++ b/panda/board/drivers/drivers.h @@ -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 ******************** diff --git a/panda/board/health.h b/panda/board/health.h index 69e2630bf0..018ef2a06f 100644 --- a/panda/board/health.h +++ b/panda/board/health.h @@ -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; diff --git a/panda/board/main_comms.h b/panda/board/main_comms.h index 8c75ed4772..1d6aeed68e 100644 --- a/panda/board/main_comms.h +++ b/panda/board/main_comms.h @@ -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) && diff --git a/panda/board/stm32f4/lladc_declarations.h b/panda/board/stm32f4/lladc_declarations.h new file mode 100644 index 0000000000..64a733a90b --- /dev/null +++ b/panda/board/stm32f4/lladc_declarations.h @@ -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} diff --git a/panda/board/sys/sys.h b/panda/board/sys/sys.h index 0907cf1e10..19e5f38f7a 100644 --- a/panda/board/sys/sys.h +++ b/panda/board/sys/sys.h @@ -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);