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

+49
View File
@@ -7,6 +7,7 @@
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
@@ -247,6 +248,48 @@ void test_crash_count_cleared_after_running_long_enough()
TEST_ASSERT_TRUE(shouldClearCrashCount(CRASH_FREE_TIME));
}
void test_quick_power_cycles_request_recovery()
{
for (uint8_t count = 0; count < POWER_CYCLES_FOR_RECOVERY; count++)
TEST_ASSERT_FALSE(powerCyclesRequestRecovery(count));
TEST_ASSERT_TRUE(powerCyclesRequestRecovery(POWER_CYCLES_FOR_RECOVERY));
}
void test_recovery_for_wifi_starts_clock_when_wifi_works()
{
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, 0, false, true));
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_TIME * 10, false, true));
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_MIN_TIME - 1, true, true));
TEST_ASSERT_TRUE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_MIN_TIME, true, true));
}
void test_recovery_for_other_reasons_waits_recovery_time()
{
const RecoveryReason reasons[] = {RecoveryReason::Crash, RecoveryReason::Manual,
RecoveryReason::PowerCycle, RecoveryReason::None};
for (RecoveryReason reason : reasons)
{
TEST_ASSERT_FALSE(recoveryShouldStartMain(reason, RECOVERY_TIME - 1, true, true));
TEST_ASSERT_TRUE(recoveryShouldStartMain(reason, RECOVERY_TIME, true, true));
// The time counts, not the WiFi: an OTA upload over the setup WiFi is possible
TEST_ASSERT_TRUE(recoveryShouldStartMain(reason, RECOVERY_TIME, false, true));
}
}
void test_recovery_never_starts_missing_clock_firmware()
{
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_TIME, true, false));
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Manual, RECOVERY_TIME, true, false));
}
void test_every_recovery_reason_has_a_text()
{
for (uint8_t r = 0; r <= (uint8_t)RecoveryReason::PowerCycle; r++)
TEST_ASSERT_TRUE(strlen(recoveryReasonText((RecoveryReason)r)) > 0);
TEST_ASSERT_TRUE(strcmp(recoveryReasonText(RecoveryReason::Wifi),
recoveryReasonText(RecoveryReason::Crash)) != 0);
}
// ---------------------------------------------------------------------------
// Status LED
// ---------------------------------------------------------------------------
@@ -434,6 +477,12 @@ int main()
RUN_TEST(test_new_firmware_needs_time_and_wifi_to_be_accepted);
RUN_TEST(test_crash_count_cleared_after_running_long_enough);
RUN_TEST(test_quick_power_cycles_request_recovery);
RUN_TEST(test_recovery_for_wifi_starts_clock_when_wifi_works);
RUN_TEST(test_recovery_for_other_reasons_waits_recovery_time);
RUN_TEST(test_recovery_never_starts_missing_clock_firmware);
RUN_TEST(test_every_recovery_reason_has_a_text);
RUN_TEST(test_led_off_while_display_off);
RUN_TEST(test_led_blinks_slow_with_wifi_and_fast_without);