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

+153
View File
@@ -0,0 +1,153 @@
// Recovery app (factory partition): WiFi setup portal and OTA, nothing else. Started when
// the clock firmware can't connect to WiFi, crashes repeatedly, after quick power cycles,
// from the Home Assistant button, or by the bootloader when no clock firmware works.
// It never drives the display. See README.md "Recovery app".
#include <Arduino.h>
#include <ArduinoOTA.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include "config.h"
#include "credentials.h"
#include "logic/boot_policy.h"
#include "recovery_state.h"
#include "system/boot_target.h"
#include "system/system_store.h"
#include "web_portal.h"
// Retry the stored WiFi this often while it is not connected
static const uint32_t STATION_RETRY_TIME = 30000;
// Close the setup AP this long after the stored WiFi works again
static const uint32_t AP_CLOSE_DELAY = 120000;
static const uint32_t RECOVERY_WATCHDOG_S = 60;
RecoveryState recovery;
static uint32_t station_lost_since = 0;
static uint32_t station_connected_since = 0;
static bool watchdog_running = false;
static bool main_available = false;
void connectStation()
{
String ssid, password;
loadWifiCredentials(ssid, password);
Serial.printf("Connecting to WiFi %s\n", ssid.c_str());
WiFi.disconnect();
WiFi.begin(ssid.c_str(), password.c_str());
recovery.station_attempt = millis();
}
static void openAccessPoint()
{
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(SETUP_AP_SSID, SETUP_AP_PASSWORD);
portalStartCaptiveDns();
recovery.ap_running = true;
Serial.printf("Setup WiFi \"%s\" open at %s\n", SETUP_AP_SSID, WiFi.softAPIP().toString().c_str());
}
static void closeAccessPoint()
{
portalStopCaptiveDns();
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
recovery.ap_running = false;
Serial.println("Setup WiFi closed");
}
static void updateWifi()
{
uint32_t now = millis();
if (WiFi.status() == WL_CONNECTED)
{
station_lost_since = now;
if (recovery.ap_running && now - station_connected_since >= AP_CLOSE_DELAY &&
WiFi.softAPgetStationNum() == 0)
closeAccessPoint();
return;
}
station_connected_since = now;
if (!recovery.ap_running && now - station_lost_since >= SETUP_AP_DELAY)
openAccessPoint();
// Connection attempts disturb the access point, so don't retry while someone uses it
bool ap_in_use = recovery.ap_running && WiFi.softAPgetStationNum() > 0;
if (!ap_in_use && now - recovery.station_attempt >= STATION_RETRY_TIME)
connectStation();
}
// Double blink: recovery app is running
static void updateLed()
{
uint32_t t = millis() % 1500;
digitalWrite(LED_PIN, t < 100 || (t >= 250 && t < 350));
}
void setup()
{
Serial.begin(115200);
Serial.println("FlipDot recovery app");
// Display off: relay off, and no high TX line that would power the Nano
digitalWrite(RELAY_PIN, HIGH);
pinMode(RELAY_PIN, OUTPUT);
pinMode(NANO_TX_PIN, INPUT);
pinMode(LED_PIN, OUTPUT);
recovery.reason = takeRecoveryReason();
main_available = mainFirmwareAvailable();
Serial.printf("Reason: %s\n", recoveryReasonText(recovery.reason));
WiFi.persistent(false);
WiFi.setAutoReconnect(false); // retries are done in updateWifi()
WiFi.setHostname(HOSTNAME);
WiFi.mode(WIFI_STA);
connectStation();
ArduinoOTA.setHostname(HOSTNAME);
ArduinoOTA.onStart([]() {
// The update blocks loop() until it is done
if (watchdog_running)
esp_task_wdt_delete(NULL);
watchdog_running = false;
Serial.println("OTA update started");
});
ArduinoOTA.onEnd([]() { Serial.println("OTA update done, starting the new firmware"); });
ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("OTA error %u, restarting\n", error);
ESP.restart();
});
ArduinoOTA.begin();
portalBegin();
// A hang restarts the ESP32; the bootloader then starts this recovery app again
esp_task_wdt_init(RECOVERY_WATCHDOG_S, true);
esp_task_wdt_add(NULL);
watchdog_running = true;
}
void loop()
{
if (watchdog_running)
esp_task_wdt_reset();
ArduinoOTA.handle();
portalLoop();
updateWifi();
updateLed();
bool wifi_connected = WiFi.status() == WL_CONNECTED;
bool automatic = !recovery.stay &&
recoveryShouldStartMain(recovery.reason, millis(), wifi_connected, main_available);
if (recovery.start_main || automatic)
{
recovery.start_main = false;
startMainFirmware(); // returns only when no slot holds a valid firmware
recovery.stay = true;
}
delay(2);
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <stdint.h>
#include "logic/boot_policy.h"
// State of the recovery app, shared by main.cpp and the web portal
struct RecoveryState
{
RecoveryReason reason = RecoveryReason::None;
bool stay = false; // "Stay in recovery" pressed: never start the clock firmware automatically
bool ap_running = false; // the FlipDot-Setup access point is open
bool start_main = false; // "Start clock firmware" pressed
uint32_t station_attempt = 0; // millis() of the last WiFi.begin()
};
extern RecoveryState recovery;
// Connect (again) to the WiFi stored in NVS
void connectStation();
+204
View File
@@ -0,0 +1,204 @@
#include "web_portal.h"
#include <DNSServer.h>
#include <WebServer.h>
#include <WiFi.h>
#include "config.h"
#include "recovery_state.h"
#include "system/boot_target.h"
#include "system/system_store.h"
static WebServer server(80);
static DNSServer dns;
static bool dns_running = false;
// Network scan, refreshed when the page is opened and the last scan is old
static const uint32_t SCAN_MAX_AGE = 30000;
static uint32_t last_scan = 0;
static bool scan_started = false;
static String htmlEscape(const String &text)
{
String out;
out.reserve(text.length());
for (char c : text)
{
switch (c)
{
case '&': out += "&amp;"; break;
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '"': out += "&quot;"; break;
case '\'': out += "&#39;"; break;
default: out += c;
}
}
return out;
}
static void startScanIfOld()
{
if (WiFi.scanComplete() == WIFI_SCAN_RUNNING)
return;
if (!scan_started || millis() - last_scan >= SCAN_MAX_AGE)
{
WiFi.scanDelete();
WiFi.scanNetworks(true); // async: the page shows the previous result meanwhile
scan_started = true;
last_scan = millis();
}
}
static String networkOptions()
{
String options;
int count = WiFi.scanComplete();
for (int i = 0; i < count; i++)
{
String ssid = htmlEscape(WiFi.SSID(i));
if (ssid.isEmpty())
continue;
options += "<option value=\"" + ssid + "\">" + ssid + " (" + WiFi.RSSI(i) + " dBm)</option>";
}
return options;
}
static String page(const String &title, const String &body)
{
return "<!doctype html><html><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
"<title>" + title + "</title><style>"
"body{font-family:sans-serif;max-width:32em;margin:1em auto;padding:0 1em;line-height:1.4}"
"h1{font-size:1.4em}h2{font-size:1.1em;margin-top:1.5em}"
"input,select,button{font-size:1em;padding:.4em;margin:.2em 0;width:100%;box-sizing:border-box}"
"button{cursor:pointer}td{padding:.1em .6em .1em 0;vertical-align:top}code{background:#eee;padding:0 .2em}"
"</style></head><body><h1>" + title + "</h1>" + body + "</body></html>";
}
static String row(const String &label, const String &value)
{
return "<tr><td>" + label + "</td><td>" + value + "</td></tr>";
}
static void handleRoot()
{
startScanIfOld();
String ssid, password;
loadWifiCredentials(ssid, password);
bool connected = WiFi.status() == WL_CONNECTED;
String main_slot = loadMainSlot();
String body = "<p>The clock is paused. Reason: <b>" + String(recoveryReasonText(recovery.reason)) + "</b>.</p><table>";
body += row("WiFi", connected ? "connected to " + htmlEscape(WiFi.SSID()) + ", " + WiFi.localIP().toString()
: "not connected (trying " + htmlEscape(ssid) + ")");
if (recovery.ap_running)
body += row("Setup WiFi", String(SETUP_AP_SSID) + ", " + WiFi.softAPIP().toString());
body += row("ota_0", slotDescription("ota_0") + (main_slot == "ota_0" ? " (last used)" : ""));
body += row("ota_1", slotDescription("ota_1") + (main_slot == "ota_1" ? " (last used)" : ""));
String next;
if (recovery.stay)
next = "stays in recovery until you start it";
else if (recovery.reason == RecoveryReason::Wifi)
next = "starts as soon as the WiFi works";
else
next = "starts automatically in " + String((RECOVERY_TIME - min<uint32_t>(millis(), RECOVERY_TIME)) / 60000 + 1) + " min";
body += row("Clock", next);
body += "</table>";
body += "<form method=\"post\" action=\"/start\"><button>Start the clock firmware now</button></form>";
if (!recovery.stay)
body += "<form method=\"post\" action=\"/stay\"><button>Stay in recovery</button></form>";
body += "<h2>WiFi</h2><form method=\"post\" action=\"/wifi\">"
"<select onchange=\"document.getElementById('s').value=this.value\">"
"<option value=\"\">Networks nearby...</option>" + networkOptions() + "</select>"
"<input id=\"s\" name=\"ssid\" placeholder=\"WiFi name\" value=\"" + htmlEscape(ssid) + "\" required>"
"<input name=\"password\" type=\"password\" placeholder=\"Password\">"
"<button>Save and connect</button></form>";
String ip = connected ? WiFi.localIP().toString() : WiFi.softAPIP().toString();
body += "<h2>Firmware update</h2><p>Upload a new clock firmware from the PC:<br>"
"<code>pio run -e esp32 -t upload --upload-port " + ip + "</code><br>"
"It is written to ota_0 and starts right after the upload.</p>";
server.send(200, "text/html", page("FlipDot recovery", body));
}
static void handleWifi()
{
String ssid = server.arg("ssid");
String password = server.arg("password");
ssid.trim();
if (ssid.isEmpty())
{
server.send(400, "text/html", page("FlipDot recovery", "<p>Please enter a WiFi name.</p><p><a href=\"/\">Back</a></p>"));
return;
}
saveWifiCredentials(ssid, password);
server.send(200, "text/html",
page("FlipDot recovery",
"<p>Saved. Connecting to <b>" + htmlEscape(ssid) + "</b>...</p>"
"<p>When it works, the clock starts again (if the WiFi was the problem). "
"If not, connect to <b>" SETUP_AP_SSID "</b> again and check the password.</p>"
"<p><a href=\"/\">Back</a></p>"));
delay(500); // let the page reach the browser before the WiFi changes
connectStation();
}
static void handleStart()
{
server.send(200, "text/html", page("FlipDot recovery", "<p>Starting the clock firmware...</p>"));
delay(500);
recovery.start_main = true;
}
static void handleStay()
{
recovery.stay = true;
server.sendHeader("Location", "/");
server.send(303);
}
// Captive portal: on the setup WiFi every unknown page or host leads to the recovery page
static void handleNotFound()
{
if (recovery.ap_running && server.client().localIP() == WiFi.softAPIP())
{
server.sendHeader("Location", "http://" + WiFi.softAPIP().toString() + "/");
server.send(302);
return;
}
server.send(404, "text/plain", "Not found");
}
void portalBegin()
{
server.on("/", HTTP_GET, handleRoot);
server.on("/wifi", HTTP_POST, handleWifi);
server.on("/start", HTTP_POST, handleStart);
server.on("/stay", HTTP_POST, handleStay);
server.onNotFound(handleNotFound);
server.begin();
}
void portalLoop()
{
if (dns_running)
dns.processNextRequest();
server.handleClient();
}
void portalStartCaptiveDns()
{
dns.start(53, "*", WiFi.softAPIP());
dns_running = true;
}
void portalStopCaptiveDns()
{
dns.stop();
dns_running = false;
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
// Web page of the recovery app (port 80), reachable on the normal WiFi and on the
// FlipDot-Setup access point (http://192.168.4.1):
// - status: why recovery started, WiFi, clock firmware in ota_0 / ota_1
// - choose another WiFi (with a scan of the networks nearby)
// - start the clock firmware now, or stay in recovery
// - how to upload a new firmware over OTA
void portalBegin();
void portalLoop();
// While the access point is open, all DNS names point to this page, so phones open it
// automatically after connecting
void portalStartCaptiveDns();
void portalStopCaptiveDns();