diff --git a/core1.c b/core1.c index 0e269ef..c83df54 100644 --- a/core1.c +++ b/core1.c @@ -18,6 +18,25 @@ #define FIFO_VERIFY_TIMEOUT_MS 2000 +// --------------------------------------------------------------------------- +// Watchdog liveness (ISSUES.md H4) +// --------------------------------------------------------------------------- +// The RP2040 watchdog is system-wide. Core 0 owns the actual watchdog_update(); +// core 1 only advances this counter to prove it is still alive. So if EITHER +// core wedges - core 1 stops bumping the heartbeat, or core 0 stops checking it +// and petting - the watchdog is no longer fed and the board resets. + +volatile uint32_t core1_heartbeat = 0; + +void watchdog_feed_core0(void) { + static uint32_t last_seen = 0; + uint32_t hb = core1_heartbeat; + if (hb != last_seen) { + last_seen = hb; + watchdog_update(); + } +} + // --------------------------------------------------------------------------- // Input buffer // Input format: [1-3 digit key ID][6 digit TOTP code]# @@ -78,15 +97,15 @@ static void process_input(void) { // Wait for verdict, feeding watchdog while waiting absolute_time_t deadline = make_timeout_time_ms(FIFO_VERIFY_TIMEOUT_MS); while (true) { - watchdog_update(); + core1_heartbeat++; // prove liveness; core 0 pets the watchdog if (door_verify_mailbox.response_seq == my_seq) { __dmb(); // seq match visible => response_granted is too if (door_verify_mailbox.response_granted) { buzzer_play_success(); buzzer_play_door_open(); - buzzer_on(); + // latch_open() is non-blocking now (schedules its own close), + // so it no longer holds core 1 for the whole grant window. latch_open(); - buzzer_off(); } else { buzzer_play_fail(); } @@ -153,6 +172,6 @@ void main1(void) { } sleep_ms(5); - watchdog_update(); + core1_heartbeat++; // prove liveness to core 0's watchdog feeder } } \ No newline at end of file diff --git a/core1.h b/core1.h index 4d02232..e96ab0c 100644 --- a/core1.h +++ b/core1.h @@ -1,6 +1,20 @@ #ifndef CORE1_H #define CORE1_H +#include + void main1(void); -#endif \ No newline at end of file +// Core 1 liveness heartbeat. Core 1 bumps this on every keypad-loop iteration +// (and while it waits on a door verdict); core 0 watches it in +// watchdog_feed_core0() and only pets the (system-wide) RP2040 watchdog when it +// has advanced. That way a wedged core 1 also lets the watchdog fire, instead +// of core 1 blindly feeding it while core 0 is stuck (ISSUES.md H4). +extern volatile uint32_t core1_heartbeat; + +// Pet the hardware watchdog, but ONLY if core 1's heartbeat advanced since the +// last call. Must be called from core 0 exclusively - from the main service +// loop and from any long core-0 poll (e.g. ntp_sync). Never call from core 1. +void watchdog_feed_core0(void); + +#endif diff --git a/hardware/latch.c b/hardware/latch.c index 5039d19..55b3b42 100644 --- a/hardware/latch.c +++ b/hardware/latch.c @@ -1,7 +1,19 @@ -#include "buzzer.h" #include "latch.h" #include "pico/stdlib.h" +// Id of the pending "close the strike" alarm, 0 when none is scheduled. +static alarm_id_t latch_close_alarm = 0; + +// Timer-alarm callback: de-energise the strike. Runs in the alarm IRQ context, +// so it only touches a GPIO register and clears the stored id. +static int64_t latch_close_cb(alarm_id_t id, void *user_data) { + (void)id; + (void)user_data; + gpio_put(LATCH_PIN, false); + latch_close_alarm = 0; + return 0; // one-shot: do not reschedule +} + void latch_init() { gpio_init(LATCH_PIN); gpio_set_dir(LATCH_PIN, GPIO_OUT); @@ -15,8 +27,26 @@ void latch_init() { gpio_put(LATCH_PIN, false); } +// Energise the strike and schedule its de-energise via a timer alarm, then +// return immediately. This used to sleep for LATCH_OPEN_DELAY ms, which stalled +// the caller (core 1's keypad loop, or core 0's console) for the whole grant +// window and ate the ~8s watchdog margin on the door-open path (ISSUES.md H4). +// The door stays open for the same duration either way. void latch_open() { gpio_put(LATCH_PIN, true); - sleep_ms(LATCH_OPEN_DELAY); - gpio_put(LATCH_PIN, false); -} \ No newline at end of file + + // A second open within the window supersedes the pending close, so the + // strike stays energised for a fresh full delay instead of closing early. + if (latch_close_alarm > 0) { + cancel_alarm(latch_close_alarm); + latch_close_alarm = 0; + } + + latch_close_alarm = add_alarm_in_ms(LATCH_OPEN_DELAY, latch_close_cb, NULL, true); + if (latch_close_alarm < 0) { + // Could not schedule the auto-close: fail safe by de-energising now + // rather than risk leaving the strike open indefinitely. + gpio_put(LATCH_PIN, false); + latch_close_alarm = 0; + } +} diff --git a/main.c b/main.c index 4583119..25ee3a7 100644 --- a/main.c +++ b/main.c @@ -26,6 +26,10 @@ door_verify_mailbox_t door_verify_mailbox; +// Max boot-time NTP sync attempts before booting into a degraded, time-not-set +// state instead of blocking forever (ISSUES.md H4). +#define BOOT_NTP_MAX_ATTEMPTS 3 + static void boot_network(void) { wifi_config_t cfg; if (!storage_wifi_get(&cfg)) { @@ -41,15 +45,29 @@ static void boot_network(void) { return; } - // Block until first NTP sync succeeds - beep + retry on failure + // Bound the boot-time NTP sync. A blackholed UDP/123 or a poisoned DNS must + // not wedge boot forever: that would leave the door dead AND the serial + // console unreachable, with no path to recovery (ISSUES.md H4). After a few + // attempts, boot anyway into a degraded "time-not-set" state - the console + // comes up and ntp_task() keeps retrying the first sync in the service loop. + // The door stays closed while the clock is unset: totp_verify() fails closed + // when clock_get_unix_time() reports the RTC was never set (consistent with + // M1's intent). printf("[main] waiting for NTP sync...\r\n"); - while (!ntp_sync()) { - printf("[main] NTP sync failed, retrying in %ds...\r\n", NTP_RETRY_INTERVAL_S); + for (int attempt = 1; attempt <= BOOT_NTP_MAX_ATTEMPTS; attempt++) { + if (ntp_sync()) { + printf("[main] NTP sync ok\r\n"); + return; + } + printf("[main] NTP sync failed (%d/%d), retrying in %ds...\r\n", attempt, + BOOT_NTP_MAX_ATTEMPTS, NTP_RETRY_INTERVAL_S); buzzer_beep_short(); - sleep_ms(NTP_RETRY_INTERVAL_S * 1000); + if (attempt < BOOT_NTP_MAX_ATTEMPTS) + sleep_ms(NTP_RETRY_INTERVAL_S * 1000); } - printf("[main] NTP sync ok\r\n"); + printf("[main] NTP unavailable - booting in degraded (time-not-set) mode\r\n"); + printf("[main] door disabled until time is set; console available, NTP retrying\r\n"); } static void core0_handle_door_verify(void) { @@ -96,8 +114,6 @@ int main(void) { buzzer_beep_short(); - watchdog_enable(8000, true); // 8 second timeout, pause on debug - // Core 1 must be running and ready before any flash writes multicore_launch_core1(main1); multicore_fifo_pop_blocking(); // wait for core 1 ready signal @@ -127,7 +143,16 @@ int main(void) { console_init(); + // Enable the watchdog only now that boot is complete. The boot path has + // legitimately long single-core blocking (recovery beeps, WiFi association, + // the bounded NTP sync) that no single core can pet within the RP2040's + // ~8s watchdog ceiling, and boot is already bounded on every path above. + // From here the service loop feeds the watchdog, gated on core 1's + // heartbeat, so a wedge on EITHER core triggers a reset (ISSUES.md H4). + watchdog_enable(8000, true); // 8 second timeout, pause on debug + while (true) { + watchdog_feed_core0(); // pets iff core 1's heartbeat advanced core0_handle_door_verify(); console_task(); wifi_task(); diff --git a/network/ntp.c b/network/ntp.c index 735d2d5..fec6327 100644 --- a/network/ntp.c +++ b/network/ntp.c @@ -1,5 +1,6 @@ #include "ntp.h" #include "wifi.h" +#include "core1.h" #include "hardware/buzzer.h" #include "hardware/clock.h" #include "version.h" @@ -270,6 +271,11 @@ bool ntp_sync(void) { // Poll until done or timeout absolute_time_t deadline = make_timeout_time_ms(NTP_TIMEOUT_S * 1000); while (ntp_state != NTP_STATE_SUCCESS && ntp_state != NTP_STATE_FAILED) { + // This poll runs on core 0 and can block for up to NTP_TIMEOUT_S, which + // exceeds the watchdog window - keep feeding it (still gated on core 1's + // heartbeat) so a reachable-but-slow server does not trip a reset while + // a genuinely wedged core still does (ISSUES.md H4). + watchdog_feed_core0(); cyw43_arch_poll(); sleep_ms(10); if (time_reached(deadline)) { @@ -290,11 +296,25 @@ bool ntp_sync(void) { } void ntp_task(void) { - if (!synced) - return; if (!wifi_is_connected()) return; + if (!synced) { + // Degraded "time-not-set" state after a bounded boot (ISSUES.md H4): + // keep retrying the first sync so the door recovers once NTP is + // reachable again. Spaced out (not every tick) so the blocking sync + // does not monopolise core 0 and starve the console; retried silently + // to avoid nagging the buzzer every interval while NTP stays blocked. + static uint64_t last_retry_us = 0; + uint64_t now_us = time_us_64(); + if (last_retry_us != 0 && + (now_us - last_retry_us) / 1000000ULL < (uint64_t)NTP_DEGRADED_RETRY_S) + return; + last_retry_us = now_us; + ntp_sync(); + return; + } + uint64_t elapsed_us = time_us_64() - last_sync_monotonic_us; uint32_t elapsed_s = (uint32_t)(elapsed_us / 1000000ULL); diff --git a/network/ntp.h b/network/ntp.h index 71adc67..0cd9988 100644 --- a/network/ntp.h +++ b/network/ntp.h @@ -6,6 +6,7 @@ #define NTP_RESYNC_INTERVAL_S (30 * 60) // 30 minutes #define NTP_RETRY_INTERVAL_S 5 // retry on boot failure +#define NTP_DEGRADED_RETRY_S 30 // first-sync retry cadence in degraded mode (H4) #define NTP_TIMEOUT_S 15 // per-sync timeout // Max backward correction tolerated on a single accepted sync, kept well under diff --git a/network/wifi.c b/network/wifi.c index 05c05a4..3e00d05 100644 --- a/network/wifi.c +++ b/network/wifi.c @@ -18,7 +18,12 @@ bool wifi_connect(const char *ssid, const char *password) { } printf("[wifi] connecting to '%s'...\r\n", ssid); - int rc = cyw43_arch_wifi_connect_timeout_ms(ssid, password, CYW43_AUTH_WPA2_AES_PSK, 15000); + // Cap the blocking association attempt below the ~8s watchdog window: since + // core 0 now feeds the watchdog itself (ISSUES.md H4), a 15s blocking + // connect on the wifi_task() reconnect path would trip a reset mid-attempt + // and reboot-loop while the AP is slow/unreachable. A failed attempt is + // retried by wifi_task() on its next tick. + int rc = cyw43_arch_wifi_connect_timeout_ms(ssid, password, CYW43_AUTH_WPA2_AES_PSK, 6000); if (rc) { printf("[wifi] connect failed: %d\r\n", rc); diff --git a/serial/commands_system.c b/serial/commands_system.c index 4b92f9e..bdd3cac 100644 --- a/serial/commands_system.c +++ b/serial/commands_system.c @@ -64,11 +64,13 @@ void cmd_status(int argc, char **argv) { printf("ntp: not synced\r\n"); } - // Keys (admin only: key inventory is target-selection data) + // Keys (admin only: key inventory is target-selection data). Declared at + // function scope so the scrub below always runs, even on the non-admin path + // where the array stays zero-initialised. + static key_record_t keys[BACKUP_MAX_KEYS]; if (commands_is_admin()) { - static key_record_t keys[BACKUP_MAX_KEYS]; - int count = storage_key_list(keys, BACKUP_MAX_KEYS); - int enabled = 0, corrupt = 0; + int count = storage_key_list(keys, BACKUP_MAX_KEYS); + int enabled = 0, corrupt = 0; for (int i = 0; i < count; i++) { if (!keys[i].is_checksum_valid) corrupt++; diff --git a/test/stub/pico/time.h b/test/stub/pico/time.h index 1ce6eda..1f175cc 100644 --- a/test/stub/pico/time.h +++ b/test/stub/pico/time.h @@ -18,4 +18,12 @@ absolute_time_t make_timeout_time_ms(uint32_t ms); bool time_reached(absolute_time_t t); uint64_t time_us_64(void); +/* Timer alarms, used by the non-blocking latch (hardware/latch.c). */ +typedef int32_t alarm_id_t; +typedef int64_t (*alarm_callback_t)(alarm_id_t id, void *user_data); + +alarm_id_t add_alarm_in_ms(uint32_t ms, alarm_callback_t callback, void *user_data, + bool fire_if_past); +bool cancel_alarm(alarm_id_t alarm_id); + #endif