diff --git a/data/waveshare73-v1/ap_bg_retry.bin b/data/waveshare73-v1/ap_bg_retry.bin new file mode 100644 index 0000000..0a73343 Binary files /dev/null and b/data/waveshare73-v1/ap_bg_retry.bin differ diff --git a/data/waveshare73-v1/ap_bg_retry_preview.png b/data/waveshare73-v1/ap_bg_retry_preview.png new file mode 100644 index 0000000..a8f4537 Binary files /dev/null and b/data/waveshare73-v1/ap_bg_retry_preview.png differ diff --git a/scripts/gen_screens.py b/scripts/gen_screens.py index 19d1442..f5bceae 100644 --- a/scripts/gen_screens.py +++ b/scripts/gen_screens.py @@ -146,15 +146,20 @@ def orientation_diagrams(draw, accent, show_active_ls=True): # ═══════════════════════════════════════════════════════════════════════════════ -# AP SCREEN — yellow accent, WiFi credentials +# AP SCREEN — accent-colored, WiFi credentials +# Pass accent=YL/header="SETUP MODE — STEP 1 OF 2"/qr_label="SCAN TO CONNECT" +# for the normal first-attempt screen, or accent=RD/header="CONNECTION FAILED +# — TRY AGAIN"/qr_label="Connection Failed — try again" for the post-WiFi-fail +# retry screen. Same layout either way so the panel diff is just color + +# header/label text. # ═══════════════════════════════════════════════════════════════════════════════ -def gen_ap(): +def gen_ap(accent=YL, header="SETUP MODE — STEP 1 OF 2", qr_label="SCAN TO CONNECT"): img = Image.new("RGB", (W, H), WH) draw = ImageDraw.Draw(img) # ── Status bar ──────────────────────────────────────────────── - draw.rectangle([0, 0, W-1, BAR_H-1], fill=YL) - draw.text((24, 18), "SETUP MODE — STEP 1 OF 2", font=F_BAR, fill=BK) + draw.rectangle([0, 0, W-1, BAR_H-1], fill=accent) + draw.text((24, 18), header, font=F_BAR, fill=BK) # Right chip: black box with device SSID chip_x, chip_y = 498, 11 @@ -163,7 +168,7 @@ def gen_ap(): chip_w = bb[2]-bb[0] + 22 chip_x2 = chip_x + chip_w draw.rectangle([chip_x, chip_y, chip_x2, BAR_H-12], fill=BK) - draw.text((chip_x+11, chip_y+7), chip_text, font=F_CHIP, fill=YL) + draw.text((chip_x+11, chip_y+7), chip_text, font=F_CHIP, fill=accent) # ── Panel dividers ──────────────────────────────────────────── draw.rectangle([DIV1_X, BODY_Y, DIV1_X+1, H-1], fill=BK) @@ -174,7 +179,7 @@ def gen_ap(): draw.text((28, BODY_Y+20), "Connect to", font=F_HEAD, fill=BK) draw.text((28, BODY_Y+52), "WiFi", font=F_HEAD, fill=BK) bb = draw.textbbox((0,0), "WiFi", font=F_HEAD) - draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=YL) + draw.rectangle([28, BODY_Y+82, 28+bb[2]+2, BODY_Y+85], fill=accent) # Steps steps = [ @@ -186,7 +191,7 @@ def gen_ap(): for i, (l1, l2) in enumerate(steps): bx, by = 28, sy + i*46 draw.rectangle([bx, by, bx+24, by+24], fill=BK) - text_center(draw, bx+12, by+6, str(i+1), F_STEPN, YL) + text_center(draw, bx+12, by+6, str(i+1), F_STEPN, accent) draw.text((62, by+3), l1, font=F_STEP, fill=BK) draw.text((62, by+17), l2, font=F_STEP, fill=BK) @@ -196,17 +201,18 @@ def gen_ap(): draw.text((28, BODY_Y+276), "Go to 192.168.4.1", font=F_FOOT, fill=BK) # ── Centre panel ───────────────────────────────────────────── - orientation_diagrams(draw, YL, show_active_ls=True) + orientation_diagrams(draw, accent, show_active_ls=True) # ── Right panel ────────────────────────────────────────────── cx = RIGHT_CX - # "SCAN TO CONNECT" label - text_center(draw, cx, AP_QR_Y - 26, "SCAN TO CONNECT", F_BIG, BK) + # QR label — accent-colored on retry so the failure is unmistakable. + label_color = accent if accent != YL else BK + text_center(draw, cx, AP_QR_Y - 26, qr_label, F_BIG, label_color) - # QR border: yellow outer, black inner + # QR border: accent outer, black inner qx, qy, qp = AP_QR_X, AP_QR_Y, AP_QR_PX - draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=YL, width=3) + draw.rectangle([qx-6, qy-6, qx+qp+5, qy+qp+5], outline=accent, width=3) draw.rectangle([qx-3, qy-3, qx+qp+2, qy+qp+2], outline=BK, width=3) # Leave QR area white for firmware overlay @@ -218,6 +224,16 @@ def gen_ap(): return img +def gen_ap_retry(): + """Step 1/2 with red accents + 'Connection Failed — try again' label, + served after a failed WiFi connection attempt.""" + return gen_ap( + accent=RD, + header="CONNECTION FAILED — TRY AGAIN", + qr_label="Connection Failed — try again", + ) + + # ═══════════════════════════════════════════════════════════════════════════════ # SETUP SCREEN — green accent, account link # ═══════════════════════════════════════════════════════════════════════════════ @@ -340,10 +356,13 @@ if __name__ == "__main__": os.makedirs(out_dir, exist_ok=True) print(f"Generating AP screen for {panel}…") - save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png") + save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png") + print() + print(f"Generating AP retry screen for {panel}…") + save_bin(gen_ap_retry(), f"{out_dir}/ap_bg_retry.bin", f"{out_dir}/ap_bg_retry_preview.png") print() print(f"Generating setup screen for {panel}…") - save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png") + save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png") print() print("QR overlay constants for epd.cpp:") print(f" AP_QR_X={AP_QR_X}, AP_QR_Y={AP_QR_Y}, AP_QR_CELL={AP_QR_CELL}, AP_QR_PX={AP_QR_PX}") diff --git a/src/epd.h b/src/epd.h index 21c1d46..ce26a01 100644 --- a/src/epd.h +++ b/src/epd.h @@ -20,4 +20,7 @@ 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); +// Same layout as ap_screen but with red accents and a "Connection Failed — +// try again" label, served after a failed WiFi join attempt. +void epd_draw_ap_screen_retry(QRCode* qr); void epd_draw_setup_screen(QRCode* qr); diff --git a/src/main.cpp b/src/main.cpp index 3dba3ee..6a024ca 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,13 +25,17 @@ static String g_req_pass; // ── QR helpers ─────────────────────────────────────────────────────────────── -static void show_ap_qr(const String& apSsid) { +static void show_ap_qr(const String& apSsid, bool retry = false) { 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_ap_screen(&qr); + if (retry) { + epd_draw_ap_screen_retry(&qr); + } else { + epd_draw_ap_screen(&qr); + } } static void show_setup_qr(const String& mac) { @@ -116,7 +120,7 @@ static void handle_captive() { // ── WiFi provisioning ───────────────────────────────────────────────────────── -static void enter_provisioning(const String& mac) { +static void enter_provisioning(const String& mac, bool retry = false) { // Derive AP name from last 4 hex chars of MAC (no colons) String suffix = mac; suffix.replace(":", ""); @@ -124,7 +128,7 @@ static void enter_provisioning(const String& mac) { suffix.toUpperCase(); String apSsid = "PictureFrame-" + suffix; - Serial.println("AP: " + apSsid); + Serial.println(retry ? "AP (retry): " + apSsid : "AP: " + apSsid); WiFi.disconnect(true); WiFi.mode(WIFI_AP); @@ -143,8 +147,11 @@ static void enter_provisioning(const String& mac) { server.onNotFound(handle_root); server.begin(); + // On retry, repaint with red accents + "Connection Failed — try again" + // label so the user has a clear visual signal that their last credential + // entry didn't work. On first entry, paint the standard yellow Step 1/2. epd_init(); - show_ap_qr(apSsid); + show_ap_qr(apSsid, retry); epd_sleep(); g_provisioning = true; @@ -267,13 +274,11 @@ void loop() { // serves an image — no need for an artificial display delay here. normal_operation(mac); } else { - // Connection failed — fill red, restart AP - epd_init(); - epd_fill(COLOR_RED); - epd_sleep(); - delay(3000); - - // Re-enter provisioning to retry - enter_provisioning(mac); + // Connection failed — go back into AP mode with the red retry screen + // so the user sees "Connection Failed — try again" and can rescan. + // No epd_fill(COLOR_RED) detour: that would obliterate the QR for + // ~20 s and force a second redraw to put it back. One repaint into + // the retry screen is faster and clearer. + enter_provisioning(mac, /*retry=*/true); } } diff --git a/src/operation.h b/src/operation.h index 81bc584..80979c7 100644 --- a/src/operation.h +++ b/src/operation.h @@ -263,16 +263,21 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre Serial.println("[op] recovery aborted: /img.bin not in LittleFS"); } } - } else if (code == 204) { + } 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(); - displayInitialized = true; - epd_init(); - show_setup_qr(mac); - } else if (code == 404) { - http.end(); - displayInitialized = true; - epd_init(); - show_setup_qr(mac); } else { // Sync failed (5xx, timeout, malformed). Per FR38, the last-good image // must persist; only the border indicates the error. epd_draw_image_with_border diff --git a/src/panels/waveshare73/v1/epd_driver.cpp b/src/panels/waveshare73/v1/epd_driver.cpp index 03ca2cf..63ef97d 100644 --- a/src/panels/waveshare73/v1/epd_driver.cpp +++ b/src/panels/waveshare73/v1/epd_driver.cpp @@ -179,6 +179,12 @@ void epd_draw_ap_screen(QRCode* qr) { draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 563, 185, 5); } +void epd_draw_ap_screen_retry(QRCode* qr) { + // Same QR coordinates — only the bg .bin differs (red accents, + // "Connection Failed — try again" label). + draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, 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/test/mocks/epd.h b/test/mocks/epd.h index a76a2d3..2353b67 100644 --- a/test/mocks/epd.h +++ b/test/mocks/epd.h @@ -14,4 +14,5 @@ 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_ap_screen_retry(void*) {} inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; } diff --git a/test/test_normal_operation/test_main.cpp b/test/test_normal_operation/test_main.cpp index 6addb74..d0d94d7 100644 --- a/test/test_normal_operation/test_main.cpp +++ b/test/test_normal_operation/test_main.cpp @@ -130,20 +130,41 @@ void test_fw03_304_no_redraw() { 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() { +// 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(1, g_show_setup_qr_count); + 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); + TEST_ASSERT_TRUE(g_deep_sleep_started); } -// FW-05: 404 — show_setup_qr called exactly once -void test_fw05_404_shows_setup_qr() { +// 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. +void test_fw05_404_does_not_redraw() { 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_show_setup_qr_count); + TEST_ASSERT_EQUAL(0, g_epd_init_count); TEST_ASSERT_EQUAL(0, g_epd_draw_image_count); + TEST_ASSERT_TRUE(g_deep_sleep_started); } // FW-06a: 5xx error WITH a cached image → preserve last image and overlay a @@ -517,8 +538,9 @@ int main(int argc, char** argv) { 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_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_falls_back_to_fill); RUN_TEST(test_fw06c_304_after_error_repaints_clean);