Files
football2801 8fb68e94e7 Revert "fix(13e6): X-Draw-Pending recovery handshake + CDC serial routing"
This reverts commit 3d7a793. The X-Draw-Pending header suppresses
rotation server-side whenever the firmware boots with NVS_KEY_DRAW_NEEDED
set — including overriding the cold-boot force-resync. If drawNeeded ever
gets stuck (e.g. an interrupted draw whose recovery branch itself fails),
the picture stops advancing entirely. Reverting until we have telemetry
confirming whether that flag is being asserted in the field; the bare
304-with-drawNeeded recovery branch alone is sufficient for the typical
power-loss-mid-draw case.

ARDUINO_USB_CDC_ON_BOOT=1 was bundled in the same commit and goes with
it — losing USB-serial visibility on the 13.3" until we re-add it cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 09:12:49 -04:00

666 lines
30 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-PANEL-A: X-Panel-Id header sent on every poll. V2 (13.3") shipped with
// this header but no test covered it; if a future refactor accidentally
// drops the addHeader call, the server's DeviceModel routing breaks silently
// (a 13.3" frame would be served 7.3" images cropped to 800x480).
void test_fw_panel_id_header_sent_on_every_poll() {
g_http_response_headers["X-Image-Id"] = "1";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_TRUE_MESSAGE(
g_http_request_headers.find("X-Panel-Id") != g_http_request_headers.end(),
"X-Panel-Id header must be present on every poll");
}
// FW-PANEL-B: Header value matches the compile-time PANEL_ID. In the native-
// test build PANEL_ID is "unknown" (no env -D flag) — that's the fallback
// path; the real production envs set the panel-specific id via build_flags.
void test_fw_panel_id_header_value_matches_macro() {
g_http_response_headers["X-Image-Id"] = "1";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_STRING(PANEL_ID, g_http_request_headers["X-Panel-Id"].c_str());
}
// FW-BOOT-LOOP-A: bootstrap_loop exits once the wrapped poll deep-sleeps
// (i.e. an image has arrived and impl entered esp_deep_sleep_start). In
// production esp_deep_sleep_start never returns; the test mock just sets
// g_deep_sleep_started and returns. Without the in-test break the loop
// would spin forever.
void test_fw_bootstrap_loop_exits_after_deep_sleep() {
g_http_response_headers["X-Image-Id"] = "1";
g_http_response_headers["X-Interval-Ms"] = "300000";
g_http_body = "BINDATA";
int iterations = 0;
bootstrap_loop([&]() {
iterations++;
normal_operation_impl(String("mac"), http, String("url"), prefs);
});
// One poll succeeded → deep_sleep flagged → loop returned. If the loop
// didn't honor the flag we'd never reach this assertion (test timeout).
TEST_ASSERT_EQUAL(1, iterations);
TEST_ASSERT_TRUE(g_deep_sleep_started);
}
// FW-BOOT-LOOP-B: pre-image-arrived path — impl returns WITHOUT deep_sleep,
// so the loop would iterate in production. In the test we drive the
// iteration count via a side channel: the callable bumps a counter and
// flips the response code mid-run, exercising the retry behaviour without
// trapping the test forever.
void test_fw_bootstrap_loop_iterates_when_no_image() {
g_http_get_code = 204; // no image available yet
// No img_id in NVS, so impl returns without arming deep sleep.
int iterations = 0;
bootstrap_loop([&]() {
iterations++;
normal_operation_impl(String("mac"), http, String("url"), prefs);
// After the third no-image poll, simulate the image arriving so the
// loop can exit cleanly. In production the loop runs until the
// server flips to 200 — we just compress the timeline.
if (iterations == 3) {
g_http_get_code = 200;
g_http_response_headers["X-Image-Id"] = "7";
g_http_response_headers["X-Interval-Ms"] = "300000";
g_http_body = "BINDATA";
}
});
// Three 204 spins + one 200 spin = 4 total iterations before the loop
// saw the deep-sleep flag and broke out. Locks in that the loop does
// NOT exit on a no-image return (would freeze the device on the QR).
TEST_ASSERT_EQUAL(4, iterations);
TEST_ASSERT_TRUE(g_deep_sleep_started);
}
// 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("WeVisto-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("WeVisto-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);
RUN_TEST(test_fw_panel_id_header_sent_on_every_poll);
RUN_TEST(test_fw_panel_id_header_value_matches_macro);
RUN_TEST(test_fw_bootstrap_loop_exits_after_deep_sleep);
RUN_TEST(test_fw_bootstrap_loop_iterates_when_no_image);
return UNITY_END();
}