Match the failure path's latency to the happy path. Before: a wrong
password meant the user stared at the yellow Step 1/2 screen for the
full 30 s WIFI_TIMEOUT_MS before the red retry repaint started — total
~50 s to "Connection Failed" visible. After: WL_CONNECT_FAILED and
WL_NO_SSID_AVAIL bail attempt_wifi() immediately, so the red repaint
starts within a few seconds of the radio giving up — total ~25 s,
matching the happy-path-to-Step-2/2 timing.
Also collapse the duplicate boot-time poll loop in main.cpp onto the
shared attempt_wifi() so the same fast-fail covers boot-with-stored-
creds, not just captive-portal submission.
Tests: FW-15a (auth fail) and FW-15b (no SSID) assert millis() never
reaches WIFI_TIMEOUT_MS on those statuses. Existing FW-15 tightened
to use WL_DISCONNECTED so it actually exercises the timeout path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs fixed:
- NVS img_id now written before epd_init/draw; new draw_needed flag in NVS
survives power-loss mid-refresh so next boot re-draws from LittleFS instead
of showing stale content
- epd_sleep() now only called when display was initialized this cycle,
preventing a 60 s wait_busy() timeout on every 304 poll
- esp_task_wdt_reset() added to wait_busy() loop so the ~20 s 6-color
refresh no longer triggers the task watchdog
Also extracts normal_operation into operation.h template and adds
a native PlatformIO test suite (16 tests) covering the full response matrix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>