Files
pictureFrame-firmware/src/operation.h
T
football2801 d900083398 chore(13e6): TEMP power-monitor telemetry headers
To validate the PIN_PWR rail-cut change (e2c9d8f) without a bench
multimeter, have the device report its previous cycle's awake time
and panel-init time on each poll:

  X-Prev-Awake-Ms       — millis() at the moment esp_deep_sleep_start
                          armed, last cycle. Total awake duration
                          since reset, ~5–10 s steady-state.
  X-Prev-Panel-Init-Ms  — duration of epd_init() last cycle. Spikes
                          here would suggest the rail isn't coming
                          back up cleanly after the GPIO-hold release.

Headers are sent only when the cached NVS values are non-zero (skips
the first boot under this firmware). All call sites marked `// TEMP:
power-monitor` for clean removal once the change is validated. Two
new NVS keys (tm_awk, tm_pin) sit alongside the existing ones; mock
Preferences extended with getUInt/putUInt to match.

Server side logs the headers via `device.poll.power_telemetry`
(separate commit in pictureFrame-webApp).

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

418 lines
19 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
#include <Arduino.h>
#include <Preferences.h>
#include <LittleFS.h>
#include "config.h"
#ifdef UNIT_TEST
// In unit tests, use mock stubs for hardware-dependent headers.
// The test build adds test/mocks to the include path via -iquote.
#include "epd_mock.h"
#include "esp_sleep.h"
#include "driver/gpio.h"
#else
#include "epd.h"
#include <esp_sleep.h>
#include <driver/gpio.h>
#include <mbedtls/sha256.h>
#endif
// ── SHA-256 of a LittleFS file ────────────────────────────────────────────────
// Used to verify the server-supplied X-Image-Sha256 against what we actually
// wrote to flash. ESP32-S3 has a hardware SHA accelerator; mbedtls picks it up
// automatically. Returns lowercase hex (matches PHP's hash_file('sha256') and
// the X-Image-Sha256 header style). Empty string on failure or in unit tests
// (where mbedtls isn't linked) — callers treat that as "skip verification".
inline String sha256_of_file(const char* path) {
#ifdef UNIT_TEST
(void)path;
return String("");
#else
File f = LittleFS.open(path, "r");
if (!f) return String("");
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts(&ctx, 0); // 0 = SHA-256 (1 = SHA-224)
uint8_t buf[1024];
while (f.available()) {
int n = f.read(buf, sizeof(buf));
if (n <= 0) break;
mbedtls_sha256_update(&ctx, buf, (size_t)n);
}
uint8_t digest[32];
mbedtls_sha256_finish(&ctx, digest);
mbedtls_sha256_free(&ctx);
f.close();
char hex[65];
for (int i = 0; i < 32; i++) sprintf(&hex[i * 2], "%02x", digest[i]);
hex[64] = '\0';
return String(hex);
#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(("WeVisto-" + 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 (true) {
int s = WiFi.status();
if (s == WL_CONNECTED) return true;
// Bail the moment the radio reports a terminal failure — bad PSK
// surfaces as WL_CONNECT_FAILED and missing SSID as WL_NO_SSID_AVAIL
// within a few seconds. Without this the user stares at the yellow
// Step 1/2 for the full WIFI_TIMEOUT_MS before the red retry repaints.
if (s == WL_CONNECT_FAILED || s == WL_NO_SSID_AVAIL) return false;
if (millis() - start > WIFI_TIMEOUT_MS) return false;
delay(200);
}
}
// ── Reset button hold detection ───────────────────────────────────────────────
inline bool check_reset_button() {
uint32_t hold_start = millis();
while (digitalRead(PIN_BTN_RESET) == LOW) {
if (millis() - hold_start >= RESET_HOLD_MS) {
return true;
}
delay(50);
}
return false;
}
template<typename HTTP>
void normal_operation_impl(const String& mac, HTTP& http, const String& url, Preferences& prefs) {
Serial.println("[op] start GET " + url);
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;
bool errBorder = prefs.getInt(NVS_KEY_ERR_BORDER, 0) != 0;
int schemaV = prefs.getInt(NVS_KEY_SCHEMA_V, 0);
bool justProvisioned = prefs.getInt(NVS_KEY_JUST_PROVISIONED, 0) != 0;
// TEMP: power-monitor — previous cycle's awake + panel-init durations.
// Reported as X-Prev-Awake-Ms / X-Prev-Panel-Init-Ms below. Remove
// along with the corresponding writes near deep_sleep_start + the
// epd_init() timing block once the PIN_PWR cut is validated.
uint32_t prevAwakeMs = prefs.getUInt(NVS_KEY_PREV_AWAKE_MS, 0);
uint32_t prevPanelInitMs = prefs.getUInt(NVS_KEY_PREV_PANEL_INIT_MS, 0);
prefs.end();
// Schema migration: on first boot under err-border-aware firmware, the
// display may be holding a stale full-screen yellow from the old buggy
// epd_fill(YELLOW) path. The old firmware never wrote NVS_KEY_ERR_BORDER,
// so we'd have no other signal that a clean repaint is needed. Force one
// by treating this boot as if errBorder were set, then bump schema_v so
// it doesn't fire again.
if (schemaV < NVS_SCHEMA_VERSION) {
Serial.println(String("[op] schema migration v") + String(schemaV) + " -> v" +
String(NVS_SCHEMA_VERSION) + ", forcing one-shot recovery redraw");
errBorder = true;
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_SCHEMA_V, NVS_SCHEMA_VERSION);
prefs.end();
}
if (currentImgId >= 0) {
http.addHeader("X-Current-Image-Id", String(currentImgId));
}
// Report the panel hardware so the server can route image-rendering
// dimensions to the right DeviceModel. Comes from -DPANEL_ID in the
// active env's build_flags (see config.h). Sent on every poll so the
// server can correct a mis-set Device.model lazily without needing
// a separate registration handshake.
http.addHeader("X-Panel-Id", PANEL_ID);
// Tell the server how we got here. The server uses this to honor a
// power-cycle as a deliberate "force resync" — a poll that arrives with
// X-Boot-Reason: cold gets a fresh rotation even outside configured wake
// times, so unplugging and replugging the frame works as a manual refresh.
// Timer wakes (the normal case) keep their schedule-gated semantics.
const esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
http.addHeader("X-Boot-Reason",
cause == ESP_SLEEP_WAKEUP_TIMER ? "timer" : "cold");
// Tell the server we're freshly provisioned and haven't been
// acknowledged yet. The server uses this to refuse to serve images
// for a stale binding (e.g. sold device whose old owner forgot to
// "Remove this frame") and to send X-Claimed: 1 once the binding is
// current — at which point we clear the NVS flag below.
if (justProvisioned) {
http.addHeader("X-Just-Provisioned", "1");
}
// TEMP: power-monitor — last cycle's awake + panel-init times.
// Lets us see, server-side, whether the PIN_PWR rail cut affects
// either. Send only if non-zero (skips the first boot after a
// firmware that didn't store them).
if (prevAwakeMs > 0) {
http.addHeader("X-Prev-Awake-Ms", String((unsigned long)prevAwakeMs));
}
if (prevPanelInitMs > 0) {
http.addHeader("X-Prev-Panel-Init-Ms", String((unsigned long)prevPanelInitMs));
}
const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id", "X-Image-Sha256", "X-Claimed" };
http.collectHeaders(collectHeaders, 4);
int code = http.GET();
Serial.println("[op] GET code=" + String(code));
// Server confirmed we're claimed → flag clears, regardless of what
// happened to the response body. Without this, every poll forever
// would carry X-Just-Provisioned and force the awaiting-claim gate.
if (justProvisioned) {
String claimed = http.header("X-Claimed");
if (claimed == "1") {
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_JUST_PROVISIONED, 0);
prefs.end();
justProvisioned = false;
}
}
// Honor the server's X-Interval-Ms — that's the user's configured
// rotationIntervalMinutes / wakeTimes schedule, computed in
// DeviceImageController::computeIntervalMs. Clamp to sane physical
// limits so a malformed 0/garbage value doesn't burn the battery
// (CLAMP_MIN) and a misconfigured "every 999 days" doesn't strand the
// device for a week (CLAMP_MAX). When no header is present (server
// bug, mid-deploy), fall back to FETCH_INTERVAL_MS_FALLBACK.
uint64_t sleepMs = FETCH_INTERVAL_MS_FALLBACK;
String intervalHdr = http.header("X-Interval-Ms");
if (intervalHdr.length() > 0) {
uint64_t v = strtoull(intervalHdr.c_str(), nullptr, 10);
if (v > 0) {
sleepMs = v;
if (sleepMs < SLEEP_CLAMP_MIN_MS) sleepMs = SLEEP_CLAMP_MIN_MS;
if (sleepMs > SLEEP_CLAMP_MAX_MS) sleepMs = SLEEP_CLAMP_MAX_MS;
}
}
bool displayInitialized = false;
if (code == 200) {
String newId = http.header("X-Image-Id");
String newSha = http.header("X-Image-Sha256");
File f = LittleFS.open(IMAGE_PATH, "w", true);
if (f) { http.writeToStream(&f); f.close(); }
http.end();
// Verify integrity. If the server sent a SHA-256 and the bytes we
// just wrote don't hash to it, the transfer corrupted somewhere
// between Imagick and our flash — skip the panel update and leave
// NVS_KEY_IMG_ID alone, so the next poll re-fetches from scratch
// (the device will keep claiming the old image-id, server will see
// the mismatch and send 200 again with fresh bytes). Set the err
// border so the user sees a sync issue instead of garbage.
bool integrityOk = true;
if (newSha.length() > 0) {
String actualSha = sha256_of_file(IMAGE_PATH);
if (!newSha.equalsIgnoreCase(actualSha)) {
integrityOk = false;
Serial.println(String("[op] sha256 mismatch: expected=") + newSha +
" actual=" + actualSha + " — discarding image, retry next poll");
}
}
if (!integrityOk) {
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_ERR_BORDER, 1);
prefs.end();
// Don't touch the panel — it keeps whatever was up before.
// displayInitialized stays false; epd_sleep() at the bottom is
// skipped, so no spurious wait_busy timeout on the next cycle.
} else {
// 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;
// TEMP: power-monitor — time the panel init, stored in NVS at
// end of cycle for next boot to report. Remove the timing
// wrapper when the PIN_PWR cut is validated.
uint32_t panelInitStart = millis();
epd_init();
uint32_t panelInitMs = millis() - panelInitStart;
File r = LittleFS.open(IMAGE_PATH, "r");
if (r) { epd_draw_image_from_file(r); r.close(); }
// Draw complete — clear pending and error-border flags. The fresh
// image fully overwrites any prior border, so error state is gone.
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
prefs.putInt(NVS_KEY_ERR_BORDER, 0);
prefs.putUInt(NVS_KEY_PREV_PANEL_INIT_MS, panelInitMs); // TEMP
prefs.end();
}
} else if (code == 304) {
http.end();
// Redraw from LittleFS if either: a previous draw was interrupted
// (drawNeeded), or a sync-fail border is currently on screen and the
// server is healthy again (errBorder) — repaint clean to clear it.
if (drawNeeded || errBorder) {
Serial.println(String("[op] 304 with recovery flags (drawNeeded=") +
String((int)drawNeeded) + " errBorder=" +
String((int)errBorder) + ") -> repainting clean from /img.bin");
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.putInt(NVS_KEY_ERR_BORDER, 0);
prefs.end();
Serial.println("[op] recovery redraw complete; flags cleared");
} else {
Serial.println("[op] recovery aborted: /img.bin not in LittleFS");
}
}
} else if (code == 204 || code == 404) {
// No image to serve. Don't touch the panel — whatever's already
// displayed is the right thing:
// • currentImgId == -1 → the setup QR is up (painted by
// enter_provisioning after WiFi save). The 15s bootstrap poll
// hits this branch every cycle until the user claims via
// /setup/{mac}; redrawing the QR each time would put the panel
// in a perpetual ~20s e-ink redraw loop and risk ghosting.
// • currentImgId >= 0 → a real photo is up (server hiccup, asset
// missing, image deleted). Don't paint the setup QR over the
// user's photo; leave the last-good image alone.
// displayInitialized stays false → epd_sleep() at the bottom is
// also skipped, since the display was already in sleep from the
// previous cycle.
http.end();
} else {
// Sync failed (5xx, timeout, TLS handshake failure → code=-1, etc.).
// Criterion is "does /img.bin exist?", not "is currentImgId >= 0?":
// • file exists → a real photo is on the panel. Draw it with a
// yellow border so the user sees a sync signal without losing it.
// Per FR38, the last-good image must persist.
// • no file → bootstrap window. Panel is showing the setup-claim
// QR which the user still needs to scan. A transient TLS hiccup
// must NOT wipe that to a yellow fill — it would leave the
// recipient with no way to claim the frame until the next 200.
http.end();
File r = LittleFS.open(IMAGE_PATH, "r");
if (r) {
Serial.println(String("[op] sync fail code=") + String(code) +
" -> drawing image with yellow border");
displayInitialized = true;
epd_init();
epd_draw_image_with_border(r, COLOR_YELLOW, BORDER_THICKNESS_PX);
r.close();
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_ERR_BORDER, 1);
prefs.end();
} else {
Serial.println(String("[op] sync fail code=") + String(code) +
" with no cached image; preserving setup screen");
}
}
// 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();
// Bootstrap window: the device has never received an image — the user is
// still mid-setup, looking at the green setup QR, waiting for the first
// photo to land. Return WITHOUT arming deep sleep. The caller is expected
// to re-invoke us on a short timer until imgIdAfter goes >= 0, keeping
// WiFi up and the device responsive. The previous "15 s deep-sleep
// between bootstrap polls" wasted ~5 s per cycle on flash boot + WiFi
// reconnect, so end-user wait until first image was ~20 s × N retries.
prefs.begin(NVS_NAMESPACE, true);
int32_t imgIdAfter = prefs.getInt(NVS_KEY_IMG_ID, -1);
prefs.end();
if (imgIdAfter < 0) {
Serial.println("[op] bootstrap: no image yet, caller will retry");
return;
}
esp_sleep_enable_timer_wakeup(sleepMs * 1000ULL);
// Wake on the BOOT button so the user-facing "hold until the screen
// starts to flash" reset works even during deep sleep. Without this,
// the button only did anything during the brief awake window during
// a poll, and a full sleep cycle of holding did nothing.
// Pin is GPIO 0 (PIN_BTN_RESET); active-low because BOOT is pulled-up
// and shorts to ground on press. After EXT0 wakes the chip, setup()
// runs and check_reset_button() handles the remainder of the hold.
// A too-short press wakes the device but check_reset_button returns
// false → normal_operation_impl runs → the next poll fetches a fresh
// image, which doubles as a "force refresh" gesture.
esp_sleep_enable_ext0_wakeup((gpio_num_t)PIN_BTN_RESET, 0);
// Latch any gpio_hold_en pins through the deep sleep period.
// epd_sleep() cuts PIN_PWR LOW + holds it; without this call the
// hold releases on the RTC transition and the panel rail comes back
// up, losing the saving. Released per-pin in epd_setup_pins() on
// wake via gpio_hold_dis().
gpio_deep_sleep_hold_en();
// TEMP: power-monitor — millis() at this point is the total awake
// duration since boot. Next boot reads it back and reports as
// X-Prev-Awake-Ms. Remove when PIN_PWR cut is validated.
prefs.begin(NVS_NAMESPACE, false);
prefs.putUInt(NVS_KEY_PREV_AWAKE_MS, millis());
prefs.end();
esp_deep_sleep_start();
}
// ── Bootstrap-stay-awake loop ────────────────────────────────────────────────
// Wraps normal_operation_impl in a retry loop for the pre-first-image window.
// Once an image arrives, impl calls esp_deep_sleep_start() and never returns —
// in production that's literal silicon-level halt; in unit tests the mock just
// sets g_deep_sleep_started and returns, so we check that flag to break out.
// Caller passes a callable that runs one poll iteration (instantiates a fresh
// HTTPClient bound to the WiFiClient, calls normal_operation_impl, etc.).
template<typename PollOnce>
inline void bootstrap_loop(PollOnce poll_once) {
while (true) {
poll_once();
#ifdef UNIT_TEST
// Production: esp_deep_sleep_start never returns. Tests: it sets the
// flag and returns, so without this guard the loop spins forever.
if (g_deep_sleep_started) return;
#endif
delay(BOOTSTRAP_RETRY_INTERVAL_MS);
}
}