Public Access
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:
1 parent
ddbdf7e65f
commit
4749f9e596
25 files changed
+979
-29
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user