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

+92
View File
@@ -0,0 +1,92 @@
#include "system/boot_target.h"
#include <Arduino.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include "system/system_store.h"
static const esp_partition_t *appPartition(esp_partition_subtype_t subtype)
{
return esp_partition_find_first(ESP_PARTITION_TYPE_APP, subtype, NULL);
}
// The partition starts with a firmware image (full check happens in esp_ota_set_boot_partition)
static bool holdsFirmware(const esp_partition_t *partition)
{
esp_app_desc_t desc;
return partition && esp_ota_get_partition_description(partition, &desc) == ESP_OK;
}
bool recoveryAvailable()
{
return holdsFirmware(appPartition(ESP_PARTITION_SUBTYPE_APP_FACTORY));
}
void rebootToRecovery(RecoveryReason reason)
{
const esp_partition_t *recovery = appPartition(ESP_PARTITION_SUBTYPE_APP_FACTORY);
if (!holdsFirmware(recovery))
{
Serial.println("No recovery app on this flash");
return;
}
Serial.printf("Restarting into the recovery app: %s\n", recoveryReasonText(reason));
setRecoveryReason(reason);
// Selecting factory clears otadata; the recovery app sets the clock firmware again later
if (esp_ota_set_boot_partition(recovery) != ESP_OK)
{
Serial.println("Could not select the recovery app");
return;
}
delay(100);
ESP.restart();
}
const char *runningPartitionLabel()
{
return esp_ota_get_running_partition()->label;
}
bool mainFirmwareAvailable()
{
return holdsFirmware(appPartition(ESP_PARTITION_SUBTYPE_APP_OTA_0)) ||
holdsFirmware(appPartition(ESP_PARTITION_SUBTYPE_APP_OTA_1));
}
static bool startSlot(const esp_partition_t *partition)
{
if (!holdsFirmware(partition))
return false;
// Verifies the whole image; fails for a broken or half written firmware
if (esp_ota_set_boot_partition(partition) != ESP_OK)
return false;
Serial.printf("Starting the clock firmware in %s\n", partition->label);
delay(100);
ESP.restart();
return true;
}
void startMainFirmware()
{
const esp_partition_t *ota0 = appPartition(ESP_PARTITION_SUBTYPE_APP_OTA_0);
const esp_partition_t *ota1 = appPartition(ESP_PARTITION_SUBTYPE_APP_OTA_1);
bool last_was_ota1 = loadMainSlot() == "ota_1";
if (last_was_ota1)
startSlot(ota1) || startSlot(ota0);
else
startSlot(ota0) || startSlot(ota1);
Serial.println("No valid clock firmware found");
}
String slotDescription(const char *label)
{
const esp_partition_t *partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, label);
if (!holdsFirmware(partition))
return "empty";
// The app description holds the Arduino core's version, so use the one the clock
// firmware stored when it started
String version = loadSlotVersion(label);
return version.isEmpty() ? "firmware (version unknown)" : "version " + version;
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <Arduino.h>
#include "logic/boot_policy.h"
// Switching between the clock firmware (ota_0 / ota_1) and the recovery app (factory).
// See partitions.csv.
// The flash has a recovery app (only with partitions.csv, after the one-time USB install)
bool recoveryAvailable();
// Restart into the recovery app. Returns only when there is no recovery app.
void rebootToRecovery(RecoveryReason reason);
// Label of the partition this firmware runs from ("factory", "ota_0", "ota_1")
const char *runningPartitionLabel();
// Recovery app: an ota slot holds a clock firmware
bool mainFirmwareAvailable();
// Recovery app: restart into the clock firmware (the last one that ran, else the other
// slot). Returns only when no slot holds a valid firmware.
void startMainFirmware();
// The firmware in an ota slot for the recovery page, e.g. "version 2.1.0" or "empty"
String slotDescription(const char *label);
+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);
+7 -4
View File
@@ -10,10 +10,13 @@
// - Watchdog: loop() must keep running; a hang longer than WATCHDOG_TIMEOUT_S restarts
// the ESP32 (and counts as a crash).
// - Crash counter (kept in RTC memory over resets): after a crash the ESP32 first runs
// only OTA for a while, after SAFE_MODE_CRASHES crashes in a row it goes to safe mode.
// The decisions are in logic/boot_policy.
// only OTA for a while, after SAFE_MODE_CRASHES crashes in a row it goes to safe mode
// (the recovery app). The decisions are in logic/boot_policy.
// - Power cycles: switching the power on POWER_CYCLES_FOR_RECOVERY times, each time for
// less than POWER_CYCLE_WINDOW, starts the recovery app.
// Call first in setup(): reads the reset reason and updates the crash counter
// Call first in setup(): reads the reset reason, updates the crash counter, and restarts
// into the recovery app after quick power cycles
BootMode firmwareGuardBegin();
// Safe mode is over: allow one more try of the main code
@@ -28,5 +31,5 @@ void firmwareGuardStopWatchdog();
// Call every loop(): feeds the watchdog, marks a new firmware as good, clears the counter
void firmwareGuardLoop(bool wifi_connected);
// Short status for Home Assistant, e.g. "2.1.0, ok, 0 crashes"
// Short status for Home Assistant, e.g. "2.1.0, ok, 0 crashes, recovery ok"
const char *firmwareGuardStatus();
+98
View File
@@ -0,0 +1,98 @@
#include "system/system_store.h"
#include <Preferences.h>
#include "credentials.h"
static const char *NAMESPACE = "flipdot";
void loadWifiCredentials(String &ssid, String &password)
{
Preferences prefs;
prefs.begin(NAMESPACE, true);
ssid = prefs.getString("wifi_ssid", "");
password = prefs.getString("wifi_pass", "");
prefs.end();
if (ssid.isEmpty())
{
ssid = WIFI_SSID;
password = WIFI_PASSWORD;
}
}
void saveWifiCredentials(const String &ssid, const String &password)
{
Preferences prefs;
prefs.begin(NAMESPACE, false);
prefs.putString("wifi_ssid", ssid);
prefs.putString("wifi_pass", password);
prefs.end();
}
void setRecoveryReason(RecoveryReason reason)
{
Preferences prefs;
prefs.begin(NAMESPACE, false);
prefs.putUChar("rec_reason", (uint8_t)reason);
prefs.end();
}
RecoveryReason takeRecoveryReason()
{
Preferences prefs;
prefs.begin(NAMESPACE, false);
uint8_t reason = prefs.getUChar("rec_reason", 0);
if (reason)
prefs.putUChar("rec_reason", 0);
prefs.end();
return reason <= (uint8_t)RecoveryReason::PowerCycle ? (RecoveryReason)reason : RecoveryReason::None;
}
void saveMainSlot(const char *label, const char *version)
{
String version_key = String("ver_") + label;
Preferences prefs;
prefs.begin(NAMESPACE, false);
if (prefs.getString("main_slot", "") != label)
prefs.putString("main_slot", label);
if (prefs.getString(version_key.c_str(), "") != version)
prefs.putString(version_key.c_str(), version);
prefs.end();
}
String loadMainSlot()
{
Preferences prefs;
prefs.begin(NAMESPACE, true);
String label = prefs.getString("main_slot", "");
prefs.end();
return label;
}
String loadSlotVersion(const char *label)
{
String version_key = String("ver_") + label;
Preferences prefs;
prefs.begin(NAMESPACE, true);
String version = prefs.getString(version_key.c_str(), "");
prefs.end();
return version;
}
uint8_t loadPowerOnCount()
{
Preferences prefs;
prefs.begin(NAMESPACE, true);
uint8_t count = prefs.getUChar("power_ons", 0);
prefs.end();
return count;
}
void savePowerOnCount(uint8_t count)
{
Preferences prefs;
prefs.begin(NAMESPACE, false);
if (prefs.getUChar("power_ons", 0) != count)
prefs.putUChar("power_ons", count);
prefs.end();
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <Arduino.h>
#include "logic/boot_policy.h"
// Settings in NVS, shared by the clock firmware and the recovery app (both read the same
// NVS partition). Survives power loss and firmware updates.
// WiFi set in the recovery web page, or WIFI_SSID / WIFI_PASSWORD from secrets.h
void loadWifiCredentials(String &ssid, String &password);
void saveWifiCredentials(const String &ssid, const String &password);
// Why the recovery app is started; take...() reads and clears it
void setRecoveryReason(RecoveryReason reason);
RecoveryReason takeRecoveryReason();
// The clock firmware that started last: its partition label ("ota_0" / "ota_1") and
// version, stored per slot for the recovery page
void saveMainSlot(const char *label, const char *version);
String loadMainSlot();
String loadSlotVersion(const char *label);
// Quick power ons in a row (see powerCyclesRequestRecovery)
uint8_t loadPowerOnCount();
void savePowerOnCount(uint8_t count);