013e49d859
Three problems surfaced during the first 13.3" end-to-end run: 1) LittleFS IntegerDivideByZero on 200 → write /img.bin. Cause: the ~3.5 MB SPIFFS in default_16MB.csv can't fit three 960 KB setup screens + a 960 KB cached image (~3.84 MB). Switching to a custom partitions_13e6.csv with 24 MB LittleFS on the 32 MB flash. 2) Yellow wash across the panel on long SPI bursts. Cause: SPI DMA from a PSRAM-backed scratch buffer hits a cache-coherency window — the CPU's writes hadn't reached PSRAM yet when DMA read it. Push each half in 8 KB chunks through an internal-SRAM (DMA-coherent) scratch, and drop the bus clock to 4 MHz to match the 7.3" production speed. 3) Bootstrap window (no image yet) was deep-sleeping for 15 s between polls — each cycle a ~5 s ROM-boot + Wi-Fi reconnect, so the user waited ~20 s × N retries between scanning the setup QR and seeing their first photo land. Now normal_operation_impl returns early during bootstrap and main.cpp's normal_operation loops with a 2 s delay, keeping Wi-Fi up. Once the first image arrives, the normal scheduled deep sleep takes over. Also fixes a related bug Matt called out: a transient TLS hiccup during bootstrap was hitting the 5xx fallback path and painting a full yellow fill over the green setup QR, leaving the user with no claim path. Criterion is now "does /img.bin exist?" (panel has something worth showing with a border) rather than "is currentImgId set?", so a fresh device with no cached image preserves the setup screen through transient network errors. Diagnostic prints in the panel driver + [op] start/code lines in normal_operation_impl that proved invaluable during bringup; leaving them in for now. Tests updated for the new bootstrap semantics (deep sleep no longer arms on bootstrap-cycle 204/404/5xx); 43/43 native tests pass, 7.3" production build stays byte-identical. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
588 lines
26 KiB
C++
588 lines
26 KiB
C++
#include <unity.h>
|
|
#include <map>
|
|
#include <string>
|
|
#include <cstdint>
|
|
#include <cctype>
|
|
|
|
// 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<std::string, std::string> g_http_response_headers;
|
|
std::map<std::string, std::string> 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;
|
|
int g_epd_draw_border_count, g_epd_draw_border_last_color, g_epd_draw_border_last_thickness;
|
|
|
|
uint64_t g_sleep_us;
|
|
bool g_deep_sleep_started;
|
|
esp_sleep_wakeup_cause_t g_wakeup_cause;
|
|
int g_ext0_wakeup_pin;
|
|
int g_ext0_wakeup_level;
|
|
|
|
// 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_epd_draw_border_count = g_epd_draw_border_last_color = g_epd_draw_border_last_thickness = 0;
|
|
g_sleep_us = 0;
|
|
g_deep_sleep_started = false;
|
|
g_wakeup_cause = ESP_SLEEP_WAKEUP_TIMER; // default to timer wake unless a test sets cold
|
|
g_ext0_wakeup_pin = -1;
|
|
g_ext0_wakeup_level = -1;
|
|
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() {
|
|
// 30 s — at SLEEP_CLAMP_MIN_MS, well under MAX, so honored as-is
|
|
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;
|
|
// 30 s — at SLEEP_CLAMP_MIN_MS, well under MAX, so honored as-is
|
|
g_http_response_headers["X-Interval-Ms"] = "30000";
|
|
// 304 only ever happens when the device already holds the image, so
|
|
// pre-set img_id to match what the server would have served. Without
|
|
// this the FIRST_IMAGE_POLL bootstrap override would (correctly)
|
|
// shorten sleep to 15 s — and that's not what this test exercises.
|
|
prefs.ints[NVS_KEY_IMG_ID] = 1;
|
|
|
|
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 with no prior image — panel already shows the setup QR from
|
|
// provisioning; the firmware MUST NOT redraw it on every 15s bootstrap poll
|
|
// or the e-ink panel sits in a perpetual mid-refresh loop.
|
|
void test_fw04_204_no_prior_image_does_not_redraw() {
|
|
g_http_get_code = 204;
|
|
// currentImgId defaults to -1 from prefs.clear() in reset_state()
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL_MESSAGE(0, g_show_setup_qr_count,
|
|
"204 must not redraw the QR — panel already holds it from provisioning");
|
|
TEST_ASSERT_EQUAL(0, g_epd_init_count);
|
|
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
|
// Bootstrap: no deep sleep. Caller (main.cpp normal_operation loop) keeps
|
|
// WiFi up and retries on a short BOOTSTRAP_RETRY_INTERVAL_MS timer.
|
|
TEST_ASSERT_FALSE(g_deep_sleep_started);
|
|
}
|
|
|
|
// FW-04b: 204 after a real image was previously displayed — panel holds the
|
|
// photo; firmware MUST NOT paint the setup QR over it.
|
|
void test_fw04b_204_with_prior_image_does_not_redraw() {
|
|
g_http_get_code = 204;
|
|
prefs.ints[NVS_KEY_IMG_ID] = 7; // device has previously painted image #7
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL_MESSAGE(0, g_show_setup_qr_count,
|
|
"204 must not paint the setup QR over a real photo");
|
|
TEST_ASSERT_EQUAL(0, g_epd_init_count);
|
|
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
|
TEST_ASSERT_EQUAL(0, g_epd_fill_count);
|
|
}
|
|
|
|
// FW-05: 404 — same logic as 204; panel keeps whatever's there. Without a
|
|
// prior image, the bootstrap path also skips deep sleep (caller retries).
|
|
void test_fw05_404_does_not_redraw() {
|
|
g_http_get_code = 404;
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL(0, g_show_setup_qr_count);
|
|
TEST_ASSERT_EQUAL(0, g_epd_init_count);
|
|
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
|
TEST_ASSERT_FALSE(g_deep_sleep_started);
|
|
}
|
|
|
|
// FW-06a: 5xx error WITH a cached image → preserve last image and overlay a
|
|
// yellow BORDER (per FR38). MUST NOT fill the screen with yellow — that would
|
|
// destroy the last good image. Sets the err_border NVS flag so the next
|
|
// healthy response repaints clean.
|
|
void test_fw06a_error_with_cache_draws_border_not_fill() {
|
|
g_http_get_code = 500;
|
|
LittleFS.files[IMAGE_PATH] = "IMGDATA";
|
|
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
|
|
TEST_ASSERT_EQUAL_MESSAGE(0, g_epd_fill_count,
|
|
"epd_fill must NOT be called when a cached image exists — it would obliterate the photo");
|
|
TEST_ASSERT_EQUAL(1, g_epd_draw_border_count);
|
|
TEST_ASSERT_EQUAL(COLOR_YELLOW, g_epd_draw_border_last_color);
|
|
TEST_ASSERT_EQUAL(BORDER_THICKNESS_PX, g_epd_draw_border_last_thickness);
|
|
TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
|
|
}
|
|
|
|
// FW-06b: 5xx error during the bootstrap window (no cached image) MUST NOT
|
|
// paint anything — the panel is holding the green setup-claim QR, which the
|
|
// user still needs to scan. A transient TLS hiccup or server blip wiping it
|
|
// to yellow leaves the recipient stranded with no path to claim the frame.
|
|
// err_border MUST NOT be set either, since there's nothing to "recover" from
|
|
// on the next healthy response.
|
|
void test_fw06b_error_without_cache_preserves_setup_screen() {
|
|
g_http_get_code = 500;
|
|
// LittleFS has no IMAGE_PATH entry
|
|
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
|
|
TEST_ASSERT_EQUAL(0, g_epd_draw_border_count);
|
|
TEST_ASSERT_EQUAL_MESSAGE(0, g_epd_fill_count,
|
|
"5xx during bootstrap must NOT paint yellow over the setup QR");
|
|
// err_border NOT set — default sentinel (-1) means key wasn't written.
|
|
TEST_ASSERT_EQUAL(-1, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
|
|
TEST_ASSERT_FALSE(g_deep_sleep_started);
|
|
}
|
|
|
|
// FW-06c: 304 with err_border flag set (sync recovered after a previous
|
|
// failure) → repaint the cached image clean and clear the flag.
|
|
void test_fw06c_304_after_error_repaints_clean() {
|
|
g_http_get_code = 304;
|
|
prefs.ints[NVS_KEY_ERR_BORDER] = 1;
|
|
prefs.ints[NVS_KEY_IMG_ID] = 7;
|
|
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_MESSAGE(0, g_epd_draw_border_count,
|
|
"304 with err flag must redraw clean — no border on the recovery frame");
|
|
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
|
|
}
|
|
|
|
// FW-06d: 304 (same image) with NO error or pending state must NOT touch the
|
|
// display. Specifically must not invoke any yellow path. Locks down the
|
|
// regression the user reported: 304 was suspected of triggering yellow fill.
|
|
void test_fw06d_304_steady_state_does_not_fill_yellow() {
|
|
g_http_get_code = 304;
|
|
prefs.ints[NVS_KEY_IMG_ID] = 7;
|
|
prefs.ints[NVS_KEY_SCHEMA_V] = NVS_SCHEMA_VERSION; // post-migration steady state
|
|
LittleFS.files[IMAGE_PATH] = "IMGDATA";
|
|
// err_border = 0, draw_needed = 0 (defaults)
|
|
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
|
|
TEST_ASSERT_EQUAL(0, g_epd_fill_count);
|
|
TEST_ASSERT_EQUAL(0, g_epd_draw_border_count);
|
|
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
|
TEST_ASSERT_EQUAL(0, g_epd_init_count);
|
|
}
|
|
|
|
// FW-06e: 200 response after a previous error border → fresh image fully
|
|
// overwrites the framebuffer, err_border flag cleared.
|
|
void test_fw06e_200_after_error_clears_flag() {
|
|
g_http_response_headers["X-Image-Id"] = "8";
|
|
g_http_body = "BINDATA";
|
|
prefs.ints[NVS_KEY_ERR_BORDER] = 1;
|
|
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
|
|
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
|
|
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
|
|
}
|
|
|
|
// FW-06f: First boot of err-border-aware firmware (NVS schema_v missing) on a
|
|
// device that may be displaying a stale full-screen yellow from the previous
|
|
// buggy build → migration forces a clean redraw on the next 304 and bumps
|
|
// schema_v so it doesn't fire again. Locks in the upgrade-path recovery.
|
|
void test_fw06f_schema_migration_forces_redraw_on_first_boot() {
|
|
g_http_get_code = 304;
|
|
prefs.ints[NVS_KEY_IMG_ID] = 3;
|
|
LittleFS.files[IMAGE_PATH] = "IMGDATA";
|
|
// No NVS_KEY_SCHEMA_V set → defaults to 0, triggers migration
|
|
|
|
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(NVS_SCHEMA_VERSION, prefs.getInt(NVS_KEY_SCHEMA_V, -1));
|
|
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
|
|
}
|
|
|
|
// FW-06g: Second boot under same firmware → migration does NOT fire again,
|
|
// 304 is a no-op as in the steady-state case.
|
|
void test_fw06g_schema_migration_does_not_fire_again() {
|
|
g_http_get_code = 304;
|
|
prefs.ints[NVS_KEY_IMG_ID] = 3;
|
|
prefs.ints[NVS_KEY_SCHEMA_V] = NVS_SCHEMA_VERSION;
|
|
LittleFS.files[IMAGE_PATH] = "IMGDATA";
|
|
|
|
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_EQUAL(0, g_epd_fill_count);
|
|
}
|
|
|
|
// 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 within clamp range → exact server value used.
|
|
// 5 min sits well between SLEEP_CLAMP_MIN_MS and SLEEP_CLAMP_MAX_MS.
|
|
void test_fw09_server_interval_honored() {
|
|
g_http_response_headers["X-Interval-Ms"] = "300000"; // 5 min
|
|
g_http_response_headers["X-Image-Id"] = "1";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL_UINT64(300000ULL * 1000ULL, g_sleep_us);
|
|
}
|
|
|
|
// FW-10: server interval > SLEEP_CLAMP_MAX_MS → clamped at MAX.
|
|
// Protects against a misconfigured "every 999 days" stranding the device.
|
|
void test_fw10_server_interval_clamped_to_max() {
|
|
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(SLEEP_CLAMP_MAX_MS * 1000ULL, g_sleep_us);
|
|
}
|
|
|
|
// FW-10b: server interval < SLEEP_CLAMP_MIN_MS → clamped UP to MIN.
|
|
// Protects the battery against a runaway poll if the server sends 1000 ms
|
|
// (or anything malformed-but-positive that survives strtoull).
|
|
void test_fw10b_server_interval_clamped_to_min() {
|
|
g_http_response_headers["X-Interval-Ms"] = "1000";
|
|
g_http_response_headers["X-Image-Id"] = "1";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL_UINT64(SLEEP_CLAMP_MIN_MS * 1000ULL, g_sleep_us);
|
|
}
|
|
|
|
// FW-FORCE-RESYNC: a wakeup cause of UNDEFINED (cold boot, hard reset,
|
|
// power-cycle) MUST surface to the server as X-Boot-Reason: cold so that
|
|
// the server can force a rotation regardless of the wakeTimes schedule.
|
|
// This is what makes "unplug → replug" a manual refresh feature for users.
|
|
void test_fw_cold_boot_sends_X_Boot_Reason_cold() {
|
|
g_wakeup_cause = ESP_SLEEP_WAKEUP_UNDEFINED;
|
|
g_http_response_headers["X-Image-Id"] = "1";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL_STRING("cold", g_http_request_headers["X-Boot-Reason"].c_str());
|
|
}
|
|
|
|
// Inverse of FW-FORCE-RESYNC: scheduled timer wakeups must not pretend to be
|
|
// power-cycles, or every poll would force a rotation and the schedule gating
|
|
// would be useless.
|
|
void test_fw_timer_wake_sends_X_Boot_Reason_timer() {
|
|
g_wakeup_cause = ESP_SLEEP_WAKEUP_TIMER;
|
|
g_http_response_headers["X-Image-Id"] = "1";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL_STRING("timer", g_http_request_headers["X-Boot-Reason"].c_str());
|
|
}
|
|
|
|
// FW-PROV-A: just-provisioned NVS flag set → header sent on the poll. Without
|
|
// this header the server can't tell a freshly-reset device from a cached
|
|
// one, and would happily serve the prior owner's photo to a buyer.
|
|
void test_fw_just_provisioned_flag_sets_header() {
|
|
prefs.ints[NVS_KEY_JUST_PROVISIONED] = 1;
|
|
g_http_response_headers["X-Image-Id"] = "1";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL_STRING("1", g_http_request_headers["X-Just-Provisioned"].c_str());
|
|
}
|
|
|
|
// FW-PROV-B: no flag → no header. Steady-state polls must not look like
|
|
// fresh provisioning, otherwise every reboot would force the awaiting-
|
|
// claim gate.
|
|
void test_fw_no_flag_means_no_header() {
|
|
// prefs.ints[NVS_KEY_JUST_PROVISIONED] is unset, default 0.
|
|
g_http_response_headers["X-Image-Id"] = "1";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_TRUE(
|
|
g_http_request_headers.find("X-Just-Provisioned") == g_http_request_headers.end()
|
|
);
|
|
}
|
|
|
|
// FW-PROV-C: server returns X-Claimed: 1 → flag clears in NVS. From then
|
|
// on the firmware polls without the just-provisioned signal, just like a
|
|
// long-stable device.
|
|
void test_fw_X_Claimed_response_clears_flag() {
|
|
prefs.ints[NVS_KEY_JUST_PROVISIONED] = 1;
|
|
g_http_response_headers["X-Image-Id"] = "1";
|
|
g_http_response_headers["X-Claimed"] = "1";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_JUST_PROVISIONED, -1));
|
|
}
|
|
|
|
// FW-RESET-WAKE: every deep-sleep cycle must arm EXT0 wakeup on the BOOT
|
|
// button, otherwise holding it during sleep does nothing and the user-
|
|
// facing 5-second-hold reset only works during the brief awake window.
|
|
// Reported live by Matt 2026-05-09: held BOOT, device didn't reset.
|
|
void test_fw_deep_sleep_arms_ext0_button_wakeup() {
|
|
g_http_response_headers["X-Image-Id"] = "1";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL(PIN_BTN_RESET, g_ext0_wakeup_pin);
|
|
TEST_ASSERT_EQUAL(0, g_ext0_wakeup_level); // active-low; BOOT is pulled-up
|
|
}
|
|
|
|
// FW-FIRST-IMG-A: device has never received an image (img_id = -1) AND the
|
|
// poll didn't deliver one (e.g. 204 because no images approved yet). The
|
|
// bootstrap path must SKIP deep sleep entirely — the caller (main.cpp's
|
|
// normal_operation loop) keeps WiFi alive and re-invokes the function on
|
|
// a short BOOTSTRAP_RETRY_INTERVAL_MS timer so the user doesn't watch a
|
|
// ~5 s deep-sleep + wifi-reconnect on every "no image yet" cycle.
|
|
// Without this contract, a fresh device on a noon-daily schedule would
|
|
// deep-sleep for the server's 6-hour interval and sit dark all day.
|
|
void test_fw_first_image_bootstrap_skips_deep_sleep() {
|
|
g_http_get_code = 204;
|
|
// Server tries to set a 6-hour interval for the user's noon-daily schedule.
|
|
g_http_response_headers["X-Interval-Ms"] = String((unsigned long)(6ULL * 60 * 60 * 1000)).c_str();
|
|
// No img_id in NVS → device has never seen an image.
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_FALSE(g_deep_sleep_started);
|
|
}
|
|
|
|
// FW-FIRST-IMG-B: once we've persisted an image (200 path wrote img_id), the
|
|
// server's schedule wins again. Without this assertion the override would
|
|
// trap the device in 15s polling forever and burn the battery.
|
|
void test_fw_first_image_bootstrap_clears_after_200() {
|
|
// Pre-set NVS as if we'd received an image previously.
|
|
prefs.ints[NVS_KEY_IMG_ID] = 42;
|
|
g_http_get_code = 200;
|
|
g_http_response_headers["X-Image-Id"] = "42";
|
|
g_http_response_headers["X-Interval-Ms"] = "300000"; // 5 min (well above 15s)
|
|
g_http_body = "BINDATA";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL_UINT64(300000ULL * 1000ULL, g_sleep_us);
|
|
}
|
|
|
|
// FW-FIRST-IMG-C: receiving the FIRST image (200 with previously -1 img_id)
|
|
// must let the server's schedule take over starting from this very poll —
|
|
// no extra "one more 15s sleep" cycle.
|
|
void test_fw_first_image_just_arrived_uses_server_interval() {
|
|
// img_id starts at -1 (default, no prior image).
|
|
g_http_get_code = 200;
|
|
g_http_response_headers["X-Image-Id"] = "1";
|
|
g_http_response_headers["X-Interval-Ms"] = "300000";
|
|
g_http_body = "BINDATA";
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
// The 200 path persists img_id=1 BEFORE sleep is computed.
|
|
TEST_ASSERT_EQUAL_UINT64(300000ULL * 1000ULL, g_sleep_us);
|
|
}
|
|
|
|
// FW-PROV-D: server omits X-Claimed (e.g. stale-binding 204) → flag stays
|
|
// set so the device keeps signaling "I'm freshly provisioned" until a
|
|
// later poll lands on a fresh-binding response.
|
|
void test_fw_no_X_Claimed_response_keeps_flag() {
|
|
prefs.ints[NVS_KEY_JUST_PROVISIONED] = 1;
|
|
g_http_get_code = 204;
|
|
// No X-Claimed header set.
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_JUST_PROVISIONED, -1));
|
|
}
|
|
|
|
// FW-11: no X-Interval-Ms → fallback used. Should not happen in normal
|
|
// operation but guards against rolling deploys / hand-crafted responses.
|
|
void test_fw11_fallback_used_when_header_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_FALLBACK * 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-17a: 200 response with mismatched X-Image-Sha256 — the bytes were
|
|
// corrupted in transit. Don't paint the panel; don't update NVS_KEY_IMG_ID
|
|
// (so the next poll re-fetches); raise err_border so the user sees a sync
|
|
// issue instead of garbage on the panel.
|
|
void test_fw17a_sha256_mismatch_skips_draw_and_keeps_old_img_id() {
|
|
g_http_response_headers["X-Image-Id"] = "42";
|
|
g_http_response_headers["X-Image-Sha256"] = "deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe";
|
|
g_http_body = "BINDATA";
|
|
prefs.ints[NVS_KEY_IMG_ID] = 7; // pre-existing image we want to keep
|
|
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
|
|
// Panel must not be touched — the corrupt bytes never reach hardware.
|
|
TEST_ASSERT_EQUAL(0, g_epd_init_count);
|
|
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
|
|
// NVS image-id stays at the prior value — next poll mismatches the
|
|
// server's currentImage, server sends 200 again, device retries.
|
|
TEST_ASSERT_EQUAL(7, prefs.getInt(NVS_KEY_IMG_ID, -1));
|
|
// err_border raised so the next healthy 304 will repaint clean.
|
|
TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
|
|
}
|
|
|
|
// FW-17b: 200 response without an X-Image-Sha256 header (e.g., older server)
|
|
// — verification is skipped, the success path runs as it always did. Backward-
|
|
// compat guarantee: the firmware still works against pre-checksum servers.
|
|
void test_fw17b_missing_sha256_header_skips_verification() {
|
|
g_http_response_headers["X-Image-Id"] = "42";
|
|
// No X-Image-Sha256 set
|
|
g_http_body = "BINDATA";
|
|
|
|
normal_operation_impl(String("mac"), http, String("url"), prefs);
|
|
|
|
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
|
|
TEST_ASSERT_EQUAL(42, prefs.getInt(NVS_KEY_IMG_ID, -1));
|
|
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -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_no_prior_image_does_not_redraw);
|
|
RUN_TEST(test_fw04b_204_with_prior_image_does_not_redraw);
|
|
RUN_TEST(test_fw05_404_does_not_redraw);
|
|
RUN_TEST(test_fw06a_error_with_cache_draws_border_not_fill);
|
|
RUN_TEST(test_fw06b_error_without_cache_preserves_setup_screen);
|
|
RUN_TEST(test_fw06c_304_after_error_repaints_clean);
|
|
RUN_TEST(test_fw06d_304_steady_state_does_not_fill_yellow);
|
|
RUN_TEST(test_fw06e_200_after_error_clears_flag);
|
|
RUN_TEST(test_fw06f_schema_migration_forces_redraw_on_first_boot);
|
|
RUN_TEST(test_fw06g_schema_migration_does_not_fire_again);
|
|
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_clamped_to_max);
|
|
RUN_TEST(test_fw10b_server_interval_clamped_to_min);
|
|
RUN_TEST(test_fw11_fallback_used_when_header_absent);
|
|
RUN_TEST(test_fw_cold_boot_sends_X_Boot_Reason_cold);
|
|
RUN_TEST(test_fw_timer_wake_sends_X_Boot_Reason_timer);
|
|
RUN_TEST(test_fw_just_provisioned_flag_sets_header);
|
|
RUN_TEST(test_fw_no_flag_means_no_header);
|
|
RUN_TEST(test_fw_X_Claimed_response_clears_flag);
|
|
RUN_TEST(test_fw_no_X_Claimed_response_keeps_flag);
|
|
RUN_TEST(test_fw_first_image_bootstrap_skips_deep_sleep);
|
|
RUN_TEST(test_fw_first_image_bootstrap_clears_after_200);
|
|
RUN_TEST(test_fw_first_image_just_arrived_uses_server_interval);
|
|
RUN_TEST(test_fw_deep_sleep_arms_ext0_button_wakeup);
|
|
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);
|
|
RUN_TEST(test_fw17a_sha256_mismatch_skips_draw_and_keeps_old_img_id);
|
|
RUN_TEST(test_fw17b_missing_sha256_header_skips_verification);
|
|
return UNITY_END();
|
|
}
|