Add ESP32 recovery app with WiFi setup portal

- New flash layout (partitions.csv): factory = recovery app, ota_0/ota_1 =
  clock firmware; esp32_usb uploads to ota_0 so it never overwrites it
- src/recovery: WiFi + OTA + web page only. Opens the password protected
  'FlipDot-Setup' AP with a captive portal when WiFi fails, lets you pick
  another WiFi (stored in NVS, used by both apps), start the clock or stay
- Recovery is started when the clock firmware can't connect to WiFi,
  crashes 3 times, after 3 quick power cycles, from a new Home Assistant
  button, or by the bootloader when no clock firmware works
- Shared NVS store and boot switching in src/esp32/system
- Clock firmware keeps working on the old flash layout (falls back to
  OTA-only safe mode)
- README: recovery app, one-time USB installation; tests: 65 total

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-13 01:21:04 +02:00
1 parent ddbdf7e65f
commit 4749f9e596
25 files changed
+979 -29

No files matched your search

+38 -4
View File
@@ -6,6 +6,8 @@
#include <esp_task_wdt.h>
#include "config.h"
#include "system/boot_target.h"
#include "system/system_store.h"
// Survives crashes and software resets, not a power loss
static const uint32_t RTC_MAGIC = 0xF11D0C0A;
@@ -16,7 +18,8 @@ static uint32_t main_start = 0; // millis() when the main code started
static bool watchdog_running = false;
static bool firmware_pending = false; // new OTA firmware, not yet marked as good
static bool crash_count_cleared = false;
static char status[64];
static bool power_on_count_cleared = false;
static char status[80];
// The Arduino core marks a new OTA firmware as good right at boot. Returning true here
// leaves that to us, so the bootloader can roll back a firmware that fails early.
@@ -70,16 +73,40 @@ static const char *firmwareState()
static void updateStatus()
{
snprintf(status, sizeof(status), "%s, %s, %u crashes", FIRMWARE_VERSION, firmwareState(),
rtc_magic == RTC_MAGIC ? rtc_crash_count : 0);
snprintf(status, sizeof(status), "%s, %s, %u crashes, %s", FIRMWARE_VERSION, firmwareState(),
rtc_magic == RTC_MAGIC ? rtc_crash_count : 0,
recoveryAvailable() ? "recovery ok" : "no recovery app");
}
// Switching the power on and off quickly a few times starts the recovery app. Checked
// before anything else, so it works even when the rest of this firmware is broken.
static void checkPowerCycles(ResetKind reset)
{
if (reset != ResetKind::PowerOn)
return;
uint8_t count = loadPowerOnCount() + 1;
if (powerCyclesRequestRecovery(count) && recoveryAvailable())
{
savePowerOnCount(0);
rebootToRecovery(RecoveryReason::PowerCycle);
}
savePowerOnCount(count);
}
BootMode firmwareGuardBegin()
{
ResetKind reset = resetKind();
checkPowerCycles(reset);
uint8_t count = rtc_magic == RTC_MAGIC ? rtc_crash_count : 0;
BootDecision decision = decideBoot(resetKind(), count);
BootDecision decision = decideBoot(reset, count);
setCrashCount(decision.crash_count);
// Tells the recovery app which slot to start and which version is in it
const char *label = runningPartitionLabel();
if (strncmp(label, "ota_", 4) == 0)
saveMainSlot(label, FIRMWARE_VERSION);
esp_ota_img_states_t state;
firmware_pending = esp_ota_get_state_partition(esp_ota_get_running_partition(), &state) == ESP_OK &&
state == ESP_OTA_IMG_PENDING_VERIFY;
@@ -126,6 +153,13 @@ void firmwareGuardLoop(bool wifi_connected)
Serial.println("New firmware marked as good");
}
// Running long enough after a power on: it was not a quick power cycle
if (!power_on_count_cleared && millis() >= POWER_CYCLE_WINDOW)
{
savePowerOnCount(0);
power_on_count_cleared = true;
}
if (!crash_count_cleared && shouldClearCrashCount(running))
{
setCrashCount(0);