fix: harden firmware NVS persistence, WDT, and 304 epd_sleep
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>
This commit is contained in:
+7
-3
@@ -25,12 +25,16 @@
|
||||
|
||||
// ── NVS ──────────────────────────────────────────────────────────────────────
|
||||
#define NVS_NAMESPACE "pf"
|
||||
#define NVS_KEY_SSID "ssid"
|
||||
#define NVS_KEY_PASS "pass"
|
||||
#define NVS_KEY_SSID "ssid"
|
||||
#define NVS_KEY_PASS "pass"
|
||||
#define NVS_KEY_IMG_ID "img_id"
|
||||
#define NVS_KEY_DRAW_NEEDED "draw"
|
||||
|
||||
// ── Network ──────────────────────────────────────────────────────────────────
|
||||
#define APP_BASE_URL "https://pictureframe.edholm.me"
|
||||
#define AP_IP "192.168.4.1"
|
||||
#define WIFI_TIMEOUT_MS 30000
|
||||
#define FETCH_INTERVAL_MS 900000 // 15 min deep sleep between polls
|
||||
#ifndef FETCH_INTERVAL_MS
|
||||
#define FETCH_INTERVAL_MS 60000 // 1 min deep sleep between polls
|
||||
#endif
|
||||
#define IMAGE_PATH "/img.bin"
|
||||
|
||||
+63
-25
@@ -1,11 +1,18 @@
|
||||
#include "epd.h"
|
||||
#include "config.h"
|
||||
#include <LittleFS.h>
|
||||
#include <qrcode.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
static uint8_t s_row[EPD_WIDTH / 2];
|
||||
|
||||
static void wait_busy() {
|
||||
while (digitalRead(PIN_BUSY) == LOW) delay(5);
|
||||
uint32_t start = millis();
|
||||
while (digitalRead(PIN_BUSY) == LOW) {
|
||||
if (millis() - start > 60000) return; // 6-color refresh takes ~20s
|
||||
delay(5);
|
||||
esp_task_wdt_reset(); // feed WDT — display refresh can take ~20 s
|
||||
}
|
||||
}
|
||||
static void cmd(uint8_t c) {
|
||||
digitalWrite(PIN_DC, LOW);
|
||||
@@ -22,7 +29,7 @@ static void dat(uint8_t d) {
|
||||
|
||||
void epd_init() {
|
||||
digitalWrite(PIN_RST, HIGH); delay(20);
|
||||
digitalWrite(PIN_RST, LOW); delay(2);
|
||||
digitalWrite(PIN_RST, LOW); delay(10);
|
||||
digitalWrite(PIN_RST, HIGH); delay(20);
|
||||
wait_busy(); delay(30);
|
||||
|
||||
@@ -59,9 +66,7 @@ void epd_fill(uint8_t color) {
|
||||
cmd(0x10);
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
for (int i = 0; i < EPD_WIDTH * EPD_HEIGHT / 2; i++) {
|
||||
SPI.transfer(byte);
|
||||
}
|
||||
for (int i = 0; i < EPD_WIDTH * EPD_HEIGHT / 2; i++) SPI.transfer(byte);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
epd_refresh();
|
||||
}
|
||||
@@ -69,28 +74,16 @@ void epd_fill(uint8_t color) {
|
||||
void epd_draw_image_from_file(fs::File& f) {
|
||||
cmd(0x10);
|
||||
uint8_t buf[512];
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
while (f.available()) {
|
||||
size_t n = f.read(buf, sizeof(buf));
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
SPI.writeBytes(buf, n);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
epd_refresh();
|
||||
}
|
||||
|
||||
// Inline helper: returns the EPD color nibble for display pixel (px, py)
|
||||
// given a centered QR code with cellPx pixels per module.
|
||||
static inline uint8_t qr_nibble(QRCode* qr, int px, int py, int offX, int offY, int cellPx,
|
||||
uint8_t bg, uint8_t fg) {
|
||||
int qx = (px - offX) / cellPx;
|
||||
int qy = (py - offY) / cellPx;
|
||||
if (qx >= 0 && qx < qr->size && qy >= 0 && qy < qr->size) {
|
||||
return qrcode_getModule(qr, qx, qy) ? fg : bg;
|
||||
}
|
||||
return bg;
|
||||
}
|
||||
|
||||
void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg) {
|
||||
int qrPx = qr->size * cellPx;
|
||||
int offX = (EPD_WIDTH - qrPx) / 2;
|
||||
@@ -99,14 +92,59 @@ void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg) {
|
||||
cmd(0x10);
|
||||
for (int y = 0; y < EPD_HEIGHT; y++) {
|
||||
for (int x = 0; x < EPD_WIDTH; x += 2) {
|
||||
uint8_t hi = qr_nibble(qr, x, y, offX, offY, cellPx, bg, fg);
|
||||
uint8_t lo = qr_nibble(qr, x+1, y, offX, offY, cellPx, bg, fg);
|
||||
s_row[x / 2] = (hi << 4) | lo;
|
||||
auto nibble = [&](int px) -> uint8_t {
|
||||
int qx = (px - offX) / cellPx, qy = (y - offY) / cellPx;
|
||||
if (qx >= 0 && qx < qr->size && qy >= 0 && qy < qr->size)
|
||||
return qrcode_getModule(qr, qx, qy) ? fg : bg;
|
||||
return bg;
|
||||
};
|
||||
s_row[x/2] = (nibble(x) << 4) | nibble(x+1);
|
||||
}
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_CS, LOW);
|
||||
SPI.writeBytes(s_row, sizeof(s_row));
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
epd_refresh();
|
||||
}
|
||||
|
||||
// Stream background from LittleFS, overlaying QR at (qr_x, qr_y) with given cell size.
|
||||
// Falls back to a solid fill if the file is missing.
|
||||
static void draw_from_lfs(const char* path, uint8_t fallback_color,
|
||||
QRCode* qr, int qr_x, int qr_y, int qr_cell) {
|
||||
File f = LittleFS.open(path, "r");
|
||||
if (!f) { epd_fill(fallback_color); return; }
|
||||
|
||||
int qr_px = qr->size * qr_cell;
|
||||
|
||||
cmd(0x10);
|
||||
for (int y = 0; y < EPD_HEIGHT; y++) {
|
||||
f.read(s_row, sizeof(s_row));
|
||||
|
||||
if (y >= qr_y && y < qr_y + qr_px) {
|
||||
int qy = (y - qr_y) / qr_cell;
|
||||
int x0 = max(qr_x, 0), x1 = min(qr_x + qr_px, EPD_WIDTH);
|
||||
for (int x = x0; x < x1; x++) {
|
||||
uint8_t c = qrcode_getModule(qr, (x - qr_x) / qr_cell, qy)
|
||||
? COLOR_BLACK : COLOR_WHITE;
|
||||
if (x & 1) s_row[x/2] = (s_row[x/2] & 0xF0) | c;
|
||||
else s_row[x/2] = (s_row[x/2] & 0x0F) | (c << 4);
|
||||
}
|
||||
}
|
||||
|
||||
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_CS, LOW);
|
||||
SPI.writeBytes(s_row, sizeof(s_row));
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
f.close();
|
||||
epd_refresh();
|
||||
}
|
||||
|
||||
void epd_draw_ap_screen(QRCode* qr) {
|
||||
// AP_QR_X=563, AP_QR_Y=185, AP_QR_CELL=5 (must match gen_screens.py)
|
||||
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 563, 185, 5);
|
||||
}
|
||||
|
||||
void epd_draw_setup_screen(QRCode* qr) {
|
||||
// SETUP_QR_X=553, SETUP_QR_Y=175, SETUP_QR_CELL=5 (must match gen_screens.py)
|
||||
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 553, 175, 5);
|
||||
}
|
||||
|
||||
@@ -12,3 +12,7 @@ void epd_draw_image_from_file(fs::File& f);
|
||||
// bg/fg are EPD color nibbles (COLOR_WHITE / COLOR_BLACK).
|
||||
struct QRCode;
|
||||
void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg);
|
||||
|
||||
// Draw the setup screen: pre-rendered background from LittleFS with QR overlaid.
|
||||
void epd_draw_ap_screen(QRCode* qr);
|
||||
void epd_draw_setup_screen(QRCode* qr);
|
||||
|
||||
+12
-60
@@ -10,6 +10,7 @@
|
||||
#include <qrcode.h>
|
||||
#include "config.h"
|
||||
#include "epd.h"
|
||||
#include "operation.h"
|
||||
|
||||
// ── Globals ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -25,23 +26,24 @@ static String g_req_pass;
|
||||
// ── QR helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
static void show_ap_qr(const String& apSsid) {
|
||||
// Encode open WiFi network so phone can auto-join
|
||||
String content = "WIFI:S:" + apSsid + ";T:nopass;;";
|
||||
|
||||
QRCode qr;
|
||||
uint8_t buf[qrcode_getBufferSize(5)];
|
||||
qrcode_initText(&qr, buf, 5, ECC_LOW, content.c_str());
|
||||
epd_draw_qr(&qr, 8, COLOR_WHITE, COLOR_BLACK);
|
||||
epd_draw_ap_screen(&qr);
|
||||
}
|
||||
|
||||
static void show_setup_qr(const String& mac) {
|
||||
String url = String(APP_BASE_URL) + "/setup/" + mac;
|
||||
Serial.println("show_setup_qr: " + url);
|
||||
|
||||
QRCode qr;
|
||||
uint8_t buf[qrcode_getBufferSize(6)];
|
||||
qrcode_initText(&qr, buf, 6, ECC_LOW, url.c_str());
|
||||
// Green background indicates success
|
||||
epd_draw_qr(&qr, 7, COLOR_GREEN, COLOR_BLACK);
|
||||
Serial.println("QR size: " + String(qr.size));
|
||||
epd_draw_setup_screen(&qr);
|
||||
Serial.println("epd_draw_setup_screen done");
|
||||
}
|
||||
|
||||
// ── Captive portal HTML ───────────────────────────────────────────────────────
|
||||
@@ -149,15 +151,7 @@ static void enter_provisioning(const String& mac) {
|
||||
}
|
||||
|
||||
static bool attempt_wifi(const String& ssid, const String& pass) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
|
||||
uint32_t start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
if (millis() - start > WIFI_TIMEOUT_MS) return false;
|
||||
delay(200);
|
||||
}
|
||||
return true;
|
||||
return ::attempt_wifi(ssid.c_str(), pass.c_str());
|
||||
}
|
||||
|
||||
// ── Normal operation ──────────────────────────────────────────────────────────
|
||||
@@ -170,44 +164,8 @@ static void normal_operation(const String& mac) {
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(client, url);
|
||||
int code = http.GET();
|
||||
|
||||
epd_init();
|
||||
|
||||
if (code == 200) {
|
||||
// Stream new image to LittleFS and display it
|
||||
File f = LittleFS.open(IMAGE_PATH, "w", true);
|
||||
if (f) {
|
||||
http.writeToStream(&f);
|
||||
f.close();
|
||||
}
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) {
|
||||
epd_draw_image_from_file(r);
|
||||
r.close();
|
||||
}
|
||||
} else if (code == 204) {
|
||||
// No new image — display whatever is stored
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) {
|
||||
epd_draw_image_from_file(r);
|
||||
r.close();
|
||||
}
|
||||
// No stored image yet: keep current display (e-ink is persistent)
|
||||
} else if (code == 404) {
|
||||
// Device not registered — show setup QR so user can link this device
|
||||
show_setup_qr(mac);
|
||||
} else {
|
||||
// Server error / timeout: yellow border indication (server reachable, sync failed)
|
||||
epd_fill(COLOR_YELLOW);
|
||||
}
|
||||
|
||||
http.end();
|
||||
epd_sleep();
|
||||
|
||||
// Deep sleep until next poll
|
||||
esp_sleep_enable_timer_wakeup((uint64_t)FETCH_INTERVAL_MS * 1000ULL);
|
||||
esp_deep_sleep_start();
|
||||
normal_operation_impl(mac, http, url, prefs);
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
@@ -229,15 +187,7 @@ void setup() {
|
||||
LittleFS.begin(true); // format on first use
|
||||
|
||||
// Check reset button: if held at boot, wipe credentials
|
||||
uint32_t hold_start = millis();
|
||||
bool clear_creds = false;
|
||||
while (digitalRead(PIN_BTN_RESET) == LOW) {
|
||||
if (millis() - hold_start >= RESET_HOLD_MS) {
|
||||
clear_creds = true;
|
||||
break;
|
||||
}
|
||||
delay(50);
|
||||
}
|
||||
bool clear_creds = check_reset_button();
|
||||
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
if (clear_creds) {
|
||||
@@ -256,6 +206,7 @@ void setup() {
|
||||
}
|
||||
|
||||
// Attempt to join saved network
|
||||
Serial.println("[wifi] connecting to ssid=" + ssid);
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
uint32_t start = millis();
|
||||
@@ -265,10 +216,11 @@ void setup() {
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.println("[wifi] connected ip=" + WiFi.localIP().toString());
|
||||
normal_operation(mac);
|
||||
// normal_operation calls deep_sleep — never returns
|
||||
} else {
|
||||
// Can't reach saved network — enter provisioning to get new credentials
|
||||
Serial.println("[wifi] failed after " + String(WIFI_TIMEOUT_MS) + " ms — entering provisioning");
|
||||
enter_provisioning(mac);
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <Preferences.h>
|
||||
#include <LittleFS.h>
|
||||
#include "config.h"
|
||||
|
||||
#ifdef UNIT_TEST
|
||||
// In unit tests, use mock stubs for hardware-dependent headers.
|
||||
// The test build adds test/mocks to the include path via -iquote.
|
||||
#include "epd_mock.h"
|
||||
#include "esp_sleep.h"
|
||||
#else
|
||||
#include "epd.h"
|
||||
#include <esp_sleep.h>
|
||||
#endif
|
||||
|
||||
#ifndef UNIT_TEST
|
||||
// Defined in main.cpp
|
||||
static void show_setup_qr(const String& mac);
|
||||
#else
|
||||
// Stub for native tests — tracks call count
|
||||
extern int g_show_setup_qr_count;
|
||||
inline void show_setup_qr(const String& mac) { g_show_setup_qr_count++; }
|
||||
#endif
|
||||
|
||||
// ── Utility: derive AP SSID from MAC ─────────────────────────────────────────
|
||||
// Strips colons, uppercases, takes the last 4 chars.
|
||||
// Builds via std::string so single-char append is unambiguous on all targets.
|
||||
inline String ap_ssid_from_mac(const String& mac) {
|
||||
std::string cleaned;
|
||||
const char* p = mac.c_str();
|
||||
while (*p) {
|
||||
if (*p != ':') cleaned += (char)toupper((unsigned char)*p);
|
||||
++p;
|
||||
}
|
||||
std::string suffix = cleaned.substr(cleaned.size() - 4);
|
||||
return String(("PictureFrame-" + suffix).c_str());
|
||||
}
|
||||
|
||||
// ── WiFi connection attempt ───────────────────────────────────────────────────
|
||||
inline bool attempt_wifi(const char* ssid, const char* pass) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid, pass);
|
||||
uint32_t start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
if (millis() - start > WIFI_TIMEOUT_MS) return false;
|
||||
delay(200);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Reset button hold detection ───────────────────────────────────────────────
|
||||
inline bool check_reset_button() {
|
||||
uint32_t hold_start = millis();
|
||||
while (digitalRead(PIN_BTN_RESET) == LOW) {
|
||||
if (millis() - hold_start >= RESET_HOLD_MS) {
|
||||
return true;
|
||||
}
|
||||
delay(50);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
template<typename HTTP>
|
||||
void normal_operation_impl(const String& mac, HTTP& http, const String& url, Preferences& prefs) {
|
||||
prefs.begin(NVS_NAMESPACE, true);
|
||||
int32_t currentImgId = prefs.getInt(NVS_KEY_IMG_ID, -1);
|
||||
bool drawNeeded = prefs.getInt(NVS_KEY_DRAW_NEEDED, 0) != 0;
|
||||
prefs.end();
|
||||
|
||||
if (currentImgId >= 0) {
|
||||
http.addHeader("X-Current-Image-Id", String(currentImgId));
|
||||
}
|
||||
|
||||
const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id" };
|
||||
http.collectHeaders(collectHeaders, 2);
|
||||
int code = http.GET();
|
||||
|
||||
uint64_t sleepMs = FETCH_INTERVAL_MS;
|
||||
String intervalHdr = http.header("X-Interval-Ms");
|
||||
if (intervalHdr.length() > 0) {
|
||||
uint64_t v = strtoull(intervalHdr.c_str(), nullptr, 10);
|
||||
if (v > 0) sleepMs = std::min<uint64_t>(v, (uint64_t)FETCH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
bool displayInitialized = false;
|
||||
|
||||
if (code == 200) {
|
||||
String newId = http.header("X-Image-Id");
|
||||
|
||||
File f = LittleFS.open(IMAGE_PATH, "w", true);
|
||||
if (f) { http.writeToStream(&f); f.close(); }
|
||||
http.end();
|
||||
|
||||
// Persist ID and set draw_needed before touching the display.
|
||||
// If the device loses power during the ~20 s refresh, the flag survives
|
||||
// in NVS so the next boot re-draws from LittleFS instead of looping on 200.
|
||||
if (newId.length() > 0) {
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putInt(NVS_KEY_DRAW_NEEDED, 1);
|
||||
prefs.putInt(NVS_KEY_IMG_ID, newId.toInt());
|
||||
prefs.end();
|
||||
}
|
||||
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) { epd_draw_image_from_file(r); r.close(); }
|
||||
|
||||
// Draw complete — clear the pending flag.
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
|
||||
prefs.end();
|
||||
|
||||
} else if (code == 304) {
|
||||
http.end();
|
||||
// If a previous draw was interrupted (power loss mid-refresh), the image
|
||||
// file is in LittleFS and the ID is correct in NVS — just re-draw it.
|
||||
if (drawNeeded) {
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) {
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
epd_draw_image_from_file(r);
|
||||
r.close();
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
|
||||
prefs.end();
|
||||
}
|
||||
}
|
||||
} else if (code == 204) {
|
||||
http.end();
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
show_setup_qr(mac);
|
||||
} else if (code == 404) {
|
||||
http.end();
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
show_setup_qr(mac);
|
||||
} else {
|
||||
http.end();
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
epd_fill(COLOR_YELLOW);
|
||||
}
|
||||
|
||||
// Only power off the display if it was initialized this cycle. Calling
|
||||
// epd_sleep() when the display is already in hardware deep sleep (from the
|
||||
// previous cycle) causes wait_busy() to time out at 60 s, wasting the
|
||||
// entire poll interval on every 304 response.
|
||||
if (displayInitialized) epd_sleep();
|
||||
|
||||
esp_sleep_enable_timer_wakeup(sleepMs * 1000ULL);
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
Reference in New Issue
Block a user