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:
2026-05-06 12:09:37 -04:00
parent 3740331b5b
commit dd0970ed7c
23 changed files with 973 additions and 88 deletions
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <string>
#include <cstring>
#include <cstdint>
#include <cstdlib>
#include <algorithm>
#include <sstream>
#include <cctype>
// Minimal String class that mimics Arduino's String for native tests
struct String {
std::string _s;
String() {}
String(const char* s) : _s(s ? s : "") {}
String(const std::string& s) : _s(s) {}
String(int v) { _s = std::to_string(v); }
String(unsigned long v) { _s = std::to_string(v); }
String(long long v) { _s = std::to_string(v); }
String(unsigned long long v) { _s = std::to_string(v); }
const char* c_str() const { return _s.c_str(); }
size_t length() const { return _s.size(); }
bool isEmpty() const { return _s.empty(); }
bool empty() const { return _s.empty(); }
int toInt() const { return _s.empty() ? 0 : std::stoi(_s); }
String substring(size_t from) const { return String(_s.substr(from)); }
String substring(size_t from, size_t to) const { return String(_s.substr(from, to - from)); }
void replace(const char* from, const char* to_str) {
std::string result;
std::string f(from), t(to_str);
size_t pos = 0, found;
while ((found = _s.find(f, pos)) != std::string::npos) {
result += _s.substr(pos, found - pos);
result += t;
pos = found + f.size();
}
result += _s.substr(pos);
_s = result;
}
void toUpperCase() {
for (char& c : _s) c = (char)toupper((unsigned char)c);
}
bool operator==(const String& o) const { return _s == o._s; }
bool operator==(const char* o) const { return _s == o; }
bool operator!=(const String& o) const { return _s != o._s; }
bool operator!=(const char* o) const { return _s != o; }
String operator+(const String& o) const { return String(_s + o._s); }
String operator+(const char* o) const { return String(_s + o); }
String& operator+=(const String& o) { _s += o._s; return *this; }
String& operator+=(const char* o) { _s += o; return *this; }
// Allow use as map key
operator std::string() const { return _s; }
// toString() for IP-like objects that have it
String toString() const { return *this; }
};
inline String operator+(const char* a, const String& b) { return String(std::string(a) + b._s); }
inline String operator+(const std::string& a, const String& b) { return String(a + b._s); }
// Controllable millis and digitalRead for timeout / button tests
extern uint32_t g_millis_value;
extern int g_digital_read_value;
#ifndef LOW
#define LOW 0
#define HIGH 1
#endif
inline unsigned long millis() { return g_millis_value += 10; }
inline void delay(unsigned long) {}
inline void pinMode(int, int) {}
inline int digitalRead(int) { return g_digital_read_value; }
// Color constants (from config.h)
#define COLOR_YELLOW 0x2
#define COLOR_RED 0x3
// Serial mock
struct SerialMock {
void begin(int) {}
void println(const String&) {}
void println(const char*) {}
void println(int) {}
void print(const String&) {}
void print(const char*) {}
void flush() {}
} Serial;
// strtoull is available from <cstdlib> on native
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <cstdint>
#include <string>
enum class DNSReplyCode { NoError };
struct DNSServer {
void setErrorReplyCode(DNSReplyCode) {}
void start(uint16_t, const char*, const std::string&) {}
void processNextRequest() {}
void stop() {}
};
+30
View File
@@ -0,0 +1,30 @@
#pragma once
// Minimal FS.h stub for native unit tests
// Provides the fs::File type that Arduino's FS library normally defines.
#include "Arduino.h"
#include <string>
#include <cstdint>
namespace fs {
struct File {
std::string* _buf = nullptr;
bool _valid = false;
bool _write = false;
size_t _pos = 0;
explicit operator bool() const { return _valid; }
void close() { _valid = false; }
size_t write(const uint8_t* data, size_t len) {
if (_buf && _write) { _buf->append((const char*)data, len); return len; }
return 0;
}
int read() {
if (_buf && _pos < _buf->size()) return (uint8_t)(*_buf)[_pos++];
return -1;
}
size_t size() { return _buf ? _buf->size() : 0; }
};
} // namespace fs
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "Arduino.h"
#include "LittleFS.h"
#include "WiFiClientSecure.h"
#include <map>
// Global test state for inspecting behavior
extern int g_http_get_code;
extern std::map<std::string, std::string> g_http_response_headers;
extern std::map<std::string, std::string> g_http_request_headers;
extern bool g_http_end_called;
extern std::string g_http_body;
struct MockHTTPClient {
bool _ended = false;
void begin(WiFiClientSecure&, const String& url) {}
void addHeader(const char* name, const String& value) {
g_http_request_headers[name] = value._s;
}
void collectHeaders(const char** headers, int count) {}
int GET() {
_ended = false;
return g_http_get_code;
}
String header(const char* name) {
if (_ended) return String("");
auto it = g_http_response_headers.find(name);
return it != g_http_response_headers.end() ? String(it->second) : String("");
}
size_t writeToStream(File* f) {
if (f && *f) {
f->write((const uint8_t*)g_http_body.data(), g_http_body.size());
}
return g_http_body.size();
}
void end() {
_ended = true;
g_http_end_called = true;
}
};
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include "Arduino.h"
#include <map>
#include <vector>
struct File {
std::string* _buf = nullptr;
bool _valid = false;
bool _write = false;
size_t _pos = 0;
explicit operator bool() const { return _valid; }
void close() { _valid = false; }
size_t write(const uint8_t* data, size_t len) {
if (_buf && _write) { _buf->append((const char*)data, len); return len; }
return 0;
}
int read() {
if (_buf && _pos < _buf->size()) return (uint8_t)(*_buf)[_pos++];
return -1;
}
size_t size() { return _buf ? _buf->size() : 0; }
};
struct LittleFSClass {
std::map<std::string, std::string> files;
bool begin(bool) { return true; }
File open(const char* path, const char* mode, bool create = false) {
File f;
f._valid = true;
f._write = (mode[0] == 'w');
f._buf = &files[path];
if (f._write) f._buf->clear();
f._pos = 0;
return f;
}
} LittleFS;
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "Arduino.h"
#include <map>
// Shared sequence counter — incremented by each instrumented mock call
extern int g_call_seq;
extern int g_prefs_putint_seq; // sequence position of last putInt call
struct Preferences {
std::map<std::string, int32_t> ints;
std::map<std::string, std::string> strings;
bool _open = false;
void begin(const char*, bool) { _open = true; }
void end() { _open = false; }
int32_t getInt(const char* key, int32_t def = 0) {
auto it = ints.find(key);
return it != ints.end() ? it->second : def;
}
void putInt(const char* key, int32_t val) {
ints[key] = val;
// Record the sequence of the FIRST putInt call (ordering test uses this
// to verify NVS is written before epd_draw_image_from_file).
if (g_prefs_putint_seq < 0) g_prefs_putint_seq = g_call_seq;
g_call_seq++;
}
String getString(const char* key, const char* def = "") {
auto it = strings.find(key);
return it != strings.end() ? String(it->second) : String(def);
}
void putString(const char* key, const String& val) { strings[key] = val._s; }
void clear() { ints.clear(); strings.clear(); }
};
+8
View File
@@ -0,0 +1,8 @@
#pragma once
struct SPISettings { SPISettings(uint32_t, uint8_t, uint8_t) {} };
struct SPIClass {
void begin(int,int,int,int) {}
void beginTransaction(SPISettings) {}
} SPI;
#define MSBFIRST 1
#define SPI_MODE0 0
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <string>
struct WebServer {
WebServer(int) {}
void on(const char*, void(*)()) {}
void on(const char*, int, void(*)()) {}
void onNotFound(void(*)()) {}
void begin() {}
void handleClient() {}
void stop() {}
bool hasArg(const char*) { return false; }
std::string arg(const char*) { return ""; }
void send(int, const char*, const char*) {}
void send_P(int, const char*, const char*) {}
void sendHeader(const char*, const char*) {}
};
#define HTTP_GET 0
#define HTTP_POST 1
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "Arduino.h"
#define WIFI_STA 0
#define WIFI_AP 1
#define WL_CONNECTED 3
extern int g_wifi_status;
struct WiFiClass {
String macAddress() { return String("1C:C3:AB:D1:91:F8"); }
int status() { return g_wifi_status; }
void mode(int) {}
void begin(const char*, const char*) {}
void disconnect(bool) {}
bool softAP(const char*) { return true; }
String softAPIP() { return String("192.168.4.1"); }
String localIP() { return String("192.168.1.100"); }
} WiFi;
+4
View File
@@ -0,0 +1,4 @@
#pragma once
struct WiFiClientSecure {
void setInsecure() {}
};
+28
View File
@@ -0,0 +1,28 @@
#pragma once
// Mirror of src/config.h for use in native unit tests.
// Values must match src/config.h so test assertions stay consistent.
#define APP_BASE_URL "https://pictureframe.edholm.me"
#define NVS_NAMESPACE "pf"
#define NVS_KEY_SSID "ssid"
#define NVS_KEY_PASS "pass"
#define NVS_KEY_IMG_ID "img_id"
#define NVS_KEY_DRAW_NEEDED "draw"
#define IMAGE_PATH "/img.bin"
#define FETCH_INTERVAL_MS 60000ULL
#define WIFI_TIMEOUT_MS 30000
#define RESET_HOLD_MS 5000
#define AP_IP "192.168.4.1"
#define PIN_CS 5
#define PIN_DC 17
#define PIN_RST 16
#define PIN_BUSY 4
#define PIN_SCK 18
#define PIN_MOSI 23
#define PIN_BTN_RESET 0
// Color constants (also defined in Arduino mock, repeated here for clarity)
#define COLOR_BLACK 0x0
#define COLOR_WHITE 0x1
#define COLOR_YELLOW 0x2
#define COLOR_RED 0x3
#define COLOR_BLUE 0x5
#define COLOR_GREEN 0x6
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "Arduino.h"
// Call counters for assertions
extern int g_epd_init_count;
extern int g_epd_sleep_count;
extern int g_epd_draw_image_count;
extern int g_epd_fill_count;
extern int g_epd_fill_last_color;
extern int g_epd_draw_setup_count;
inline void epd_init() { g_epd_init_count++; }
inline void epd_sleep() { g_epd_sleep_count++; }
inline void epd_draw_image_from_file(File& f) { g_epd_draw_image_count++; }
inline void epd_fill(int color) { g_epd_fill_count++; g_epd_fill_last_color = color; }
inline void epd_draw_ap_screen(void*) {}
inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; }
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "Arduino.h"
// Shared sequence counter — incremented by each instrumented mock call
extern int g_call_seq;
extern int g_epd_draw_seq; // sequence position of last epd_draw_image_from_file call
// Call counters for assertions
extern int g_epd_init_count;
extern int g_epd_sleep_count;
extern int g_epd_draw_image_count;
extern int g_epd_fill_count;
extern int g_epd_fill_last_color;
extern int g_epd_draw_setup_count;
inline void epd_init() { g_epd_init_count++; }
inline void epd_sleep() { g_epd_sleep_count++; }
inline void epd_draw_image_from_file(File& f) {
g_epd_draw_image_count++;
if (g_epd_draw_seq < 0) g_epd_draw_seq = g_call_seq;
g_call_seq++;
}
inline void epd_fill(int color) { g_epd_fill_count++; g_epd_fill_last_color = color; }
inline void epd_draw_ap_screen(void*) {}
inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; }
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <cstdint>
extern uint64_t g_sleep_us;
extern bool g_deep_sleep_started;
inline void esp_sleep_enable_timer_wakeup(uint64_t us) { g_sleep_us = us; }
inline void esp_deep_sleep_start() { g_deep_sleep_started = true; }
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <cstdint>
#include <cstddef>
struct QRCode { int size; };
inline size_t qrcode_getBufferSize(int) { return 128; }
inline void qrcode_initText(QRCode* qr, uint8_t*, int, int, const char*) { qr->size = 21; }
#define ECC_LOW 0