diff --git a/build b/build index d8da0a3ba5..ef4a69a7b7 100755 --- a/build +++ b/build @@ -27,6 +27,10 @@ add_panda_targets() { panda_h7_remote_can_ignition_only panda_hkg_remote_can_ignition_only panda_h7_hkg_remote_can_ignition_only + panda_tesla_wake + panda_h7_tesla_wake + panda_tesla_wake_can_ignition_only + panda_h7_tesla_wake_can_ignition_only panda_jungle_h7 body_h7 ) diff --git a/common/libcommon.a b/common/libcommon.a index 4f3cdf222d..28e8aeaa94 100644 Binary files a/common/libcommon.a and b/common/libcommon.a differ diff --git a/common/params_keys.h b/common/params_keys.h index d13b0e87b0..dcd46dbd6a 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -362,6 +362,7 @@ inline static std::unordered_map keys = { {"IgnoreIgnitionLine", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}}, {"LongPitch", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}}, {"RemoteStartBootsComma", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}}, + {"TeslaWakeOnCAN", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}}, {"RemapCancelToDistance", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}}, {"NAPAdaptiveAccel", {PERSISTENT, BOOL, "1", "1", 0, SETTINGS_SIMPLE}}, {"NAPFollowDistance", {PERSISTENT, INT, "4", "4"}}, diff --git a/common/params_pyx.so b/common/params_pyx.so index 34893246f4..2bce85cc71 100755 Binary files a/common/params_pyx.so and b/common/params_pyx.so differ diff --git a/panda/SConscript b/panda/SConscript index bcc4899f35..ee9184d0da 100644 --- a/panda/SConscript +++ b/panda/SConscript @@ -181,6 +181,11 @@ build_project("panda_h7_remote_can_ignition_only", base_project_h7, "./board/mai build_project("panda_hkg_remote_can_ignition_only", base_project_f4, "./board/main.c", ["-DPANDA_HKG_REMOTE_START", "-DPANDA_IGNORE_IGNITION_LINE"]) build_project("panda_h7_hkg_remote_can_ignition_only", base_project_h7, "./board/main.c", ["-DPANDA_HKG_REMOTE_START", "-DPANDA_IGNORE_IGNITION_LINE"]) +build_project("panda_tesla_wake", base_project_f4, "./board/main.c", ["-DPANDA_TESLA_WAKE_ON_CAN"]) +build_project("panda_h7_tesla_wake", base_project_h7, "./board/main.c", ["-DPANDA_TESLA_WAKE_ON_CAN"]) +build_project("panda_tesla_wake_can_ignition_only", base_project_f4, "./board/main.c", ["-DPANDA_TESLA_WAKE_ON_CAN", "-DPANDA_IGNORE_IGNITION_LINE"]) +build_project("panda_h7_tesla_wake_can_ignition_only", base_project_h7, "./board/main.c", ["-DPANDA_TESLA_WAKE_ON_CAN", "-DPANDA_IGNORE_IGNITION_LINE"]) + # panda jungle fw flags = [ "-DPANDA_JUNGLE", diff --git a/panda/board/drivers/bootkick.h b/panda/board/drivers/bootkick.h index eab7da3ebc..94ec0d6d00 100644 --- a/panda/board/drivers/bootkick.h +++ b/panda/board/drivers/bootkick.h @@ -2,18 +2,18 @@ bool bootkick_reset_triggered = false; -void bootkick_tick(bool ignition, bool recent_heartbeat) { +void bootkick_tick(bool ignition, bool recent_heartbeat, bool wake) { static uint16_t bootkick_last_serial_ptr = 0; static uint8_t waiting_to_boot_countdown = 0; static uint8_t boot_reset_countdown = 0; static uint8_t bootkick_harness_status_prev = HARNESS_STATUS_NC; static bool bootkick_ign_prev = false; + static bool bootkick_wake_prev = false; static BootState boot_state = BOOT_BOOTKICK; BootState boot_state_prev = boot_state; const bool harness_inserted = (harness.status != bootkick_harness_status_prev) && (harness.status != HARNESS_STATUS_NC); - if ((ignition && !bootkick_ign_prev) || harness_inserted) { - // bootkick on rising edge of ignition or harness insertion + if ((ignition && !bootkick_ign_prev) || harness_inserted || (wake && !bootkick_wake_prev && !ignition)) { boot_state = BOOT_BOOTKICK; } else if (recent_heartbeat) { // disable bootkick once openpilot is up @@ -56,6 +56,7 @@ void bootkick_tick(bool ignition, bool recent_heartbeat) { // update state bootkick_ign_prev = ignition; + bootkick_wake_prev = wake; bootkick_harness_status_prev = harness.status; bootkick_last_serial_ptr = uart_ring_som_debug.w_ptr_tx; if (waiting_to_boot_countdown > 0U) { diff --git a/panda/board/drivers/bootkick_declarations.h b/panda/board/drivers/bootkick_declarations.h index 3b55b72ac6..d9f9f42c5e 100644 --- a/panda/board/drivers/bootkick_declarations.h +++ b/panda/board/drivers/bootkick_declarations.h @@ -2,4 +2,4 @@ extern bool bootkick_reset_triggered; -void bootkick_tick(bool ignition, bool recent_heartbeat); +void bootkick_tick(bool ignition, bool recent_heartbeat, bool wake); diff --git a/panda/board/drivers/can_common.h b/panda/board/drivers/can_common.h index 0b551dc1b8..1e3b05e6f4 100644 --- a/panda/board/drivers/can_common.h +++ b/panda/board/drivers/can_common.h @@ -7,7 +7,9 @@ uint32_t rx_buffer_overflow = 0; can_health_t can_health[PANDA_CAN_CNT] = {{0}, {0}, {0}}; -// Ignition detected from CAN meessages +bool wake_on_can = false; +uint32_t wake_on_can_cnt = 0U; + bool ignition_can = false; uint32_t ignition_can_cnt = 0U; #ifdef PANDA_HKG_REMOTE_START @@ -225,6 +227,23 @@ void ignition_can_hook(CANPacket_t *msg) { ignition_can_cnt = 0U; } prev_counter_tesla = counter; + + #ifdef PANDA_TESLA_WAKE_ON_CAN + uint32_t checksum = (msg->addr & 0xFFU) + (msg->addr >> 8U); + for (uint8_t i = 0U; i < 7U; i++) { + checksum += msg->data[i]; + } + static int prev_counter_tesla_wake = -1; + if (!msg->extended && (msg->data[7] == (checksum & 0xFFU))) { + if ((prev_counter_tesla_wake != -1) && (counter == ((prev_counter_tesla_wake + 1) % 16))) { + wake_on_can = ((msg->data[0] >> 5U) & 0x3U) != 0U; + wake_on_can_cnt = 0U; + } + prev_counter_tesla_wake = counter; + } else { + prev_counter_tesla_wake = -1; + } + #endif } // Tesla Model S pre-AP exception diff --git a/panda/board/drivers/can_common_declarations.h b/panda/board/drivers/can_common_declarations.h index 75580b7b64..3181fd88da 100644 --- a/panda/board/drivers/can_common_declarations.h +++ b/panda/board/drivers/can_common_declarations.h @@ -28,7 +28,9 @@ extern uint32_t rx_buffer_overflow; extern can_health_t can_health[PANDA_CAN_CNT]; -// Ignition detected from CAN meessages +extern bool wake_on_can; +extern uint32_t wake_on_can_cnt; + extern bool ignition_can; extern uint32_t ignition_can_cnt; diff --git a/panda/board/main.c b/panda/board/main.c index 284a4e392b..eacef07c59 100644 --- a/panda/board/main.c +++ b/panda/board/main.c @@ -192,7 +192,7 @@ static void tick_handler(void) { #ifdef PANDA_HKG_REMOTE_START started = started || hkg_remote_climate_wake; #endif - bootkick_tick(started, recent_heartbeat); + bootkick_tick(started, recent_heartbeat, wake_on_can); // increase heartbeat counter and cap it at the uint32 limit if (heartbeat_counter < UINT32_MAX) { @@ -270,6 +270,9 @@ static void tick_handler(void) { if (ignition_can_cnt > 2U) { ignition_can = false; } + if (wake_on_can_cnt > 2U) { + wake_on_can = false; + } #ifdef PANDA_HKG_REMOTE_START if (hkg_remote_climate_wake_cnt > 2U) { hkg_remote_climate_wake = false; @@ -280,6 +283,7 @@ static void tick_handler(void) { uptime_cnt += 1U; safety_mode_cnt += 1U; ignition_can_cnt += 1U; + wake_on_can_cnt += 1U; #ifdef PANDA_HKG_REMOTE_START hkg_remote_climate_wake_cnt += 1U; #endif diff --git a/panda/board/obj/gitversion.h b/panda/board/obj/gitversion.h index 0ed68c9f3b..e07b46d286 100644 --- a/panda/board/obj/gitversion.h +++ b/panda/board/obj/gitversion.h @@ -1,2 +1,2 @@ extern const uint8_t gitversion[19]; -const uint8_t gitversion[19] = "DEV-244aa673-DEBUG"; +const uint8_t gitversion[19] = "DEV-9c92f717-DEBUG"; diff --git a/panda/board/obj/panda.bin.signed b/panda/board/obj/panda.bin.signed index 53c3f2edcd..8fcd5533ac 100644 Binary files a/panda/board/obj/panda.bin.signed and b/panda/board/obj/panda.bin.signed differ diff --git a/panda/board/obj/panda_can_ignition_only.bin.signed b/panda/board/obj/panda_can_ignition_only.bin.signed index 90553b02ee..8c50207dee 100644 Binary files a/panda/board/obj/panda_can_ignition_only.bin.signed and b/panda/board/obj/panda_can_ignition_only.bin.signed differ diff --git a/panda/board/obj/panda_h7.bin.signed b/panda/board/obj/panda_h7.bin.signed index 3878db8f2c..cf554cd402 100644 Binary files a/panda/board/obj/panda_h7.bin.signed and b/panda/board/obj/panda_h7.bin.signed differ diff --git a/panda/board/obj/panda_h7_can_ignition_only.bin.signed b/panda/board/obj/panda_h7_can_ignition_only.bin.signed index 79f3838c32..5a9b7e34a2 100644 Binary files a/panda/board/obj/panda_h7_can_ignition_only.bin.signed and b/panda/board/obj/panda_h7_can_ignition_only.bin.signed differ diff --git a/panda/board/obj/panda_h7_hkg_remote.bin.signed b/panda/board/obj/panda_h7_hkg_remote.bin.signed index 839d05f839..2dd63291e2 100644 Binary files a/panda/board/obj/panda_h7_hkg_remote.bin.signed and b/panda/board/obj/panda_h7_hkg_remote.bin.signed differ diff --git a/panda/board/obj/panda_h7_hkg_remote_can_ignition_only.bin.signed b/panda/board/obj/panda_h7_hkg_remote_can_ignition_only.bin.signed index 9283c8c4da..5e15be0b86 100644 Binary files a/panda/board/obj/panda_h7_hkg_remote_can_ignition_only.bin.signed and b/panda/board/obj/panda_h7_hkg_remote_can_ignition_only.bin.signed differ diff --git a/panda/board/obj/panda_h7_remote.bin.signed b/panda/board/obj/panda_h7_remote.bin.signed index 403e5810a2..e1f347816c 100644 Binary files a/panda/board/obj/panda_h7_remote.bin.signed and b/panda/board/obj/panda_h7_remote.bin.signed differ diff --git a/panda/board/obj/panda_h7_remote_can_ignition_only.bin.signed b/panda/board/obj/panda_h7_remote_can_ignition_only.bin.signed index fddbec6f8e..9bd925814c 100644 Binary files a/panda/board/obj/panda_h7_remote_can_ignition_only.bin.signed and b/panda/board/obj/panda_h7_remote_can_ignition_only.bin.signed differ diff --git a/panda/board/obj/panda_h7_tesla_wake.bin.signed b/panda/board/obj/panda_h7_tesla_wake.bin.signed new file mode 100644 index 0000000000..cfff612820 Binary files /dev/null and b/panda/board/obj/panda_h7_tesla_wake.bin.signed differ diff --git a/panda/board/obj/panda_h7_tesla_wake_can_ignition_only.bin.signed b/panda/board/obj/panda_h7_tesla_wake_can_ignition_only.bin.signed new file mode 100644 index 0000000000..eb425facf3 Binary files /dev/null and b/panda/board/obj/panda_h7_tesla_wake_can_ignition_only.bin.signed differ diff --git a/panda/board/obj/panda_hkg_remote.bin.signed b/panda/board/obj/panda_hkg_remote.bin.signed index 36ef5e50ab..6b9c341202 100644 Binary files a/panda/board/obj/panda_hkg_remote.bin.signed and b/panda/board/obj/panda_hkg_remote.bin.signed differ diff --git a/panda/board/obj/panda_hkg_remote_can_ignition_only.bin.signed b/panda/board/obj/panda_hkg_remote_can_ignition_only.bin.signed index dad735ed38..65eca6127d 100644 Binary files a/panda/board/obj/panda_hkg_remote_can_ignition_only.bin.signed and b/panda/board/obj/panda_hkg_remote_can_ignition_only.bin.signed differ diff --git a/panda/board/obj/panda_remote.bin.signed b/panda/board/obj/panda_remote.bin.signed index 8dd5190492..ce1af6f653 100644 Binary files a/panda/board/obj/panda_remote.bin.signed and b/panda/board/obj/panda_remote.bin.signed differ diff --git a/panda/board/obj/panda_remote_can_ignition_only.bin.signed b/panda/board/obj/panda_remote_can_ignition_only.bin.signed index 889553b898..a23e728156 100644 Binary files a/panda/board/obj/panda_remote_can_ignition_only.bin.signed and b/panda/board/obj/panda_remote_can_ignition_only.bin.signed differ diff --git a/panda/board/obj/panda_tesla_wake.bin.signed b/panda/board/obj/panda_tesla_wake.bin.signed new file mode 100644 index 0000000000..6d2facdba3 Binary files /dev/null and b/panda/board/obj/panda_tesla_wake.bin.signed differ diff --git a/panda/board/obj/panda_tesla_wake_can_ignition_only.bin.signed b/panda/board/obj/panda_tesla_wake_can_ignition_only.bin.signed new file mode 100644 index 0000000000..c3fbd72a91 Binary files /dev/null and b/panda/board/obj/panda_tesla_wake_can_ignition_only.bin.signed differ diff --git a/panda/board/obj/version b/panda/board/obj/version index 458314896e..2c00629511 100644 --- a/panda/board/obj/version +++ b/panda/board/obj/version @@ -1 +1 @@ -DEV-244aa673-DEBUG \ No newline at end of file +DEV-9c92f717-DEBUG \ No newline at end of file diff --git a/panda/tests/wake_can/README.md b/panda/tests/wake_can/README.md new file mode 100644 index 0000000000..23b550b492 --- /dev/null +++ b/panda/tests/wake_can/README.md @@ -0,0 +1,83 @@ +# Optional Tesla wake-on-CAN + +The Vehicle settings page offers **Wake Comma with Tesla** for detected Tesla Model 3, +Model Y and Model X platforms that use `VCFRONT_LVPowerState` on the party bus. +It is hidden for other makes, unknown vehicles and pre-AP Tesla Model S. +`TeslaWakeOnCAN` is a persistent boolean, default off. Changing it while parked +uses the existing confirmed Panda firmware update/reboot flow. + +## Wake and ignition contract + +- Only firmware built with `PANDA_TESLA_WAKE_ON_CAN` enables the extra wake path. + Startup and manual update select it only with the toggle enabled and a matching + supported Tesla identity. Conflicting selected vehicle or corrupt CarParams + disables selection. Other firmware variants never set the extra wake flag. +- Accepted wake packets are standard CAN frames on bus 0, ID `0x221`, length 8. + Byte 7 must equal the sum of address bytes and payload bytes 0–6 modulo 256. + Two checksum-valid frames with consecutive modulo-16 upper-byte-6 counters are + required. Invalid checksum or extended frames break that wake counter sequence. +- `(data[0] >> 5U) & 0x3U`: OFF=0, CONDITIONING=1, ACCESSORY=2, DRIVE=3. + Valid non-OFF states set the independent wake flag. Accepted OFF clears it. + Invalid traffic cannot refresh it; the existing tick expiry clears stale wake. +- The original DRIVE ignition decoder is unchanged, including its own counter + tracking. Extra wake never asserts ignition or enters watchdog/control inputs. +- Bootkick tracks ignition and wake edges separately. A fresh wake edge can boot + while ignition is false. A held ACCESSORY state cannot mask a later DRIVE edge. + Harness, heartbeat priority, reset countdown/cancellation and one-reset-per-MCU- + boot behaviour remain intact. +- Stock host timeout, battery, voltage, thermal and forced shutdown remain in + force. This feature does not keep the host or car awake. Held wake does not + repeatedly boot the host; a new wake edge can wake it after shutdown. + +The checksum layout is defined in +`opendbc_repo/opendbc/dbc/tesla_model3_party.dbc`; the checksum algorithm is in +`opendbc_repo/opendbc/car/tesla/teslacan.py`. Checksum validation is integrity +checking, not authentication. Physical early-message availability and vehicle +wake behaviour still require hardware validation. + +## Repeatable local checks + +``` +ulimit -c 0 +python3 panda/tests/wake_can/run.py --evidence /absolute/evidence/green +python3 panda/tests/wake_can/host_policy.py --evidence /absolute/evidence/host +``` + +The runner compiles verbatim production decoder, full 8Hz tick, bootkick, +ignition-line and safety-mode predicates, and Cuatro GPIO callback, with fixture +peripherals. Each scenario runs in a fresh process. It covers 48 configurations: +Tesla, HKG, GM, IgnoreIgnitionLine, and three DEBUG/ALLOW_DEBUG combinations. +There are 600 scenario executions, 960,000 ignition/watchdog parity frames and +960,000 no-wake boot/reset/harness/GPIO parity frames against Dom +`bb04e935272ccbc7551dd5f46d18197757a35587`. Compilation uses warnings-as-errors and +UBSan. `--base REV` selects another stock reference. `--red` verifies that stock +lacks the extra non-OFF wake; optional `--regression-ref REV` can test a historical +version that masked the DRIVE edge if available locally. + +Host tests cover Tesla detection, stale identity, firmware-selection combinations, +missing files and normal update/signature behaviour. Galaxy/native settings tests +cover Tesla-only visibility and capability checks, parked-only writes, required +confirmation, cancellation and firmware preflight. `host_policy.py` exercises +actual shutdown methods with synthetic clocks and Params, without device access. + +## Firmware + +SCons supplies F4/H7 Tesla variants, each with and without IgnoreIgnitionLine: + +``` +cd panda +scons --minimal -j4 board/obj/panda_tesla_wake.bin.signed \ + board/obj/panda_h7_tesla_wake.bin.signed \ + board/obj/panda_tesla_wake_can_ignition_only.bin.signed \ + board/obj/panda_h7_tesla_wake_can_ignition_only.bin.signed +``` + +These use the existing signing configuration. No bootstub, trust key or safety +limit changes are required. Comma 3/3X and comma 4 use H7; comma 4 retains its +Cuatro GPIO callback. A local signed image is not installed firmware or physical +wake/sleep validation. The target Params binding/library must be rebuilt with +the new key before installation of the settings UI. + +Initial wake mechanism attribution: dzid26's +[commaai/panda PR2393](https://github.com/commaai/panda/pull/2393/files) and +[AmyJeanes/sunnypilot](https://github.com/AmyJeanes/sunnypilot/commit/57487d8c48be2907760a1553113f7ae7288025dc). diff --git a/panda/tests/wake_can/fixture.h b/panda/tests/wake_can/fixture.h new file mode 100644 index 0000000000..f31e96b2af --- /dev/null +++ b/panda/tests/wake_can/fixture.h @@ -0,0 +1,70 @@ +// Host-only fixtures. No CAN transmission, target access, or firmware image. +#include +#include +#include +#include +#include +#include +#include "opendbc/safety/can.h" +typedef struct {} harness_configuration; +typedef struct {} GPIO_TypeDef; +#include "board/boards/board_declarations.h" +#define HARNESS_STATUS_NC 0U +#define HARNESS_STATUS_FLIPPED 2U +#define LED_GREEN 1U +#define LED_BLUE 2U +#define POWER_SAVE_STATUS_ENABLED 1U +#define FAULT_RELAY_MALFUNCTION 1U +#define SAFETY_SILENT 0U +#define SAFETY_NOOUTPUT 19U +#define SAFETY_ALLOUTPUT 17U +#define SAFETY_ELM327 3U +struct { uint32_t SR; } timer; +#define TICK_TIMER (&timer) +struct { uint8_t status; } harness; +struct { uint16_t w_ptr_tx; } uart_ring_som_debug; +struct { uint32_t r_ptr, w_ptr; } can_rx_q, can_tx1_q, can_tx2_q, can_tx3_q; +bool line, som_gpio, gm_remote_start_boots_comma; +bool siren_enabled, relay_malfunction, controls_allowed, heartbeat_engaged; +bool heartbeat_disabled, heartbeat_lost; +uint32_t heartbeat_counter, heartbeat_engaged_mismatches, uptime_cnt, safety_mode_cnt; +uint16_t current_safety_mode = 42U, current_safety_param; +uint8_t power_save_status; +int current_safety_config; +unsigned watchdog_kicks, safety_ticks, silent_calls, ir_calls, fan_power, register_checks; +BootState observed_boot; +unsigned gpio_calls; +bool gpio_level; +#define GPIOA ((GPIO_TypeDef *)1) +void set_gpio_output(GPIO_TypeDef *port, unsigned pin, bool value) { + assert(port == GPIOA && pin == 0U); gpio_calls++; gpio_level = value; +} +static void cuatro_set_bootkick(BootState state); +void boot_output(BootState state) { observed_boot = state; cuatro_set_bootkick(state); } +bool read_som(void) { return som_gpio; } +void bool_sink(bool x) { (void)x; } +void ir_output(uint8_t x) { assert(x == 0U); ir_calls++; } +struct board fixture_board = {.set_bootkick=boot_output, .read_som_gpio=read_som, + .set_siren=bool_sink, .set_ir_power=ir_output}; +struct board *current_board = &fixture_board; +bool harness_check_ignition(void) { return line; } +void fan_tick(void) {} +void harness_tick(void) {} +void sound_tick(void) {} +void simple_watchdog_kick(void) { watchdog_kicks++; } +void fault_occurred(unsigned x) { (void)x; } +void fault_recovered(unsigned x) { (void)x; } +void can_set_orientation(bool x) { (void)x; } +void can_init_all(void) {} +void set_safety_mode(uint16_t mode, uint16_t param) { + current_safety_mode = mode; current_safety_param = param; + if (mode == SAFETY_SILENT) silent_calls++; +} +void set_power_save_state(uint8_t x) { power_save_status = x; } +void led_set(unsigned x, bool y) { (void)x; (void)y; } +void print(const char *x) { (void)x; } +void puth(unsigned x) { (void)x; } +void puth4(unsigned x) { (void)x; } +void fan_set_power(unsigned x) { fan_power = x; } +void check_registers(void) { register_checks++; } +void safety_tick(int *x) { (void)x; safety_ticks++; } diff --git a/panda/tests/wake_can/host_policy.py b/panda/tests/wake_can/host_policy.py new file mode 100644 index 0000000000..4d996cdfe6 --- /dev/null +++ b/panda/tests/wake_can/host_policy.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Execute actual host shutdown methods with an explicit clock and in-memory Params. +Diagnostic only: no imports of the live application, real Params, or hardware. +Stock timeout, voltage, battery and forced shutdown remain authoritative. +""" +import argparse +import ast +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[3] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--evidence', type=Path, required=True) + + args = parser.parse_args() + args.evidence.mkdir(parents=True, exist_ok=True) + path = ROOT/'system/hardware/power_monitoring.py' + source = path.read_text() + tree = ast.parse(source) + cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name=='PowerMonitoring') + methods = [n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name in ['shutdown_reason','should_shutdown']] + assert len(methods)==2 + clock = SimpleNamespace(now=0.) + ns = {'time':SimpleNamespace(monotonic=lambda:clock.now),'SimpleNamespace':SimpleNamespace} + nodes: list[ast.stmt] = [n for n in tree.body if isinstance(n, ast.Assign)] + nodes.extend(methods) + exec(compile(ast.Module(body=nodes,type_ignores=[]),str(path)+':actual-methods','exec'), ns) + values = {'DisablePowerDown':False,'ForcePowerDown':False} + pm = SimpleNamespace(low_voltage_start_time=None,car_voltage_mV=14000,car_battery_capacity_uWh=30e6, + params=SimpleNamespace(get_bool=lambda name:values[name])) + toggles = SimpleNamespace(device_shutdown_time=3600,low_voltage_shutdown=11.8) + # Wake is deliberately independent, but not a supported input to this policy. + pm.wake_on_can = True + reason = lambda: ns['shutdown_reason'](pm,False,True,1.,False,toggles) + observed = {} + for t in range(2,3703): + clock.now=float(t) + result = reason() + if result and 'first_shutdown' not in observed: + observed['first_shutdown']={'monotonic_s':t,'offroad_s':t-1,'reason':result,'fresh_awake':True} + assert observed['first_shutdown']=={'monotonic_s':3602,'offroad_s':3601,'reason':'offroad_timeout','fresh_awake':True} + # The one-hour constant is NOT an unconditional one-hour timeout. + toggles.device_shutdown_time=0 + assert reason() is None + observed['no_timeout_healthy_after_hour']=reason() + # Existing emergency/protective inputs stay intact; never bypass by changing ignition + # or setting DisablePowerDown to implement stay-awake. + pm.car_battery_capacity_uWh=0 + assert reason()=='battery_capacity_exhausted' + observed['exhausted']=reason() + pm.car_battery_capacity_uWh=30e6; pm.car_voltage_mV=11000 + assert reason() is None + clock.now+=29; assert reason() is None + clock.now+=1; assert reason()=='low_voltage' + observed['low_voltage']=reason() + values['ForcePowerDown']=True; assert reason()=='forced_power_down' + observed['forced']=reason() + values['ForcePowerDown']=False; pm.car_voltage_mV=14000 + toggles.device_shutdown_time=3600 + for awake in [True,False]: + pm.wake_on_can=awake + assert reason()=='offroad_timeout' # same answer; no telemetry seam exists yet + observed['contract']='Wake-only CAN deliberately does not inhibit stock host shutdown.' + observed['source_sha256']=hashlib.sha256(path.read_bytes()).hexdigest() + observed['thermal']='Not modeled here; unchanged host thermal protection must be independently preserved in any extension.' + (args.evidence/'host-policy.json').write_text(json.dumps(observed,indent=2)+'\n') + print(json.dumps(observed,indent=2)) + print('PASS stock shutdown policy assertions; no live Params or hardware accessed.') + + +if __name__ == '__main__': main() diff --git a/panda/tests/wake_can/run.py b/panda/tests/wake_can/run.py new file mode 100644 index 0000000000..cdd0f5b674 --- /dev/null +++ b/panda/tests/wake_can/run.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Compile verbatim current C functions, not a Python decoder/power-policy model. +No SCons execution, firmware build, artifact signing, or device access. +""" +import argparse +import hashlib +import itertools +import json +import os +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parents[3] +HERE = Path(__file__).resolve().parent +BASE = 'bb04e935272ccbc7551dd5f46d18197757a35587' +FILES = ['panda/board/drivers/can_common.h', 'panda/board/main.c', + 'panda/board/drivers/bootkick.h', 'panda/board/boards/cuatro.h'] + + +def between(text, start, end): + assert text.count(start) == 1, start + tail = text[text.index(start):] + assert end in tail, end + return tail[:tail.index(end)] + + +def generate(read): + can, main, boot, cuatro = [read(p) for p in FILES] + independent_wake = 'bool recent_heartbeat, bool wake)' in boot + # Historical two-argument definitions must not include today's declaration. + if not independent_wake: + boot = boot.replace('#include "bootkick_declarations.h"', '') + globals_ = between(can, 'bool ignition_can = false;', '\nbool can_silent') + # Added wake globals immediately precede ignition globals in candidate. + if 'bool wake_on_can = false;' in can: + globals_ = between(can, 'bool wake_on_can = false;', '\nbool can_silent') + else: + globals_ = 'bool wake_on_can=false; uint32_t wake_on_can_cnt=0;\n' + globals_ + chunks = [ + ('can globals', globals_), + ('decoder', between(can, 'void ignition_can_hook(CANPacket_t *msg) {', '\nbool can_tx_check_min_slots_free')), + ('ignition line', between(main, 'static bool panda_ignition_line(void) {', '\n\n// ********************* Serial')), + ('car safety predicate', between(main, 'bool is_car_safety_mode(uint16_t mode) {', '\n// ***************************** main')), + ('cuatro GPIO callback', between(cuatro, 'static void cuatro_set_bootkick(BootState state) {', '\nstatic void cuatro_set_amp_enabled')), + ('bootkick', boot), + ('entire 8Hz tick', between(main, '#define HEARTBEAT_IGNITION_CNT_ON', '\nint main(void) {')), + ] + call = 'bootkick_tick(ign, hb, wake);' if independent_wake else '(void)wake; bootkick_tick(ign, hb);' + adapter = '\nstatic void test_bootkick(bool ign, bool hb, bool wake) { ' + call + ' }\n' + adapter += 'static void legacy_bootkick(bool ign, bool hb) { test_bootkick(ign, hb, false); }\n' + output = '#include "fixture.h"\n' + '\n'.join(x[1] for x in chunks) + adapter + '\n#include "scenarios.h"\n' + return output, {name: hashlib.sha256(text.encode()).hexdigest() for name, text in chunks} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--evidence', type=Path, required=True) + parser.add_argument('--red', action='store_true', help='Expect stock states assertion to fail') + parser.add_argument('--base', default=BASE, help='Stock Git revision for parity checks') + parser.add_argument('--regression-ref', help='Optional historical pre-fix Git revision; expect DRIVE wake failure') + args = parser.parse_args() + args.evidence.mkdir(parents=True, exist_ok=True) + source, hashes = generate(lambda p: (ROOT / p).read_text()) + if args.regression_ref: + source, hashes = generate(lambda p: subprocess.check_output(['git', 'show', f'{args.regression_ref}:{p}'], cwd=ROOT, text=True)) + stock, stock_hashes = generate(lambda p: subprocess.check_output(['git', 'show', f'{args.base}:{p}'], cwd=ROOT, text=True)) + (args.evidence / 'candidate.c').write_text(source) + (args.evidence / 'stock.c').write_text(stock) + tested_files = FILES + ['panda/board/drivers/can_common_declarations.h', + 'panda/board/drivers/bootkick_declarations.h', + 'panda/board/boards/board_declarations.h', + 'opendbc_repo/opendbc/safety/can.h'] + tested_files += [str(p.relative_to(ROOT)) for p in sorted(HERE.iterdir()) if p.suffix in {'.py', '.h', '.md'}] + manifest = {'base': args.base, 'source_sha256': {p: hashlib.sha256((ROOT / p).read_bytes()).hexdigest() for p in tested_files}, + 'extracted_sha256': hashes, 'stock_extracted_sha256': stock_hashes, 'runs': []} + scenarios = ['states','invalid','stale','watchdog','drive_watchdog','boot','serial','gpio','existing','other_cars','drive_edge', + 'independent_edges','wake_reset','stock_drive'] + for tesla, hkg, gm, ignore in itertools.product([False, True], repeat=4): + for debug, allow_debug in [(False,False), (True,True), (False,True)]: + variant = f'tesla{int(tesla)}-hkg{int(hkg)}-gm{int(gm)}-ignore{int(ignore)}-debug{int(debug)}-allow{int(allow_debug)}' + flags = [f'-D{x}' for x, yes in [('PANDA_TESLA_WAKE_ON_CAN',tesla),('PANDA_HKG_REMOTE_START',hkg),('PANDA_GM_REMOTE_START_C9',gm), + ('PANDA_IGNORE_IGNITION_LINE',ignore),('DEBUG',debug),('ALLOW_DEBUG',allow_debug)] if yes] + binaries = {} + for name in ['candidate', 'stock']: + binary = args.evidence / f'{name}-{variant}' + command = [os.environ.get('CC','cc'), '-std=gnu11', '-Wall','-Wextra','-Werror', + '-Wno-sign-compare', '-O2','-g','-fsanitize=undefined','-fno-sanitize-recover=all', + *flags, '-I'+str(HERE), '-I'+str(ROOT/'panda'), '-I'+str(ROOT/'panda/board/drivers'), + '-I'+str(ROOT/'opendbc_repo'), str(args.evidence/f'{name}.c'), '-o', str(binary)] + subprocess.run(command, check=True) + binaries[name] = binary + manifest['runs'].append({'compile': command}) + if args.regression_ref: + run = subprocess.run([str(binaries['candidate']), 'drive_edge'], text=True, capture_output=True) + assert run.returncode != 0 and 'observed_boot==BOOT_BOOTKICK' in run.stderr, run.stderr + manifest['runs'].append({'variant':variant,'regression_red_rc':run.returncode,'stderr':run.stderr}) + (args.evidence/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n') + print('RED verified: pre-fix candidate masks DRIVE:', run.stderr.strip()) + return + if args.red: + run = subprocess.run([str(binaries['stock']), 'states'], text=True, capture_output=True) + assert run.returncode != 0 and 'wake_on_can == (state != 0)' in run.stderr, run.stderr + print('RED verified: stock fails non-OFF wake assertion:', run.stderr.strip()) + return + selected = scenarios + ['checksum'] if tesla else [ + 'disabled', 'drive_watchdog', 'boot', 'serial', 'gpio', 'existing', 'other_cars', + 'stock_drive', 'independent_edges', 'wake_reset'] + for scenario in selected: + run = subprocess.run([str(binaries['candidate']),scenario], text=True,capture_output=True) + manifest['runs'].append({'variant':variant,'scenario':scenario,'rc':run.returncode,'stdout':run.stdout,'stderr':run.stderr}) + if run.returncode: + print(variant, scenario, run.stdout, run.stderr) + raise SystemExit(run.returncode) + subprocess.run([str(binaries['stock']), 'stock_drive'], check=True) + boot_traces = [subprocess.check_output([str(binaries[name]),'boot_trace']) for name in ['candidate','stock']] + assert boot_traces[0] == boot_traces[1], f'Boot/reset/harness parity failed: {variant}' + (args.evidence / f'boot-parity-{variant}.txt').write_bytes(boot_traces[0]) + manifest['runs'].append({'variant':variant,'boot_parity_frames':20000,'sha256':hashlib.sha256(boot_traces[0]).hexdigest()}) + traces = [subprocess.check_output([str(binaries[name]),'trace']) for name in ['candidate','stock']] + assert traces[0] == traces[1], f'Ignition/watchdog parity failed: {variant}' + trace_hash = hashlib.sha256(traces[0]).hexdigest() + (args.evidence / f'parity-{variant}.txt').write_bytes(traces[0]) + manifest['runs'].append({'variant':variant,'parity_frames':20000,'sha256':trace_hash}) + print(f'PASS {variant}: {len(selected)} scenarios; 20000-frame ignition/watchdog and boot/reset stock parity; DRIVE edge preserved') + (args.evidence/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n') + summary = {'configurations':sum('parity_frames' in r for r in manifest['runs']), + 'scenario_runs':sum('scenario' in r for r in manifest['runs']), + 'stock_parity_frames':sum(r.get('parity_frames',0) for r in manifest['runs']), + 'boot_parity_frames':sum(r.get('boot_parity_frames',0) for r in manifest['runs'])} + (args.evidence/'summary.json').write_text(json.dumps(summary,indent=2)+'\n') + print('PASS:', summary, 'NOT hardware/release clearance.') + + +if __name__ == '__main__': + main() diff --git a/panda/tests/wake_can/scenarios.h b/panda/tests/wake_can/scenarios.h new file mode 100644 index 0000000000..96faea1efc --- /dev/null +++ b/panda/tests/wake_can/scenarios.h @@ -0,0 +1,285 @@ +// Included after verbatim production functions. Each scenario gets a fresh process. +static void tick(void) { timer.SR = 1U; tick_handler(); } +static void second(void) { for (int i=0; i<8; i++) tick(); } +static CANPacket_t packet(unsigned bus, unsigned addr, unsigned dlc, unsigned state, unsigned counter) { + CANPacket_t p = {0}; p.bus=bus; p.addr=addr; p.data_len_code=dlc; + p.data[0]=state<<5; p.data[6]=counter<<4; + // Tesla DBC checksum: address bytes plus seven payload bytes, modulo 256. + unsigned sum=(addr & 255U)+(addr >> 8U); + for (unsigned i=0; i<7; i++) sum+=p.data[i]; + p.data[7]=sum & 255U; + return p; +} +static void tesla(unsigned state, unsigned counter) { + CANPacket_t p=packet(0,0x221,8,state,counter); ignition_can_hook(&p); +} +static void pair(unsigned state) { tesla(state,14); tesla(state,15); } +static void wake_case(void) { + assert(!wake_on_can && !ignition_can); + tesla(2,14); assert(!wake_on_can && !ignition_can); // prime only + for (unsigned state=0; state<4; state++) { + tesla(state,(15+state)%16); + assert(wake_on_can == (state != 0)); assert(ignition_can == (state == 3)); + assert(wake_on_can_cnt == 0 && ignition_can_cnt == 0); + } + tesla(0,3); assert(!wake_on_can && !ignition_can); // accepted OFF clears immediately +} +static void invalid_case(void) { + pair(2); assert(wake_on_can); wake_on_can_cnt=2; + for (unsigned bus=1; bus<8; bus++) { + CANPacket_t p=packet(bus,0x221,8,0,0); ignition_can_hook(&p); + assert(wake_on_can && wake_on_can_cnt == 2); + } + for (unsigned dlc=0; dlc<16; dlc++) { + if (dlc==8) continue; + CANPacket_t p=packet(0,0x221,dlc,0,0); ignition_can_hook(&p); + assert(wake_on_can && wake_on_can_cnt==2); + } + CANPacket_t p=packet(0,0x220,8,0,0); ignition_can_hook(&p); + assert(wake_on_can && wake_on_can_cnt==2); + tesla(0,15); assert(wake_on_can && wake_on_can_cnt==2); // duplicate + tesla(0,7); assert(wake_on_can && wake_on_can_cnt==2); // jump + tesla(0,8); assert(!wake_on_can && wake_on_can_cnt==0); // reacquire after jump + tesla(2,9); assert(wake_on_can); + wake_on_can_cnt=2; + p=packet(0,0x221,8,2,10); p.data[7]^=1; ignition_can_hook(&p); + assert(wake_on_can && wake_on_can_cnt==2); // invalid frames cannot refresh wake + tesla(0,11); assert(wake_on_can && wake_on_can_cnt==2); // valid frame primes again + tesla(0,12); assert(!wake_on_can && wake_on_can_cnt==0); +} + +static void disabled_case(void) { + for (unsigned state=0; state<4; state++) { + pair(state); + assert(!wake_on_can); // default and other manufacturers never gain Tesla wake + assert(ignition_can == (state==3)); // stock DRIVE remains independent of opt-in + } +} +static void checksum_case(void) { + CANPacket_t p=packet(0,0x221,8,2,14); + assert(p.data[7]==0x43); // address 0x23 + ACCESSORY 0x40 + counter 0xE0 + p.data[7]^=1; ignition_can_hook(&p); + tesla(2,15); assert(!wake_on_can); // bad checksum cannot prime a wake + tesla(2,0); assert(wake_on_can); // two valid consecutive frames, including wrap + tesla(0,1); assert(!wake_on_can); + // Corruption in any payload/checksum byte cannot create a wake or prime it. + for (unsigned i=0; i<8; i++) { + p=packet(0,0x221,8,2,2); p.data[i]^=1; ignition_can_hook(&p); + assert(!wake_on_can); + tesla(2,3); assert(!wake_on_can); + tesla(0,4); assert(!wake_on_can); + } + p=packet(0,0x221,8,2,5); p.extended=1; ignition_can_hook(&p); + assert(!wake_on_can); + tesla(2,6); assert(!wake_on_can); + tesla(2,7); assert(wake_on_can); + wake_on_can_cnt=2; + for (unsigned i=0; i<4; i++) { + p=packet(0,0x221,8,2,8+i); p.data[7]^=1; ignition_can_hook(&p); + second(); + } + assert(!wake_on_can); // checksum-invalid traffic ages out + // Do not change the stock DRIVE decoder while hardening the extra wake path. + p=packet(0,0x221,8,3,12); p.data[7]^=1; ignition_can_hook(&p); + assert(ignition_can && !wake_on_can); +} + +static void stale_case(void) { + pair(2); + for (unsigned i=1; i<=3; i++) { second(); assert(wake_on_can && wake_on_can_cnt==i); } + second(); assert(!wake_on_can && wake_on_can_cnt==4); + // Staleness does not reset the decoder's static previous counter (reference parity). + tesla(2,15); assert(!wake_on_can && wake_on_can_cnt==4); + tesla(2,0); assert(wake_on_can && wake_on_can_cnt==0); // wrap after stale + second(); assert(wake_on_can_cnt==1); + tesla(0,1); assert(!wake_on_can && wake_on_can_cnt==0); + wake_on_can_cnt=UINT32_MAX; second(); assert(!wake_on_can && wake_on_can_cnt==0); +} +static void watchdog_case(void) { + pair(2); assert(wake_on_can && !ignition_can); + heartbeat_disabled=true; controls_allowed=true; heartbeat_engaged=true; som_gpio=true; + second(); assert(heartbeat_counter==1 && !heartbeat_disabled && !heartbeat_lost); + second(); assert(heartbeat_counter==2 && heartbeat_lost && !heartbeat_engaged); + assert(current_safety_mode==SAFETY_SILENT && silent_calls==1); + assert(power_save_status==POWER_SAVE_STATUS_ENABLED && ir_calls==1 && fan_power==30); + assert(watchdog_kicks==16 && safety_ticks==2 && register_checks==2); + heartbeat_counter=UINT32_MAX; second(); assert(heartbeat_counter==UINT32_MAX); +} +static void drive_watchdog_case(void) { + pair(3); + for (unsigned i=1; i<5; i++) { + tesla(3,(15+i)%16); second(); assert(!heartbeat_lost && heartbeat_counter==i); + } + tesla(3,4); second(); assert(heartbeat_counter==5 && heartbeat_lost); +} +static void boot_case(void) { + legacy_bootkick(false,false); assert(observed_boot==BOOT_BOOTKICK); // first power-on + for (int i=0; i<30; i++) legacy_bootkick(false,false); + assert(!bootkick_reset_triggered); // no first-boot reset + legacy_bootkick(false,true); assert(observed_boot==BOOT_STANDBY); + legacy_bootkick(true,false); assert(observed_boot==BOOT_BOOTKICK); + for (int i=0; i<18; i++) { legacy_bootkick(true,false); assert(observed_boot==BOOT_BOOTKICK); } + legacy_bootkick(true,false); assert(observed_boot==BOOT_RESET && bootkick_reset_triggered); + assert(gpio_level); // Cuatro RESET is deasserted bootkick, NOT a separate reset pin + for (int i=0; i<4; i++) { legacy_bootkick(true,false); assert(observed_boot==BOOT_RESET); } + legacy_bootkick(true,false); assert(observed_boot==BOOT_BOOTKICK && !gpio_level); + legacy_bootkick(false,true); legacy_bootkick(true,false); + for (int i=0; i<30; i++) legacy_bootkick(true,false); + assert(observed_boot==BOOT_BOOTKICK); // only one reset per MCU boot +} +static void cancel_reset_case(int serial) { + legacy_bootkick(false,true); legacy_bootkick(true,false); + if (serial) uart_ring_som_debug.w_ptr_tx++; else som_gpio=true; + legacy_bootkick(true,false); som_gpio=false; + for (int i=0; i<30; i++) legacy_bootkick(true,false); + assert(!bootkick_reset_triggered && observed_boot==BOOT_BOOTKICK); +} +static void existing_case(void) { + // Harness insertion still wakes with ignition false. + legacy_bootkick(false,true); harness.status=1; + legacy_bootkick(false,false); assert(observed_boot==BOOT_BOOTKICK); + legacy_bootkick(false,true); assert(observed_boot==BOOT_STANDBY); + line=true; heartbeat_counter=1; second(); +#ifdef PANDA_IGNORE_IGNITION_LINE + assert(observed_boot==BOOT_STANDBY); +#else + assert(observed_boot==BOOT_BOOTKICK); +#endif +} +static void other_cars_case(void) { + CANPacket_t p=packet(0,0x1F1,8,0,0); p.data[0]=2; ignition_can_hook(&p); +#ifdef PANDA_GM_REMOTE_START_C9 + assert(!ignition_can); +#else + assert(ignition_can); +#endif + gm_remote_start_boots_comma=true; + p=packet(0,0xC9,8,0,0); p.data[6]=0x10; ignition_can_hook(&p); assert(ignition_can); + p.data[6]=0; ignition_can_hook(&p); assert(!ignition_can); + // Pre-AP checksum and counter remain active. + p=packet(0,0x101,3,0,0); p.data[0]=8; p.data[1]=14; p.data[2]=24; ignition_can_hook(&p); + assert(!ignition_can); p.data[1]=15; p.data[2]=25; ignition_can_hook(&p); assert(ignition_can); + p.data[0]=0; p.data[1]=0; p.data[2]=99; ignition_can_hook(&p); assert(ignition_can); + p.data[1]=1; p.data[2]=3; ignition_can_hook(&p); assert(!ignition_can); + // Rivian sequential modulo-15, then Mazda. + p=packet(0,0x152,8,0,0); p.data[1]=14; p.data[7]=0x10; ignition_can_hook(&p); + assert(!ignition_can); p.data[1]=0; ignition_can_hook(&p); assert(ignition_can); + p=packet(0,0x9E,8,0,0); ignition_can_hook(&p); assert(!ignition_can); + p.data[0]=0xC0; ignition_can_hook(&p); assert(ignition_can); + assert(!wake_on_can); // none of these set Tesla-only wake flag +#ifdef PANDA_HKG_REMOTE_START + ignition_can=false; legacy_bootkick(false,true); + p=packet(1,0x384,8,0,0); p.data[3]=1; ignition_can_hook(&p); + assert(hkg_remote_climate_wake && !ignition_can && !wake_on_can); + heartbeat_counter=1; second(); assert(observed_boot==BOOT_BOOTKICK && !heartbeat_lost); + for (int i=0; i<3; i++) second(); + assert(!hkg_remote_climate_wake && hkg_remote_climate_wake_cnt==4); +#endif +} +static void drive_edge_case(void) { + // Prolonged awake, SOM shuts down while fresh ACCESSORY CAN continues. + pair(2); second(); heartbeat_counter=0; second(); assert(observed_boot==BOOT_STANDBY); + for (unsigned i=0; i<3700; i++) { tesla(2,i%16); second(); } + assert(wake_on_can && !ignition_can && observed_boot==BOOT_STANDBY); + tesla(3,4); second(); assert(ignition_can && wake_on_can); + assert(observed_boot==BOOT_BOOTKICK); // Stock DRIVE edge must survive held wake. + puts("PASS: fresh ACCESSORY through SOM shutdown preserves later DRIVE bootkick"); + tesla(0,5); second(); tesla(2,6); second(); + assert(observed_boot==BOOT_BOOTKICK); // true low->high recovers + heartbeat_counter=0; second(); assert(observed_boot==BOOT_STANDBY); + // Car sleep / absent or rejected traffic must age wake out and permit re-wake. + for (unsigned i=0; i<5; i++) { + tesla(2,6); // repeated counter cannot keep the wake fresh + second(); + } + assert(!wake_on_can && !ignition_can && observed_boot==BOOT_STANDBY); + tesla(2,7); second(); assert(wake_on_can && observed_boot==BOOT_BOOTKICK); +} +static void stock_drive_case(void) { + // This exact physical-input sequence passes both stock and candidate. + pair(2); second(); heartbeat_counter=0; second(); + for (unsigned i=0; i<3700; i++) { tesla(2,i%16); second(); } + assert(!ignition_can && observed_boot==BOOT_STANDBY); + tesla(3,4); second(); + assert(ignition_can && observed_boot==BOOT_BOOTKICK); +} +static void independent_edges_case(void) { + test_bootkick(false,true,false); assert(observed_boot==BOOT_STANDBY); + test_bootkick(false,true,true); assert(observed_boot==BOOT_BOOTKICK); // edge beats heartbeat + test_bootkick(false,true,true); assert(observed_boot==BOOT_STANDBY); + test_bootkick(false,false,true); assert(observed_boot==BOOT_STANDBY); // held wake is not level-triggered + test_bootkick(true,true,true); assert(observed_boot==BOOT_BOOTKICK); // independent ignition edge + test_bootkick(true,true,false); assert(observed_boot==BOOT_STANDBY); + test_bootkick(true,false,true); assert(observed_boot==BOOT_STANDBY); // no new wake kick while started + test_bootkick(false,false,true); assert(observed_boot==BOOT_STANDBY); // falling ignition is not wake edge + test_bootkick(false,false,false); assert(observed_boot==BOOT_STANDBY); + test_bootkick(false,false,true); assert(observed_boot==BOOT_BOOTKICK); + test_bootkick(false,true,true); assert(observed_boot==BOOT_STANDBY); + harness.status=1; + test_bootkick(false,true,true); assert(observed_boot==BOOT_BOOTKICK); // harness still beats heartbeat +} +static void wake_reset_case(void) { + test_bootkick(false,true,false); + test_bootkick(false,false,true); + for (int i=0; i<18; i++) { test_bootkick(false,false,true); assert(observed_boot==BOOT_BOOTKICK); } + test_bootkick(false,false,true); assert(observed_boot==BOOT_RESET && bootkick_reset_triggered); + // Ignition, wake, harness and heartbeat cannot interrupt the active reset. + for (int i=0; i<4; i++) { + harness.status=(i%2)+1; + test_bootkick(i%2,true,i%2); assert(observed_boot==BOOT_RESET); + } + test_bootkick(true,false,true); assert(observed_boot==BOOT_BOOTKICK); + test_bootkick(false,true,false); test_bootkick(false,false,true); + for (int i=0; i<30; i++) test_bootkick(false,false,true); + assert(observed_boot==BOOT_BOOTKICK); // no second reset +} +static void boot_trace_case(void) { + uint32_t rng=0x87654321; + for (unsigned i=0; i<20000; i++) { + rng=rng*1664525U+1013904223U; + if (i%37==0) harness.status=(rng>>20)%3; + if (i%53==0) uart_ring_som_debug.w_ptr_tx++; + som_gpio=(rng%97)==0; + legacy_bootkick((rng>>30)!=0,(rng%29)==0); + printf("%u %u %u %u %u\n",i,observed_boot,bootkick_reset_triggered,gpio_level,gpio_calls); + } +} +static void trace_case(void) { + uint32_t rng=0x12345678; + const unsigned ids[]={0x221,0x101,0x152,0x1F1,0xC9,0x9E,0x384,0x220}; + for (unsigned i=0; i<20000; i++) { + rng=rng*1664525U+1013904223U; + CANPacket_t p=packet((rng>>29)%3,ids[(rng>>20)%8],(rng>>8)%16,(rng>>2)%4,i%16); + for (unsigned b=0;b<8;b++) p.data[b]=(rng>>(b*3))&255; + if ((i%3)==0) p=packet(0,0x221,8,i%4,i%16); + line=(rng&255)==0; gm_remote_start_boots_comma=(rng&256)!=0; + if ((i%17)==0) { heartbeat_counter=0; current_safety_mode=42; controls_allowed=true; heartbeat_engaged=true; } + ignition_can_hook(&p); tick(); + printf("%u %u %u %u %u %u %u %u %u %u %u %u %u\n",i,ignition_can,ignition_can_cnt,heartbeat_counter, + current_safety_mode,heartbeat_lost,heartbeat_engaged,controls_allowed,heartbeat_engaged_mismatches, + power_save_status,watchdog_kicks,safety_ticks,ir_calls); + } +} +int main(int argc,char **argv) { + assert(argc==2); + if (!strcmp(argv[1],"disabled")) disabled_case(); + else if (!strcmp(argv[1],"checksum")) checksum_case(); + else if (!strcmp(argv[1],"states")) wake_case(); + else if (!strcmp(argv[1],"invalid")) invalid_case(); + else if (!strcmp(argv[1],"stale")) stale_case(); + else if (!strcmp(argv[1],"watchdog")) watchdog_case(); + else if (!strcmp(argv[1],"drive_watchdog")) drive_watchdog_case(); + else if (!strcmp(argv[1],"boot")) boot_case(); + else if (!strcmp(argv[1],"serial")) cancel_reset_case(1); + else if (!strcmp(argv[1],"gpio")) cancel_reset_case(0); + else if (!strcmp(argv[1],"existing")) existing_case(); + else if (!strcmp(argv[1],"other_cars")) other_cars_case(); + else if (!strcmp(argv[1],"drive_edge")) drive_edge_case(); + else if (!strcmp(argv[1],"stock_drive")) stock_drive_case(); + else if (!strcmp(argv[1],"independent_edges")) independent_edges_case(); + else if (!strcmp(argv[1],"wake_reset")) wake_reset_case(); + else if (!strcmp(argv[1],"boot_trace")) boot_trace_case(); + else if (!strcmp(argv[1],"trace")) trace_case(); + else return 2; + return 0; +} diff --git a/selfdrive/pandad/panda_firmware.py b/selfdrive/pandad/panda_firmware.py new file mode 100644 index 0000000000..7064676d01 --- /dev/null +++ b/selfdrive/pandad/panda_firmware.py @@ -0,0 +1,100 @@ +"""Firmware selection shared by pandad and the parked updater.""" + +import os + +from cereal import car +from openpilot.common.basedir import BASEDIR +from openpilot.common.params import Params, UnknownKeyName +from openpilot.common.swaglog import cloudlog + + +FW_PATH = os.path.join(BASEDIR, "panda", "board", "obj") + +TESLA_CAN_WAKE_PLATFORMS = {"TESLA_MODEL_3", "TESLA_MODEL_Y", "TESLA_MODEL_X"} + + +def supports_tesla_can_wake(params: Params) -> bool: + try: + selected_make = params.get("CarMake") or "" + selected_model = params.get("CarModel") or "" + if isinstance(selected_make, bytes): + selected_make = selected_make.decode("utf-8") + if isinstance(selected_model, bytes): + selected_model = selected_model.decode("utf-8") + if selected_make and selected_make.strip().lower() != "tesla": + return False + cp_bytes = params.get("CarParams") + if cp_bytes is None: + cp_bytes = params.get("CarParamsPersistent") + if not cp_bytes: + return False + with car.CarParams.from_bytes(cp_bytes) as CP: + return (CP.brand == "tesla" and CP.carFingerprint in TESLA_CAN_WAKE_PLATFORMS and + (not selected_model or selected_model == CP.carFingerprint)) + except Exception: + return False + + +def get_tesla_wake_on_can(params: Params) -> bool: + try: + return params.get_bool("TeslaWakeOnCAN") and supports_tesla_can_wake(params) + except UnknownKeyName: + return False + + +def firmware_flags_conflict(params: Params, key: str, enabled: bool) -> bool: + if not enabled: + return False + try: + if key == "TeslaWakeOnCAN": + return params.get_bool("RemoteStartBootsComma") or params.get_bool("HKGRemoteStartBootsComma") + if key in {"RemoteStartBootsComma", "HKGRemoteStartBootsComma"}: + return params.get_bool("TeslaWakeOnCAN") + except UnknownKeyName: + return False + return False + + +def get_selected_firmware_name(app_fn: str, remote_start: bool, hkg_remote_start: bool, ignore_ignition_line: bool, + tesla_wake: bool = False) -> str: + if tesla_wake and (remote_start or hkg_remote_start): + raise ValueError("Tesla wake firmware cannot be combined with remote-start firmware") + if not remote_start and not hkg_remote_start and not ignore_ignition_line and not tesla_wake: + return app_fn + + name_parts = ["panda_h7" if app_fn == "panda_h7.bin.signed" else "panda"] + if tesla_wake: + name_parts.extend(["tesla", "wake"]) + elif hkg_remote_start: + name_parts.extend(["hkg", "remote"]) + elif remote_start: + name_parts.append("remote") + if ignore_ignition_line: + name_parts.append("can_ignition_only") + return "_".join(name_parts) + ".bin.signed" + + +def get_firmware_path(fw_path: str, app_fn: str, remote_start: bool, hkg_remote_start: bool, ignore_ignition_line: bool, + tesla_wake: bool = False) -> str: + selected_fn = get_selected_firmware_name(app_fn, remote_start, hkg_remote_start, ignore_ignition_line, tesla_wake) + selected_path = os.path.join(fw_path, selected_fn) + if selected_fn != app_fn and not os.path.isfile(selected_path): + if tesla_wake: + raise FileNotFoundError(f"Tesla wake firmware not found: {selected_path}") + cloudlog.warning(f"Selected panda firmware not found: {selected_path}, falling back to default") + return os.path.join(fw_path, app_fn) + return selected_path + + +def validate_tesla_can_wake_firmware(params: Params, enabled: bool) -> None: + """Verify both Tesla wake images before changing the setting.""" + remote_start = params.get_bool("RemoteStartBootsComma") + hkg_remote_start = params.get_bool("HKGRemoteStartBootsComma") + ignore_ignition_line = params.get_bool("IgnoreIgnitionLine") + if enabled and (remote_start or hkg_remote_start): + raise RuntimeError("Tesla wake cannot be enabled while remote-start firmware is enabled") + for app_fn in ("panda.bin.signed", "panda_h7.bin.signed"): + selected_fn = get_selected_firmware_name(app_fn, remote_start, hkg_remote_start, ignore_ignition_line, enabled) + selected_path = os.path.join(FW_PATH, selected_fn) + if not os.path.isfile(selected_path) or os.path.getsize(selected_path) == 0: + raise RuntimeError(f"Required Panda firmware is missing or empty: {selected_fn}. Update the device firmware package before changing Wake on CAN.") diff --git a/selfdrive/pandad/pandad.py b/selfdrive/pandad/pandad.py index 2b8d52a0f6..1c9521f8fe 100755 --- a/selfdrive/pandad/pandad.py +++ b/selfdrive/pandad/pandad.py @@ -12,37 +12,18 @@ from openpilot.common.params import Params, UnknownKeyName from openpilot.system.hardware import HARDWARE from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.pandad.rivian_long_flasher import prepare_rivian_bridge +from openpilot.selfdrive.pandad.panda_firmware import get_firmware_path, get_tesla_wake_on_can +from openpilot.selfdrive.pandad.panda_firmware import get_selected_firmware_name as get_selected_firmware_name -def get_selected_firmware_name(app_fn: str, remote_start: bool, hkg_remote_start: bool, ignore_ignition_line: bool) -> str: - if not remote_start and not hkg_remote_start and not ignore_ignition_line: - return app_fn - - h7 = app_fn == "panda_h7.bin.signed" - name_parts = ["panda_h7" if h7 else "panda"] - if hkg_remote_start: - name_parts.extend(["hkg", "remote"]) - elif remote_start: - name_parts.append("remote") - if ignore_ignition_line: - name_parts.append("can_ignition_only") - return "_".join(name_parts) + ".bin.signed" +def get_expected_firmware_path(panda: Panda, remote_start: bool, hkg_remote_start: bool, ignore_ignition_line: bool, + tesla_wake: bool = False) -> str: + return get_firmware_path(FW_PATH, panda.get_mcu_type().config.app_fn, remote_start, hkg_remote_start, ignore_ignition_line, tesla_wake) -def get_expected_firmware_path(panda: Panda, remote_start: bool, hkg_remote_start: bool, ignore_ignition_line: bool) -> str: - app_fn = panda.get_mcu_type().config.app_fn - selected_fn = get_selected_firmware_name(app_fn, remote_start, hkg_remote_start, ignore_ignition_line) - if selected_fn != app_fn: - selected_path = os.path.join(FW_PATH, selected_fn) - if os.path.isfile(selected_path): - return selected_path - cloudlog.warning(f"Selected panda firmware not found: {selected_path}, falling back to default") - return os.path.join(FW_PATH, app_fn) - - -def get_expected_signature(panda: Panda, remote_start: bool, hkg_remote_start: bool, ignore_ignition_line: bool) -> bytes: +def get_expected_signature(panda: Panda, remote_start: bool, hkg_remote_start: bool, ignore_ignition_line: bool, tesla_wake: bool = False) -> bytes: try: - fn = get_expected_firmware_path(panda, remote_start, hkg_remote_start, ignore_ignition_line) + fn = get_expected_firmware_path(panda, remote_start, hkg_remote_start, ignore_ignition_line, tesla_wake) return Panda.get_signature_from_firmware(fn) except Exception: cloudlog.exception("Error computing expected signature") @@ -70,7 +51,7 @@ def get_ignore_ignition_line(params: Params) -> bool: return False -def flash_panda(panda_serial: str, remote_start: bool, hkg_remote_start: bool, ignore_ignition_line: bool) -> Panda: +def flash_panda(panda_serial: str, remote_start: bool, hkg_remote_start: bool, ignore_ignition_line: bool, tesla_wake: bool = False) -> Panda: try: panda = Panda(panda_serial) except PandaProtocolMismatch: @@ -78,8 +59,8 @@ def flash_panda(panda_serial: str, remote_start: bool, hkg_remote_start: bool, i HARDWARE.recover_internal_panda() raise - fw_path = get_expected_firmware_path(panda, remote_start, hkg_remote_start, ignore_ignition_line) - fw_signature = get_expected_signature(panda, remote_start, hkg_remote_start, ignore_ignition_line) + fw_path = get_expected_firmware_path(panda, remote_start, hkg_remote_start, ignore_ignition_line, tesla_wake) + fw_signature = get_expected_signature(panda, remote_start, hkg_remote_start, ignore_ignition_line, tesla_wake) internal_panda = panda.is_internal() panda_version = "bootstub" if panda.bootstub else panda.get_version() @@ -172,8 +153,9 @@ def main() -> None: remote_start = get_remote_start_boots_comma(params) hkg_remote_start = get_hkg_remote_start_boots_comma(params) ignore_ignition_line = get_ignore_ignition_line(params) + tesla_wake = get_tesla_wake_on_can(params) for serial in panda_serials: - pandas.append(flash_panda(serial, remote_start, hkg_remote_start, ignore_ignition_line)) + pandas.append(flash_panda(serial, remote_start, hkg_remote_start, ignore_ignition_line, tesla_wake)) # Ensure internal panda is present if expected internal_pandas = [panda for panda in pandas if panda.is_internal()] @@ -225,7 +207,7 @@ def main() -> None: first_run = False # run pandad with all connected serials as arguments - if get_remote_start_boots_comma(params) or get_hkg_remote_start_boots_comma(params) or get_ignore_ignition_line(params): + if remote_start or hkg_remote_start or ignore_ignition_line or tesla_wake: os.environ["BOARDD_SKIP_FW_CHECK"] = "1" else: os.environ.pop("BOARDD_SKIP_FW_CHECK", None) diff --git a/selfdrive/pandad/tests/test_tesla_wake_firmware.py b/selfdrive/pandad/tests/test_tesla_wake_firmware.py new file mode 100644 index 0000000000..99d048a7a9 --- /dev/null +++ b/selfdrive/pandad/tests/test_tesla_wake_firmware.py @@ -0,0 +1,278 @@ +from itertools import product +from types import SimpleNamespace + +from cereal import car +import pytest + +from openpilot.selfdrive.pandad import panda_firmware +from openpilot.selfdrive.pandad import pandad + + +class VehicleParams: + def __init__(self, **values): + self.values = values + + def get(self, key): + return self.values.get(key) + + def get_bool(self, key): + return bool(self.get(key)) + + +def car_params(brand="tesla", fingerprint="TESLA_MODEL_3"): + return car.CarParams.new_message(brand=brand, carFingerprint=fingerprint).to_bytes() + + +@pytest.mark.parametrize("h7,ignore", list(product([False, True], repeat=2))) +def test_tesla_variant_uses_ignore_ignition_line(h7, ignore): + app_fn = "panda_h7.bin.signed" if h7 else "panda.bin.signed" + expected = { + (False, False): "panda_tesla_wake.bin.signed", + (False, True): "panda_tesla_wake_can_ignition_only.bin.signed", + (True, False): "panda_h7_tesla_wake.bin.signed", + (True, True): "panda_h7_tesla_wake_can_ignition_only.bin.signed", + }[h7, ignore] + assert pandad.get_selected_firmware_name(app_fn, False, False, ignore, tesla_wake=True) == expected + + +@pytest.mark.parametrize("gm,hkg,ignore,expected", [ + (False, False, False, "panda.bin.signed"), + (False, False, True, "panda_can_ignition_only.bin.signed"), + (True, False, False, "panda_remote.bin.signed"), + (True, False, True, "panda_remote_can_ignition_only.bin.signed"), + (False, True, False, "panda_hkg_remote.bin.signed"), + (False, True, True, "panda_hkg_remote_can_ignition_only.bin.signed"), + (True, True, False, "panda_hkg_remote.bin.signed"), + (True, True, True, "panda_hkg_remote_can_ignition_only.bin.signed"), +]) +@pytest.mark.parametrize("h7", [False, True]) +def test_existing_firmware_selection_unchanged(gm, hkg, ignore, expected, h7): + app_fn = "panda_h7.bin.signed" if h7 else "panda.bin.signed" + if h7: + expected = expected.replace("panda", "panda_h7", 1) + assert pandad.get_selected_firmware_name(app_fn, gm, hkg, ignore) == expected + + +@pytest.mark.parametrize("remote_start,hkg_remote_start", [(True, False), (False, True), (True, True)]) +def test_tesla_wake_rejects_remote_start_firmware(remote_start, hkg_remote_start): + with pytest.raises(ValueError, match="cannot be combined"): + pandad.get_selected_firmware_name("panda_h7.bin.signed", remote_start, hkg_remote_start, False, tesla_wake=True) + + +def test_tesla_wake_preflight_rejects_remote_start_firmware(): + params = VehicleParams(RemoteStartBootsComma=True, HKGRemoteStartBootsComma=False, IgnoreIgnitionLine=False) + with pytest.raises(RuntimeError, match="cannot be enabled"): + panda_firmware.validate_tesla_can_wake_firmware(params, True) + + +def test_firmware_toggle_conflicts_are_symmetric(): + params = VehicleParams(TeslaWakeOnCAN=True, RemoteStartBootsComma=False, HKGRemoteStartBootsComma=False) + assert panda_firmware.firmware_flags_conflict(params, "RemoteStartBootsComma", True) + assert not panda_firmware.firmware_flags_conflict(params, "RemoteStartBootsComma", False) + params.values["RemoteStartBootsComma"] = True + assert panda_firmware.firmware_flags_conflict(params, "TeslaWakeOnCAN", True) + assert not panda_firmware.firmware_flags_conflict(params, "TeslaWakeOnCAN", False) + + +@pytest.mark.parametrize("values,expected", [ + ({}, False), + ({"TeslaWakeOnCAN": True}, False), + ({"TeslaWakeOnCAN": True, "CarMake": "Tesla"}, False), + ({"CarParamsPersistent": car_params()}, False), + ({"TeslaWakeOnCAN": True, "CarParamsPersistent": car_params()}, True), + ({"TeslaWakeOnCAN": True, "CarMake": b"Tesla", "CarParamsPersistent": car_params(fingerprint="TESLA_MODEL_Y")}, True), + ({"TeslaWakeOnCAN": True, "CarMake": "Toyota", "CarParamsPersistent": car_params()}, False), + ({"TeslaWakeOnCAN": True, "CarModel": "TESLA_MODEL_X", "CarParamsPersistent": car_params()}, False), + ({"TeslaWakeOnCAN": True, "CarParams": car_params("toyota", "TOYOTA_PRIUS"), "CarParamsPersistent": car_params()}, False), + ({"TeslaWakeOnCAN": True, "CarParams": b"corrupt", "CarParamsPersistent": car_params()}, False), + ({"TeslaWakeOnCAN": True, "CarParamsPersistent": b"corrupt"}, False), + ({"TeslaWakeOnCAN": True, "CarParamsPersistent": car_params(fingerprint="TESLA_MODEL_X")}, True), + ({"TeslaWakeOnCAN": True, "CarParamsPersistent": car_params(fingerprint="TESLA_MODEL_S_PREAP")}, False), + ({"TeslaWakeOnCAN": True, "CarParamsPersistent": car_params(fingerprint="")}, False), + ({"TeslaWakeOnCAN": True, "CarParamsPersistent": car_params("hyundai", "HYUNDAI_SONATA")}, False), + ({"TeslaWakeOnCAN": True, "CarParamsCache": car_params(), "CarParamsPrevRoute": car_params()}, False), +]) +def test_tesla_wake_requires_enabled_supported_vehicle_without_conflicting_identity(values, expected): + assert hasattr(pandad, "get_tesla_wake_on_can"), "Tesla wake parameter reader is missing" + assert pandad.get_tesla_wake_on_can(VehicleParams(**values)) is expected + + +def test_unregistered_tesla_param_defaults_off(): + class OldParams(VehicleParams): + def get_bool(self, key): + raise pandad.UnknownKeyName(key) + + assert hasattr(pandad, "get_tesla_wake_on_can"), "Tesla wake parameter reader is missing" + assert pandad.get_tesla_wake_on_can(OldParams()) is False + + +def test_missing_tesla_firmware_never_falls_back_to_stock(monkeypatch, tmp_path): + monkeypatch.setattr(pandad, "FW_PATH", str(tmp_path)) + panda = SimpleNamespace(get_mcu_type=lambda: SimpleNamespace(config=SimpleNamespace(app_fn="panda_h7.bin.signed"))) + with pytest.raises(FileNotFoundError, match="panda_h7_tesla_wake"): + pandad.get_expected_firmware_path(panda, False, False, False, tesla_wake=True) + + +def test_selected_tesla_firmware_path_used(monkeypatch, tmp_path): + monkeypatch.setattr(pandad, "FW_PATH", str(tmp_path)) + firmware = tmp_path / "panda_h7_tesla_wake_can_ignition_only.bin.signed" + firmware.touch() + panda = SimpleNamespace(get_mcu_type=lambda: SimpleNamespace(config=SimpleNamespace(app_fn="panda_h7.bin.signed"))) + assert pandad.get_expected_firmware_path(panda, False, False, True, tesla_wake=True) == str(firmware) + + +class FirmwarePanda: + get_signature_from_firmware = staticmethod(pandad.Panda.get_signature_from_firmware) + + def __init__(self, app_fn="panda_h7.bin.signed"): + self.app_fn = app_fn + self.bootstub = False + self.signature = b"old firmware" + self.flashed = [] + + def get_mcu_type(self): + return SimpleNamespace(config=SimpleNamespace(app_fn=self.app_fn)) + + def is_internal(self): + return True + + def get_version(self): + return "test" + + def get_signature(self): + return self.signature + + def flash(self, fn): + self.flashed.append(fn) + self.signature = self.get_signature_from_firmware(fn) + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + +@pytest.mark.parametrize("enabled,filename", [ + (True, "panda_h7_tesla_wake.bin.signed"), + (False, "panda_h7.bin.signed"), +]) +def test_startup_flashes_and_verifies_selected_signature(monkeypatch, tmp_path, enabled, filename): + device = FirmwarePanda() + firmware = tmp_path / filename + firmware.write_bytes(b"s" * 128) + monkeypatch.setattr(pandad, "FW_PATH", str(tmp_path)) + class PandaFactory: + get_signature_from_firmware = staticmethod(FirmwarePanda.get_signature_from_firmware) + + def __new__(cls, serial): + assert serial == "test-panda" + return device + + monkeypatch.setattr(pandad, "Panda", PandaFactory) + assert pandad.flash_panda("test-panda", False, False, False, tesla_wake=enabled) is device + assert device.flashed == [str(firmware)] + assert device.signature == b"s" * 128 + pandad.flash_panda("test-panda", False, False, False, tesla_wake=enabled) + assert device.flashed == [str(firmware)] + + +@pytest.mark.parametrize("available", [False, True]) +def test_manual_updater_uses_tesla_image_and_never_flashes_stock_when_missing(monkeypatch, tmp_path, available): + from openpilot.starpilot.common import starpilot_utilities as utilities + from openpilot.selfdrive.pandad import rivian_long_flasher + + device = FirmwarePanda() + firmware = tmp_path / "panda_h7_tesla_wake.bin.signed" + if available: + firmware.write_bytes(b"m" * 128) + params = VehicleParams(TeslaWakeOnCAN=True, CarParamsPersistent=car_params()) + class PandaFactory: + @staticmethod + def list(): + return ["test-panda"] + + @staticmethod + def usb_list(): + return [] + + def __new__(cls, serial): + assert serial == "test-panda" + return device + + monkeypatch.setattr(utilities, "Panda", PandaFactory) + monkeypatch.setattr(utilities, "Params", lambda: params) + monkeypatch.setattr(utilities, "FW_PATH", str(tmp_path)) + monkeypatch.setattr(rivian_long_flasher, "is_rivian_vehicle", lambda: False) + errors = [] + monkeypatch.setattr(utilities, "capture_exception", errors.append) + removed = [] + utilities.flash_panda(SimpleNamespace(remove=removed.append)) + assert device.flashed == ([str(firmware)] if available else []) + assert bool(errors) is (not available) + if errors: + assert isinstance(errors[0], FileNotFoundError) + assert removed == ["FlashPanda"] + + +def test_other_variants_keep_existing_missing_image_fallback(monkeypatch, tmp_path): + monkeypatch.setattr(pandad, "FW_PATH", str(tmp_path)) + assert pandad.get_expected_firmware_path(FirmwarePanda(), True, True, True) == str(tmp_path / "panda_h7.bin.signed") + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_main_skips_stock_cpp_signature_check_only_for_selected_variant(monkeypatch, enabled): + device = FirmwarePanda() + device.get_usb_serial = lambda: "test-panda" + device.get_type = lambda: b"type" + device.health = lambda: {"heartbeat_lost": False, "som_reset_triggered": False} + device.reset = lambda **kwargs: None + device.close = lambda: None + params = VehicleParams(TeslaWakeOnCAN=enabled, CarParamsPersistent=car_params()) + params.remove = lambda key: params.values.pop(key, None) + params.put = lambda key, value: params.values.update({key: value}) + monkeypatch.setattr(pandad, "Params", lambda: params) + monkeypatch.setattr(pandad, "Panda", SimpleNamespace(list=lambda: ["test-panda"])) + monkeypatch.setattr(pandad, "PandaDFU", SimpleNamespace(list=lambda: [])) + monkeypatch.setattr(pandad, "HARDWARE", SimpleNamespace(has_internal_panda=lambda: True)) + monkeypatch.setattr(pandad, "prepare_rivian_bridge", lambda serials: set()) + selections = [] + + def flash(serial, gm, hkg, ignore, tesla): + selections.append((serial, gm, hkg, ignore, tesla)) + return device + + monkeypatch.setattr(pandad, "flash_panda", flash) + handlers = [] + monkeypatch.setattr(pandad.signal, "signal", lambda signum, handler: handlers.append(handler)) + environments = [] + + def start_process(args, cwd): + environments.append(pandad.os.environ.get("BOARDD_SKIP_FW_CHECK")) + return SimpleNamespace(wait=lambda: handlers[0](2, None), send_signal=lambda signum: None) + + monkeypatch.setattr(pandad.subprocess, "Popen", start_process) + monkeypatch.setenv("BOARDD_SKIP_FW_CHECK", "stale") + pandad.main() + assert selections == [("test-panda", False, False, False, enabled)] + assert environments == (["1"] if enabled else [None]) + + +@pytest.mark.parametrize("enabled,ignore", list(product([False, True], repeat=2))) +def test_ui_preflight_requires_both_board_images_without_writing(monkeypatch, tmp_path, enabled, ignore): + from openpilot.selfdrive.pandad import panda_firmware as firmware + monkeypatch.setattr(firmware, "FW_PATH", str(tmp_path), raising=False) + params = VehicleParams(TeslaWakeOnCAN=not enabled, IgnoreIgnitionLine=ignore) + before = dict(params.values) + preflight = getattr(firmware, "validate_tesla_can_wake_firmware", None) + assert callable(preflight), "firmware preflight must reject missing images before settings are saved" + base = "tesla_wake" if enabled else "" + suffix = "_".join(part for part in (base, "can_ignition_only" if ignore else "") if part) + suffix = "_" + suffix if suffix else "" + names = [f"panda{suffix}.bin.signed", f"panda_h7{suffix}.bin.signed"] + for index, name in enumerate(names): + with pytest.raises(RuntimeError, match="missing"): + preflight(params, enabled) + (tmp_path / name).write_bytes(b"firmware") + preflight(params, enabled) + assert params.values == before diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json index 7b7d0ff201..27ddec6a37 100644 --- a/starpilot/common/assets/device_settings_layout.json +++ b/starpilot/common/assets/device_settings_layout.json @@ -3580,6 +3580,20 @@ "ui_type": "toggle", "settings_tier": "simple" }, + { + "key": "TeslaWakeOnCAN", + "label": "Wake Comma with Tesla", + "description": "When enabled, your comma can wake up when your Tesla wakes up. When disabled, it normally powers on when you press the brake.", + "picker_description": "Allow your comma to wake up with your Tesla.", + "data_type": "bool", + "ui_type": "toggle", + "settings_tier": "simple", + "vehicle_makes": [ + "Tesla" + ], + "requires_capability": "TeslaCANWakeAvailable", + "requires_offroad": true + }, { "key": "HKGRemoteStartBootsComma", "label": "Remote Start Boots Comma (HKG)", diff --git a/starpilot/common/starpilot_utilities.py b/starpilot/common/starpilot_utilities.py index dd040e2bed..39df9f3740 100644 --- a/starpilot/common/starpilot_utilities.py +++ b/starpilot/common/starpilot_utilities.py @@ -22,6 +22,7 @@ from openpilot.common.realtime import DT_DMON, DT_HW from openpilot.system.hardware import HARDWARE from openpilot.system.version import get_build_metadata from panda import Panda, FW_PATH +from openpilot.selfdrive.pandad.panda_firmware import get_firmware_path, get_tesla_wake_on_can from openpilot.starpilot.common.starpilot_variables import EARTH_RADIUS, STARPILOT_API, KONIK_PATH @@ -198,21 +199,6 @@ def extract_zip(zip_file, extract_path): print(f"Extraction completed!") -def get_selected_panda_firmware_name(app_fn, remote_start, hkg_remote_start, ignore_ignition_line): - if not remote_start and not hkg_remote_start and not ignore_ignition_line: - return app_fn - - h7 = app_fn == "panda_h7.bin.signed" - name_parts = ["panda_h7" if h7 else "panda"] - if hkg_remote_start: - name_parts.extend(["hkg", "remote"]) - elif remote_start: - name_parts.append("remote") - if ignore_ignition_line: - name_parts.append("can_ignition_only") - return "_".join(name_parts) + ".bin.signed" - - def flash_panda(params_memory): from openpilot.selfdrive.pandad.rivian_long_flasher import is_rivian_bridge_panda, is_rivian_vehicle @@ -230,6 +216,8 @@ def flash_panda(params_memory): except Exception: ignore_ignition_line = False + tesla_wake = get_tesla_wake_on_can(params) + rivian = is_rivian_vehicle() usb_serials = set(Panda.usb_list()) for serial in Panda.list(): @@ -246,15 +234,8 @@ def flash_panda(params_memory): print(f"Skipping unverified external Black Panda on Rivian {serial}") continue print(f"Flashing Panda {serial}") - flash_fn = None app_fn = panda.get_mcu_type().config.app_fn - selected_fn = get_selected_panda_firmware_name(app_fn, remote_start, hkg_remote_start, ignore_ignition_line) - if selected_fn != app_fn: - candidate = os.path.join(FW_PATH, selected_fn) - if os.path.isfile(candidate): - flash_fn = candidate - else: - print(f"Selected panda firmware missing: {candidate}. Falling back to default firmware.") + flash_fn = get_firmware_path(FW_PATH, app_fn, remote_start, hkg_remote_start, ignore_ignition_line, tesla_wake) panda.flash(fn=flash_fn) except Exception as exception: print(f"Failed to flash Panda {serial}: {exception}") diff --git a/starpilot/common/starpilot_variables.py b/starpilot/common/starpilot_variables.py index 9d4cd6633a..c674b6269a 100644 --- a/starpilot/common/starpilot_variables.py +++ b/starpilot/common/starpilot_variables.py @@ -1599,6 +1599,10 @@ class StarPilotVariables: "TeslaCoopSteering", condition=toggle.car_make == "tesla" and toggle.car_model == TESLA_CAR.TESLA_MODEL_3, ) + toggle.tesla_wake_on_can = self.get_value( + "TeslaWakeOnCAN", + condition=toggle.car_make == "tesla" and toggle.car_model in {TESLA_CAR.TESLA_MODEL_3, TESLA_CAR.TESLA_MODEL_Y, TESLA_CAR.TESLA_MODEL_X}, + ) toggle.rivian_angle_control = self.get_value("RivianAngleControl", condition=toggle.car_make == "rivian") toggle.tethering_config = self.get_value("TetheringEnabled", cast=float) diff --git a/starpilot/system/the_galaxy/assets/mobile/js/api.js b/starpilot/system/the_galaxy/assets/mobile/js/api.js index 555c5237cd..7fdd7134d1 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/api.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/api.js @@ -62,8 +62,9 @@ export const api = { return res.ok ? parse(res) : {} }, - updateParam({ key, value, label }) { + updateParam({ key, value, label, confirmedPandaFirmwareFlash }) { const data = { key, value } + if (confirmedPandaFirmwareFlash === true) data.confirmedPandaFirmwareFlash = true if (label) data.label = label return request("/api/params", { method: "PUT", data }) }, diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js index 9c0b21cd77..5a36e2ba9c 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/GalaxyToggleCard.js @@ -7,6 +7,8 @@ import { import { FavoritesEditor } from "./FavoritesEditor.js" import { t } from "../i18n.js" +const PANDA_FIRMWARE_TOGGLE_KEYS = new Set(["IgnoreIgnitionLine", "RemoteStartBootsComma", "HKGRemoteStartBootsComma", "TeslaWakeOnCAN"]) + export const GalaxyToggleCard = { name: "GalaxyToggleCard", components: { FavoritesEditor }, @@ -76,12 +78,18 @@ export const GalaxyToggleCard = { rollback(prev) { this.$emit("change", { key: this.param.key, value: prev }) }, async commit(nextValue) { if (this.locked || this.updating) return + const firmwareToggle = PANDA_FIRMWARE_TOGGLE_KEYS.has(this.param.key) + if (firmwareToggle && this.values.IsOnroad) return + if (firmwareToggle && !window.confirm(`${this.param.label} requires a Panda firmware update and device reboot.\n\n${nextValue ? "Enable" : "Disable"} ${this.param.label} and flash the Panda now?`)) { + this.rollback(this.value) + return + } const prev = this.value const label = this.lastLabel || "" this.$emit("change", { key: this.param.key, value: nextValue }) this.updating = true try { - const data = await api.updateParam({ key: this.param.key, value: nextValue, label }) + const data = await api.updateParam({ key: this.param.key, value: nextValue, label, ...(firmwareToggle ? { confirmedPandaFirmwareFlash: true } : {}) }) const updated = data?.updated && typeof data.updated === "object" ? data.updated : {} if (Object.prototype.hasOwnProperty.call(updated, this.param.key)) { this.$emit("change", { key: this.param.key, value: updated[this.param.key], ...updated }) @@ -94,9 +102,9 @@ export const GalaxyToggleCard = { this.updating = false } }, - onSwitch(e) { - if (!this.locked) this.commit(!!e.target.checked) - else e.target.checked = !!this.value + async onSwitch(e) { + if (!this.locked) await this.commit(!!e.target.checked) + e.target.checked = !!this.value }, onSelect(e) { if (this.locked) { e.target.value = String(this.value ?? "") ; return } diff --git a/starpilot/system/the_galaxy/tests/test_tesla_can_wake.py b/starpilot/system/the_galaxy/tests/test_tesla_can_wake.py new file mode 100644 index 0000000000..a078f2327c --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_tesla_can_wake.py @@ -0,0 +1,82 @@ +from types import SimpleNamespace +import pytest +from test_navigation_params import _params_client, the_galaxy + + +def client_for(monkeypatch, supported=True, onroad=False): + client, params = _params_client(monkeypatch, {"IsOnroad": onroad, "TeslaWakeOnCAN": False}, "pc") + monkeypatch.setattr(the_galaxy, "_get_param_type_info", lambda: ({"TeslaWakeOnCAN"}, {"TeslaWakeOnCAN": bool})) + monkeypatch.setattr(the_galaxy, "supports_tesla_can_wake", lambda _: supported, raising=False) + monkeypatch.setattr(the_galaxy, "update_starpilot_toggles", lambda: None) + monkeypatch.setattr(the_galaxy, "validate_tesla_can_wake_firmware", lambda *_: None, raising=False) + launches = [] + monkeypatch.setattr(the_galaxy.threading, "Thread", lambda **kw: SimpleNamespace(start=lambda: launches.append(kw["target"]))) + return client, params, launches + + +@pytest.mark.parametrize("supported,onroad,confirmed,status", [ + (False, False, True, 403), (True, True, True, 403), (True, False, False, 409), +]) +def test_tesla_firmware_rejects_unsupported_onroad_or_unconfirmed_write(monkeypatch, supported, onroad, confirmed, status): + client, params, launches = client_for(monkeypatch, supported, onroad) + response = client.put("/api/params", json={"key": "TeslaWakeOnCAN", "value": True, "confirmedPandaFirmwareFlash": confirmed}) + assert response.status_code == status + assert params.values["TeslaWakeOnCAN"] is False + assert not launches + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_tesla_firmware_confirmed_offroad_write_launches_flash(monkeypatch, enabled): + client, params, launches = client_for(monkeypatch) + response = client.put("/api/params", json={"key": "TeslaWakeOnCAN", "value": enabled, "confirmedPandaFirmwareFlash": True}) + assert response.status_code == 200 + assert params.get_bool("TeslaWakeOnCAN") is enabled + assert launches == [the_galaxy._flash_panda_then_reboot] + + +def test_missing_tesla_firmware_does_not_save_or_flash(monkeypatch): + client, params, launches = client_for(monkeypatch) + def unavailable(*_): + raise RuntimeError("Tesla firmware is missing") + monkeypatch.setattr(the_galaxy, "validate_tesla_can_wake_firmware", unavailable) + response = client.put("/api/params", json={"key": "TeslaWakeOnCAN", "value": True, "confirmedPandaFirmwareFlash": True}) + assert response.status_code == 409 + assert "missing" in response.get_json()["error"] + assert params.values["TeslaWakeOnCAN"] is False + assert not launches + + +def test_tesla_wake_rejects_conflicting_remote_start(monkeypatch): + client, params, launches = client_for(monkeypatch) + params.values["RemoteStartBootsComma"] = True + response = client.put("/api/params", json={"key": "TeslaWakeOnCAN", "value": True, "confirmedPandaFirmwareFlash": True}) + assert response.status_code == 409 + assert "remote-start" in response.get_json()["error"] + assert params.values["TeslaWakeOnCAN"] is False + assert not launches + + +def test_remote_start_rejects_enabled_tesla_wake(monkeypatch): + client, params, launches = client_for(monkeypatch) + monkeypatch.setattr( + the_galaxy, + "_get_param_type_info", + lambda: ({"TeslaWakeOnCAN", "RemoteStartBootsComma"}, {"TeslaWakeOnCAN": bool, "RemoteStartBootsComma": bool}), + ) + params.values["TeslaWakeOnCAN"] = True + response = client.put("/api/params", json={"key": "RemoteStartBootsComma", "value": True, "confirmedPandaFirmwareFlash": True}) + assert response.status_code == 409 + assert "remote-start" in response.get_json()["error"] + assert params.values.get("RemoteStartBootsComma", False) is False + assert not launches + + +@pytest.mark.parametrize("supported", [False, True]) +def test_params_payload_reports_tesla_capability_and_off_default(monkeypatch, supported): + client, params, _ = client_for(monkeypatch, supported=supported) + params.values.pop("TeslaWakeOnCAN") + monkeypatch.setattr(the_galaxy, "_get_default_param_values", lambda: {"TeslaWakeOnCAN": "0"}) + response = client.get("/api/params/all") + assert response.status_code == 200 + assert response.get_json()["TeslaCANWakeAvailable"] is supported + assert response.get_json()["TeslaWakeOnCAN"] is False diff --git a/starpilot/system/the_galaxy/tests/test_tesla_can_wake_frontend.cjs b/starpilot/system/the_galaxy/tests/test_tesla_can_wake_frontend.cjs new file mode 100644 index 0000000000..06aa7983bc --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_tesla_can_wake_frontend.cjs @@ -0,0 +1,49 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const root = path.resolve(__dirname, '../../../..'); +const base = path.join(root, 'starpilot/system/the_galaxy/assets/mobile/js'); +const layout = JSON.parse(fs.readFileSync(path.join(root, 'starpilot/common/assets/device_settings_layout.json'))); +const section = layout.find(s => s.name === 'Vehicle'); +const param = section.params.find(p => p.key === 'TeslaWakeOnCAN'); +assert.ok(param, 'Wake on CAN must appear in Vehicle settings'); +const ctx = { console, FavoritesEditor: {}, window: { confirm: () => false }, api: {}, showSnackbar: () => {} }; +vm.createContext(ctx); +function load(file, expose) { + const src = fs.readFileSync(path.join(base, file), 'utf8').replace(/^import[\s\S]*?from [^\n]+\n/gm, '').replace(/export /g, ''); + vm.runInContext(src + '\n' + expose, ctx); +} +load('params.js', 'this.visible = isSettingVisible'); +for (const [make, supported, expected] of [['Tesla', true, true], ['Tesla', false, false], ['Toyota', true, false], ['', false, false]]) { + assert.equal(ctx.visible(section, param, {CarMake:make, TeslaCANWakeAvailable:supported}), expected); +} +load('components/GalaxyToggleCard.js', 'this.card = GalaxyToggleCard'); +(async () => { + const writes = [], changes = []; + ctx.api.updateParam = async data => {writes.push(data); return {}}; + const card = {param, value:false, values:{IsOnroad:false}, locked:false, updating:false, + $emit: (...args) => changes.push(args), rollback: () => {}}; + await ctx.card.methods.commit.call(card, true); + assert.equal(writes.length, 0, 'cancelled firmware confirmation must not write'); + ctx.window.confirm = () => true; + card.values.IsOnroad = true; + await ctx.card.methods.commit.call(card, true); + assert.equal(writes.length, 0, 'onroad firmware writes must be blocked'); + card.values.IsOnroad = false; + await ctx.card.methods.commit.call(card, true); + assert.equal(writes.length, 1); + assert.equal(writes[0].confirmedPandaFirmwareFlash, true); + assert.equal(writes[0].value, true); + const switchEvent = {target:{checked:true}}; + const cancelledCard = {locked:false, value:false, commit: async () => {}}; + await ctx.card.methods.onSwitch.call(cancelledCard, switchEvent); + assert.equal(switchEvent.target.checked, false, 'cancelled switch must restore its displayed state'); + const network = []; + const apiCtx = {fetch:async (url, opts) => {network.push(JSON.parse(opts.body)); return {ok:true,json:async()=>({})}}}; + vm.createContext(apiCtx); + vm.runInContext(fs.readFileSync(path.join(base,'api.js'),'utf8').replace(/export /g,'') + '\nthis.client=api', apiCtx); + await apiCtx.client.updateParam({key:'TeslaWakeOnCAN',value:true,confirmedPandaFirmwareFlash:true}); + assert.equal(network[0].confirmedPandaFirmwareFlash, true); + console.log('Tesla New Galaxy visibility, cancellation, offroad and confirmation passed'); +})().catch(e => {console.error(e); process.exitCode=1}); diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 9542a2a481..9f2176aa0d 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -46,6 +46,7 @@ from openpilot.common.params import ParamKeyFlag, ParamKeyType, Params from openpilot.common.realtime import DT_HW from openpilot.common.swaglog import cloudlog from openpilot.common.time_helpers import system_time_valid +from openpilot.selfdrive.pandad.panda_firmware import firmware_flags_conflict, supports_tesla_can_wake, validate_tesla_can_wake_firmware from openpilot.system.hardware import HARDWARE, PC from openpilot.system.hardware.hw import Paths from openpilot.system.loggerd.deleter import PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE, PRESERVE_COUNT @@ -268,7 +269,7 @@ _TESTING_GROUND_CUSTOM_RESERVED_INTERVAL_S = 15.0 _TESTING_GROUND_CUSTOM_RESERVED_PM = None _TESTING_GROUND_CUSTOM_RESERVED_LOCK = threading.Lock() _TESTING_GROUND_CUSTOM_RESERVED_LAST_PUBLISH_MONO = 0.0 -PANDA_FIRMWARE_TOGGLE_KEYS = {"IgnoreIgnitionLine", "RemoteStartBootsComma", "HKGRemoteStartBootsComma"} +PANDA_FIRMWARE_TOGGLE_KEYS = {"IgnoreIgnitionLine", "RemoteStartBootsComma", "HKGRemoteStartBootsComma", "TeslaWakeOnCAN"} PANDA_FIRMWARE_CONFIRMATION_FIELD = "confirmedPandaFirmwareFlash" _PANDA_FLASH_REBOOT_LOCK = threading.Lock() @@ -6223,11 +6224,24 @@ def setup(app): if params.get_bool("IsOnroad"): return jsonify({"error": "Cannot change PiP Side Camera configuration while driving."}), 403 + if key == "TeslaWakeOnCAN" and not supports_tesla_can_wake(params): + return jsonify({"error": "Wake on CAN is available only for a detected Tesla Model 3, Y or X."}), 403 + if key in PANDA_FIRMWARE_TOGGLE_KEYS and params.get_bool("IsOnroad"): return jsonify({"error": "Cannot flash Panda firmware while driving."}), 403 if key in PANDA_FIRMWARE_TOGGLE_KEYS and data.get(PANDA_FIRMWARE_CONFIRMATION_FIELD) is not True: return jsonify({"error": "Panda firmware changes require confirmation before flashing."}), 409 + enabled = str_val.strip() in ("1", "true", "True") + if key in PANDA_FIRMWARE_TOGGLE_KEYS and firmware_flags_conflict(params, key, enabled): + return jsonify({"error": "Tesla wake cannot be combined with remote-start firmware."}), 409 + + if key == "TeslaWakeOnCAN": + try: + validate_tesla_can_wake_firmware(params, enabled) + except RuntimeError as exc: + return jsonify({"error": str(exc)}), 409 + if key in {"LeadIndicator", "HideLeadMarker"}: enabled = str_val.strip() in ("1", "true", "True") if key == "LeadIndicator": @@ -6568,6 +6582,7 @@ def setup(app): except Exception: result[key] = None + result["TeslaCANWakeAvailable"] = supports_tesla_can_wake(params) result["HasRadar"] = _get_has_radar() result["VehicleParked"] = _get_vehicle_parked() result["AlphaLongitudinalAvailable"] = _get_alpha_longitudinal_available()