diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 4f4cbf8..c4f8b96 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -24,3 +24,10 @@ build_flags = -DENV_TEST_DISPLAY build_src_filter = + + lib_deps = ricmoo/QRCode@^0.0.1 + +[env:native-test] +platform = native +lib_deps = + throwtheswitch/Unity@^2.6 +build_flags = -DUNIT_TEST -std=c++17 -iquote test/mocks -iquote test -Itest/mocks -Itest +test_build_src = no diff --git a/firmware/src/config.h b/firmware/src/config.h index d2c7337..228bb06 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -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" diff --git a/firmware/src/epd.cpp b/firmware/src/epd.cpp index e831748..442f40c 100644 --- a/firmware/src/epd.cpp +++ b/firmware/src/epd.cpp @@ -1,11 +1,18 @@ #include "epd.h" #include "config.h" +#include #include +#include 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); +} diff --git a/firmware/src/epd.h b/firmware/src/epd.h index ee61812..d3d050d 100644 --- a/firmware/src/epd.h +++ b/firmware/src/epd.h @@ -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); diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index ef601d4..f5b9fc4 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -10,6 +10,7 @@ #include #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); } } diff --git a/firmware/src/operation.h b/firmware/src/operation.h new file mode 100644 index 0000000..29cab6c --- /dev/null +++ b/firmware/src/operation.h @@ -0,0 +1,156 @@ +#pragma once +#include +#include +#include +#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 +#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 +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(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(); +} diff --git a/firmware/test/mocks/Arduino.h b/firmware/test/mocks/Arduino.h new file mode 100644 index 0000000..def181a --- /dev/null +++ b/firmware/test/mocks/Arduino.h @@ -0,0 +1,98 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +// 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 on native diff --git a/firmware/test/mocks/DNSServer.h b/firmware/test/mocks/DNSServer.h new file mode 100644 index 0000000..403e825 --- /dev/null +++ b/firmware/test/mocks/DNSServer.h @@ -0,0 +1,10 @@ +#pragma once +#include +#include +enum class DNSReplyCode { NoError }; +struct DNSServer { + void setErrorReplyCode(DNSReplyCode) {} + void start(uint16_t, const char*, const std::string&) {} + void processNextRequest() {} + void stop() {} +}; diff --git a/firmware/test/mocks/FS.h b/firmware/test/mocks/FS.h new file mode 100644 index 0000000..8cf8f3f --- /dev/null +++ b/firmware/test/mocks/FS.h @@ -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 +#include + +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 diff --git a/firmware/test/mocks/HTTPClient.h b/firmware/test/mocks/HTTPClient.h new file mode 100644 index 0000000..14f8213 --- /dev/null +++ b/firmware/test/mocks/HTTPClient.h @@ -0,0 +1,47 @@ +#pragma once +#include "Arduino.h" +#include "LittleFS.h" +#include "WiFiClientSecure.h" +#include + +// Global test state for inspecting behavior +extern int g_http_get_code; +extern std::map g_http_response_headers; +extern std::map 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; + } +}; diff --git a/firmware/test/mocks/LittleFS.h b/firmware/test/mocks/LittleFS.h new file mode 100644 index 0000000..f757274 --- /dev/null +++ b/firmware/test/mocks/LittleFS.h @@ -0,0 +1,39 @@ +#pragma once +#include "Arduino.h" +#include +#include + +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 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; diff --git a/firmware/test/mocks/Preferences.h b/firmware/test/mocks/Preferences.h new file mode 100644 index 0000000..65c1694 --- /dev/null +++ b/firmware/test/mocks/Preferences.h @@ -0,0 +1,35 @@ +#pragma once +#include "Arduino.h" +#include + +// 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 ints; + std::map 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(); } +}; diff --git a/firmware/test/mocks/SPI.h b/firmware/test/mocks/SPI.h new file mode 100644 index 0000000..dc95b96 --- /dev/null +++ b/firmware/test/mocks/SPI.h @@ -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 diff --git a/firmware/test/mocks/WebServer.h b/firmware/test/mocks/WebServer.h new file mode 100644 index 0000000..b75b815 --- /dev/null +++ b/firmware/test/mocks/WebServer.h @@ -0,0 +1,18 @@ +#pragma once +#include +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 diff --git a/firmware/test/mocks/WiFi.h b/firmware/test/mocks/WiFi.h new file mode 100644 index 0000000..c763ac5 --- /dev/null +++ b/firmware/test/mocks/WiFi.h @@ -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; diff --git a/firmware/test/mocks/WiFiClientSecure.h b/firmware/test/mocks/WiFiClientSecure.h new file mode 100644 index 0000000..1fb5515 --- /dev/null +++ b/firmware/test/mocks/WiFiClientSecure.h @@ -0,0 +1,4 @@ +#pragma once +struct WiFiClientSecure { + void setInsecure() {} +}; diff --git a/firmware/test/mocks/config.h b/firmware/test/mocks/config.h new file mode 100644 index 0000000..fd67dff --- /dev/null +++ b/firmware/test/mocks/config.h @@ -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 diff --git a/firmware/test/mocks/epd.h b/firmware/test/mocks/epd.h new file mode 100644 index 0000000..a76a2d3 --- /dev/null +++ b/firmware/test/mocks/epd.h @@ -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++; } diff --git a/firmware/test/mocks/epd_mock.h b/firmware/test/mocks/epd_mock.h new file mode 100644 index 0000000..2ab4de9 --- /dev/null +++ b/firmware/test/mocks/epd_mock.h @@ -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++; } diff --git a/firmware/test/mocks/esp_sleep.h b/firmware/test/mocks/esp_sleep.h new file mode 100644 index 0000000..5f4188a --- /dev/null +++ b/firmware/test/mocks/esp_sleep.h @@ -0,0 +1,8 @@ +#pragma once +#include + +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; } diff --git a/firmware/test/mocks/qrcode.h b/firmware/test/mocks/qrcode.h new file mode 100644 index 0000000..9613b2a --- /dev/null +++ b/firmware/test/mocks/qrcode.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include +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 diff --git a/firmware/test/test_normal_operation/test_main.cpp b/firmware/test/test_normal_operation/test_main.cpp new file mode 100644 index 0000000..3e3a7bd --- /dev/null +++ b/firmware/test/test_normal_operation/test_main.cpp @@ -0,0 +1,248 @@ +#include +#include +#include +#include +#include + +// Include mocks first — they shadow system/Arduino headers. +// -iquote test/mocks ensures quoted includes from test_main find mocks first. +// operation.h uses #ifdef UNIT_TEST to pick epd_mock.h and esp_sleep.h. +#include "Arduino.h" +#include "WiFi.h" +#include "WiFiClientSecure.h" +#include "Preferences.h" +#include "LittleFS.h" +#include "epd_mock.h" +#include "esp_sleep.h" +#include "HTTPClient.h" +#include "SPI.h" +#include "WebServer.h" +#include "DNSServer.h" +#include "qrcode.h" +#include "config.h" + +// Define globals referenced as extern in the mock headers +int g_http_get_code; +std::map g_http_response_headers; +std::map g_http_request_headers; +bool g_http_end_called; +std::string g_http_body; + +int g_epd_init_count, g_epd_sleep_count, g_epd_draw_image_count; +int g_epd_fill_count, g_epd_fill_last_color, g_epd_draw_setup_count; + +uint64_t g_sleep_us; +bool g_deep_sleep_started; + +// Globals for new mocks +int g_show_setup_qr_count; +uint32_t g_millis_value; +int g_digital_read_value; +int g_wifi_status; + +// Ordering / sequencing globals (shared with Preferences.h and epd_mock.h) +int g_call_seq = 0; +int g_prefs_putint_seq = -1; +int g_epd_draw_seq = -1; + +// Include the template under test AFTER all mocks are defined. +// operation.h with UNIT_TEST defined will include "epd_mock.h" and "esp_sleep.h" +// via -iquote test/mocks path (real src/epd.h is never pulled in). +#include "../../src/operation.h" + +// Test fixtures +static Preferences prefs; +static MockHTTPClient http; + +void reset_state() { + g_http_get_code = 200; + g_http_response_headers.clear(); + g_http_request_headers.clear(); + g_http_end_called = false; + g_http_body = "TESTDATA"; + g_epd_init_count = g_epd_sleep_count = g_epd_draw_image_count = 0; + g_epd_fill_count = g_epd_fill_last_color = g_epd_draw_setup_count = 0; + g_sleep_us = 0; + g_deep_sleep_started = false; + g_show_setup_qr_count = 0; + g_millis_value = 0; + g_digital_read_value = HIGH; // button not pressed by default + g_wifi_status = WL_CONNECTED; // connected by default + prefs.clear(); + LittleFS.files.clear(); + http._ended = false; + g_call_seq = 0; + g_prefs_putint_seq = -1; + g_epd_draw_seq = -1; +} + +void setUp() { reset_state(); } +void tearDown() {} + +// FW-01: 200 response — file written, epd_draw called, NVS saved, deep sleep started +void test_fw01_200_response_happy_path() { + // Use an interval < FETCH_INTERVAL_MS so server value is honored + g_http_response_headers["X-Image-Id"] = "42"; + g_http_response_headers["X-Interval-Ms"] = "30000"; + g_http_body = "BINDATA"; + + normal_operation_impl(String("1C:C3:AB:D1:91:F8"), http, String("https://test/api/device/mac/image"), prefs); + + TEST_ASSERT_EQUAL(1, g_epd_draw_image_count); + TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1)); + TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us); + TEST_ASSERT_TRUE(g_deep_sleep_started); + TEST_ASSERT_FALSE(LittleFS.files[IMAGE_PATH].empty()); +} + +// FW-02: REGRESSION — headers must be read BEFORE http.end(), otherwise newId is empty +void test_fw02_headers_read_before_end_regression() { + g_http_response_headers["X-Image-Id"] = "99"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + // If newId was read after end(), NVS img_id would remain -1 + TEST_ASSERT_EQUAL(99, prefs.getInt("img_id", -1)); +} + +// FW-03: 304 — no epd draw, no init, deep sleep started +void test_fw03_304_no_redraw() { + g_http_get_code = 304; + // Use an interval < FETCH_INTERVAL_MS so server value is honored + g_http_response_headers["X-Interval-Ms"] = "30000"; + + normal_operation_impl(String("mac"), http, String("url"), prefs); + + TEST_ASSERT_EQUAL(0, g_epd_init_count); + TEST_ASSERT_EQUAL(0, g_epd_draw_image_count); + TEST_ASSERT_TRUE(g_deep_sleep_started); + TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us); +} + +// FW-04: 204 — show_setup_qr called exactly once +void test_fw04_204_shows_setup_qr() { + g_http_get_code = 204; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL(1, g_show_setup_qr_count); + TEST_ASSERT_EQUAL(0, g_epd_draw_image_count); +} + +// FW-05: 404 — show_setup_qr called exactly once +void test_fw05_404_shows_setup_qr() { + g_http_get_code = 404; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL(1, g_show_setup_qr_count); + TEST_ASSERT_EQUAL(0, g_epd_draw_image_count); +} + +// FW-06: other error — epd_fill yellow +void test_fw06_error_fills_yellow() { + g_http_get_code = 500; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL(1, g_epd_fill_count); + TEST_ASSERT_EQUAL(COLOR_YELLOW, g_epd_fill_last_color); +} + +// FW-07: NVS has saved img_id → X-Current-Image-Id header sent +void test_fw07_current_image_id_sent_when_saved() { + prefs.ints["img_id"] = 99; + g_http_response_headers["X-Image-Id"] = "99"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_STRING("99", g_http_request_headers["X-Current-Image-Id"].c_str()); +} + +// FW-08: NVS img_id = -1 (default) → X-Current-Image-Id NOT sent +void test_fw08_no_current_image_id_when_default() { + // prefs has no img_id — getInt returns -1 + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_TRUE(g_http_request_headers.find("X-Current-Image-Id") == g_http_request_headers.end()); +} + +// FW-09: server interval < FETCH_INTERVAL_MS → server value used +void test_fw09_server_interval_honored() { + g_http_response_headers["X-Interval-Ms"] = "30000"; + g_http_response_headers["X-Image-Id"] = "1"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_UINT64(30000ULL * 1000ULL, g_sleep_us); +} + +// FW-10: server interval > FETCH_INTERVAL_MS → capped at ceiling +void test_fw10_server_interval_capped() { + g_http_response_headers["X-Interval-Ms"] = "999999999"; + g_http_response_headers["X-Image-Id"] = "1"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us); +} + +// FW-11: no X-Interval-Ms → default ceiling used +void test_fw11_default_interval_when_absent() { + g_http_response_headers["X-Image-Id"] = "1"; + // no X-Interval-Ms set + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_UINT64(FETCH_INTERVAL_MS * 1000ULL, g_sleep_us); +} + +// FW-14: 304 — epd_sleep NOT called (display already in hardware deep sleep) +void test_fw14_304_skips_epd_sleep() { + g_http_get_code = 304; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL(0, g_epd_sleep_count); + TEST_ASSERT_EQUAL(0, g_epd_init_count); +} + +// FW-15: 200 — NVS img_id saved BEFORE epd_draw_image_from_file; draw_needed cleared after +void test_fw15_nvs_saved_before_epd_draw_and_flag_cleared() { + g_http_response_headers["X-Image-Id"] = "42"; + g_http_body = "BINDATA"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_TRUE_MESSAGE(g_prefs_putint_seq < g_epd_draw_seq, + "NVS putInt must be called before epd_draw_image_from_file"); + TEST_ASSERT_EQUAL(42, prefs.getInt("img_id", -1)); + TEST_ASSERT_EQUAL(1, g_epd_draw_image_count); + TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1)); +} + +// FW-16: 304 with draw_needed=1 (interrupted draw) — re-draws from LittleFS and clears flag +void test_fw16_304_with_draw_needed_redraws() { + prefs.ints["img_id"] = 42; + prefs.ints["draw"] = 1; + g_http_get_code = 304; + LittleFS.files[IMAGE_PATH] = "IMGDATA"; + + normal_operation_impl(String("mac"), http, String("url"), prefs); + + TEST_ASSERT_EQUAL(1, g_epd_init_count); + TEST_ASSERT_EQUAL(1, g_epd_draw_image_count); + TEST_ASSERT_EQUAL(1, g_epd_sleep_count); + TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1)); +} + +// FW-12/13: AP SSID derivation via ap_ssid_from_mac() +void test_fw12_ap_ssid_from_mac_aabbcc() { + String ssid = ap_ssid_from_mac(String("AA:BB:CC:DD:EE:FF")); + TEST_ASSERT_EQUAL_STRING("PictureFrame-EEFF", ssid.c_str()); +} + +void test_fw13_ap_ssid_from_real_mac() { + String ssid = ap_ssid_from_mac(String("1C:C3:AB:D1:91:F8")); + TEST_ASSERT_EQUAL_STRING("PictureFrame-91F8", ssid.c_str()); +} + +int main(int argc, char** argv) { + UNITY_BEGIN(); + RUN_TEST(test_fw01_200_response_happy_path); + RUN_TEST(test_fw02_headers_read_before_end_regression); + RUN_TEST(test_fw03_304_no_redraw); + RUN_TEST(test_fw04_204_shows_setup_qr); + RUN_TEST(test_fw05_404_shows_setup_qr); + RUN_TEST(test_fw06_error_fills_yellow); + RUN_TEST(test_fw07_current_image_id_sent_when_saved); + RUN_TEST(test_fw08_no_current_image_id_when_default); + RUN_TEST(test_fw09_server_interval_honored); + RUN_TEST(test_fw10_server_interval_capped); + RUN_TEST(test_fw11_default_interval_when_absent); + RUN_TEST(test_fw12_ap_ssid_from_mac_aabbcc); + RUN_TEST(test_fw13_ap_ssid_from_real_mac); + RUN_TEST(test_fw14_304_skips_epd_sleep); + RUN_TEST(test_fw15_nvs_saved_before_epd_draw_and_flag_cleared); + RUN_TEST(test_fw16_304_with_draw_needed_redraws); + return UNITY_END(); +} diff --git a/firmware/test/test_provisioning/test_main.cpp b/firmware/test/test_provisioning/test_main.cpp new file mode 100644 index 0000000..cab2bb3 --- /dev/null +++ b/firmware/test/test_provisioning/test_main.cpp @@ -0,0 +1,84 @@ +#include +#include +#include + +// Include mocks first +#include "Arduino.h" +#include "WiFi.h" +#include "Preferences.h" +#include "config.h" + +// Define all extern globals required by mock headers +uint32_t g_millis_value; +int g_digital_read_value; +int g_wifi_status; + +// operation.h uses g_show_setup_qr_count under UNIT_TEST +int g_show_setup_qr_count; + +// Include the functions under test +#include "../../src/operation.h" + +void reset_state() { + g_millis_value = 0; + g_digital_read_value = HIGH; // button not pressed + g_wifi_status = WL_CONNECTED; + g_show_setup_qr_count = 0; +} + +void setUp() { reset_state(); } +void tearDown() {} + +// ── FW-14: attempt_wifi returns true when WiFi connects immediately ─────────── +void test_fw14_attempt_wifi_returns_true_on_connect() { + g_wifi_status = WL_CONNECTED; + bool result = attempt_wifi("myssid", "mypass"); + TEST_ASSERT_TRUE(result); +} + +// ── FW-15: attempt_wifi returns false after timeout ─────────────────────────── +// millis() auto-increments by 10 on each call; after enough iterations the +// elapsed time exceeds WIFI_TIMEOUT_MS (30000 ms). +void test_fw15_attempt_wifi_returns_false_on_timeout() { + g_wifi_status = 0; // never WL_CONNECTED + g_millis_value = 0; + bool result = attempt_wifi("myssid", "mypass"); + TEST_ASSERT_FALSE(result); +} + +// ── FW-16: loop() state-machine (WiFi-credential submission path) ───────────── +// This test is deferred: loop() orchestrates DNS, WebServer, and WiFi +// together in a single function, making it impractical to unit-test without +// a larger integration harness. The provisioning behavior is covered at the +// integration / hardware level. +// Placeholder: always passes as a reminder. +void test_fw16_loop_state_machine_deferred() { + TEST_PASS_MESSAGE("FW-16 deferred: loop() state machine requires integration harness"); +} + +// ── FW-17: check_reset_button returns true when button held past threshold ──── +// g_digital_read_value = LOW keeps the while-loop spinning; millis() +// auto-increments by 10 per call and will exceed RESET_HOLD_MS (5000 ms). +void test_fw17_reset_button_held_returns_true() { + g_digital_read_value = LOW; + g_millis_value = 0; + bool result = check_reset_button(); + TEST_ASSERT_TRUE(result); +} + +// ── FW-18: check_reset_button returns false when button not pressed ─────────── +void test_fw18_reset_button_not_pressed_returns_false() { + g_digital_read_value = HIGH; // button not pressed — loop exits immediately + bool result = check_reset_button(); + TEST_ASSERT_FALSE(result); +} + +int main(int argc, char** argv) { + UNITY_BEGIN(); + RUN_TEST(test_fw14_attempt_wifi_returns_true_on_connect); + RUN_TEST(test_fw15_attempt_wifi_returns_false_on_timeout); + RUN_TEST(test_fw16_loop_state_machine_deferred); + RUN_TEST(test_fw17_reset_button_held_returns_true); + RUN_TEST(test_fw18_reset_button_not_pressed_returns_false); + return UNITY_END(); +}