Compare commits

5 Commits

Author SHA1 Message Date
football2801 22c9edb09e fix(13e6): pre-rotate LANDSCAPE diagram for CCW-to-landscape rotation
The user rotates the frame 90° CCW into landscape (not CW as the
previous comment block assumed), so the LANDSCAPE orientation diagram
needs to be pre-rotated the opposite direction to land upright.

- Previous: ribbon on bottom edge, LEFT arrow, label rotated 90° CCW on
  the diagram's left side (matched CW user rotation; rendered upside-
  down once the user actually rotates CCW into landscape).
- Now: ribbon on top edge, RIGHT arrow, label rotated 90° CW down the
  diagram's right side. After the user's 90° CCW rotation it lands as
  wide rect, ribbon-left, up-arrow — correct upright landscape.

Adds right_arrow() helper as the mirror of left_arrow(). Regenerated
all three setup-screen .bins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:15:59 -04:00
football2801 a31a39fdc4 feat(13e6): v1.0.0 — first known-good public release with WeVisto branding
Re-versions the 13.3" driver to a fresh v1.0.0 baseline. This is the
firmware/payload combination that's been verified end-to-end with the
WeVisto branding on a properly-powered Pi setup:

- 180° rotation of setup screens for ribbon-at-bottom mounting (from
  55ee5aa) — render path matches the server-side V2 physicalRotation=180.
- Clear-to-white pre-pass before each setup-screen draw (the d23f331
  change) so a transition from a full-color photo into the
  mostly-yellow AP screen doesn't leave ghost particles from the
  previous image.
- Setup screen renders the WeVisto wordmark (with yellow V), the
  Camogli harbor backdrop, two QRs, and the orientation tiles in full
  color over the existing hardware-SPI path.

A prior diagnostic detour (bit-banged SPI / multi-stage ghost_clear
cycles) was chasing what turned out to be a Pi 5 USB-A current budget
issue, not a firmware bug. With the host on a 27 W PSU and
usb_max_current_enable=1, hardware SPI at 4 MHz renders all six
Spectra-6 colors faithfully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:41:37 -04:00
football2801 55ee5aa95c fix(13e6): 180° rotate setup screens for ribbon-at-bottom mounting
Matches the server-side V2 physicalRotationDegrees=180° introduced in
pictureFrame-webApp@b355572. The setup screens are firmware-drawn (not
server-rendered) so they need their own compensation:

- scripts/gen_screens_13e6.py rotates the PIL image 180° in save_bin()
  before packing to 4bpp; preview PNGs reflect the rotated layout too.
- All three bg .bins regenerated (ap_bg, ap_bg_retry, setup_bg).
- epd_driver.cpp QR overlay coords updated to the post-rotation
  positions (AP 642,590 → 40,492; Setup 313,750 → 313,276).
- PANEL_FW_VERSION → v1.0.2

To deploy: pio run -e <env> -t upload AND pio run -e <env> -t uploadfs
so the rotated .bins land in LittleFS alongside the new code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:19:52 -04:00
football2801 fc1367fc55 chore(13e6): bump PANEL_FW_VERSION to v1.0.1
First post-v1.0 driver release. Power-monitor telemetry from d900083
has been reverted (28b6a35) — clean release with no debug headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:28:58 -04:00
football2801 28b6a353aa Revert "chore(13e6): TEMP power-monitor telemetry headers"
This reverts commit d900083398.
2026-05-15 20:28:22 -04:00
12 changed files with 64 additions and 74 deletions
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 238 KiB

+44 -24
View File
@@ -281,6 +281,15 @@ def left_arrow(draw, cx, cy, half_h=18, w=34, color=BK):
], fill=color) ], fill=color)
def right_arrow(draw, cx, cy, half_h=18, w=34, color=BK):
"""Solid filled triangle pointing right, centered on (cx, cy)."""
draw.polygon([
(cx + w // 2, cy),
(cx - w // 2, cy - half_h),
(cx - w // 2, cy + half_h),
], fill=color)
def paste_rotated_text(img, text, font, fill, anchor_xy, ccw_degrees=90): def paste_rotated_text(img, text, font, fill, anchor_xy, ccw_degrees=90):
""" """
Render `text` horizontally onto a transparent overlay, rotate ccw, and Render `text` horizontally onto a transparent overlay, rotate ccw, and
@@ -304,17 +313,17 @@ def orientation_diagrams(img, cx, top_y, label_color=None, compact=False):
PORTRAIT = upright tall rect, ribbon along the bottom short edge, PORTRAIT = upright tall rect, ribbon along the bottom short edge,
up-arrow inside. up-arrow inside.
LANDSCAPE = pre-rotated 90° CCW from upright landscape. The frame LANDSCAPE = pre-rotated 90° CW from upright landscape. The frame
rotation portrait→landscape is 90° CW (ribbon moves rotation portrait→landscape is 90° CCW (ribbon moves
bottom→left as viewed by the user); the CCW pre-rotation top→left as viewed by the user); the CW pre-rotation
cancels that, so when the user picks the frame up and cancels that, so when the user picks the frame up and
rotates it 90° CW into landscape the diagram lands rotates it 90° CCW into landscape the diagram lands
upright (wide rect, ribbon-left, up-arrow). upright (wide rect, ribbon-left, up-arrow).
In the portrait rendering that means: tall rect, ribbon In the portrait rendering that means: tall rect, ribbon
along bottom edge (was the LEFT edge upright), LEFT- along TOP edge (was the LEFT edge upright), RIGHT-
pointing arrow (was UP upright), and the "LANDSCAPE" pointing arrow (was UP upright), and the "LANDSCAPE"
label rotated 90° CCW so it runs up the long edge label rotated 90° CW so it runs DOWN the right long edge
reads horizontally once the frame is mounted landscape. reads horizontally once the frame is mounted landscape.
""" """
if label_color is None: if label_color is None:
label_color = BK label_color = BK
@@ -355,28 +364,29 @@ def orientation_diagrams(img, cx, top_y, label_color=None, compact=False):
pt_y + (diag_h - ribbon_thick) // 2) pt_y + (diag_h - ribbon_thick) // 2)
text_center(draw, pt_x + diag_w // 2, diag_bottom + 14, "PORTRAIT", F_TINY, BK) text_center(draw, pt_x + diag_w // 2, diag_bottom + 14, "PORTRAIT", F_TINY, BK)
# LANDSCAPE — pre-rotated 90° CCW from upright. # LANDSCAPE — pre-rotated 90° CW from upright (the user rotates 90° CCW
# into landscape; the CW pre-rotation cancels that so it lands upright).
# Upright landscape: wide rect, ribbon along LEFT long edge, UP arrow. # Upright landscape: wide rect, ribbon along LEFT long edge, UP arrow.
# After 90° CCW (in portrait rendering): tall rect, ribbon along BOTTOM # After 90° CW (in portrait rendering): tall rect, ribbon along TOP
# short edge, LEFT-pointing arrow. Label runs up the LEFT long edge, # short edge, RIGHT-pointing arrow. Label runs DOWN the RIGHT long edge,
# rotated 90° CCW so it reads L→R once the frame is rotated to landscape. # rotated 90° CW so it reads L→R once the frame is rotated to landscape.
draw.rectangle([ls_x, ls_y, ls_x + diag_w - 1, ls_y + diag_h - 1], draw.rectangle([ls_x, ls_y, ls_x + diag_w - 1, ls_y + diag_h - 1],
outline=BK, width=3) outline=BK, width=3)
draw.rectangle([ls_x, ls_y + diag_h - ribbon_thick, draw.rectangle([ls_x, ls_y,
ls_x + diag_w - 1, ls_y + diag_h - 1], fill=BK) ls_x + diag_w - 1, ls_y + ribbon_thick - 1], fill=BK)
left_arrow(draw, ls_x + diag_w // 2, right_arrow(draw, ls_x + diag_w // 2,
ls_y + (diag_h - ribbon_thick) // 2) ls_y + ribbon_thick + (diag_h - ribbon_thick) // 2)
# Rotated label, anchored just left of the diagram's left long edge. # Rotated label, anchored just right of the diagram's right long edge.
label_text = "LANDSCAPE" label_text = "LANDSCAPE"
bb = F_TINY.getbbox(label_text) bb = F_TINY.getbbox(label_text)
label_w = bb[2] - bb[0] label_w = bb[2] - bb[0]
label_h = bb[3] - bb[1] label_h = bb[3] - bb[1]
# Rotated label is `label_w` tall, `label_h` wide. Centred vertically # Rotated 90° CW: label is `label_w` tall, `label_h` wide. Centred
# against the rect, sitting just to its left. # vertically against the rect, sitting just to its right.
rotated_x = ls_x - label_h - 16 rotated_x = ls_x + diag_w + 16
rotated_y = ls_y + (diag_h - label_w) // 2 rotated_y = ls_y + (diag_h - label_w) // 2
paste_rotated_text(img, label_text, F_TINY, BK, (rotated_x, rotated_y), paste_rotated_text(img, label_text, F_TINY, BK, (rotated_x, rotated_y),
ccw_degrees=90) ccw_degrees=-90)
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@@ -564,6 +574,12 @@ def gen_setup():
# ── Save ───────────────────────────────────────────────────────────────────── # ── Save ─────────────────────────────────────────────────────────────────────
def save_bin(img, path, preview_path): def save_bin(img, path, preview_path):
# Physical mount compensation: 13.3" panel ships ribbon-at-bottom of
# portrait, opposite the scan-zero corner. Rotate 180° before packing
# so the .bin's scan order maps to a right-side-up image on the panel.
# Server-side render does the same — see DeviceModel::physicalRotationDegrees().
img = img.rotate(180)
data = pack(img) data = pack(img)
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(data) f.write(data)
@@ -593,7 +609,11 @@ if __name__ == "__main__":
print("Generating setup screen…") print("Generating setup screen…")
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()
print("QR overlay constants — keep these in sync with epd_driver.cpp:") # Post-180°-rotation coords for firmware (.bin is rotated in save_bin).
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}") rot_ap_x = W - AP_QR_X - AP_QR_PX
print(f" SETUP_QR_X={SETUP_QR_X}, SETUP_QR_Y={SETUP_QR_Y}, " rot_ap_y = H - AP_QR_Y - AP_QR_PX
f"SETUP_QR_CELL={SETUP_QR_CELL}, SETUP_QR_PX={SETUP_QR_PX}") rot_setup_x = W - SETUP_QR_X - SETUP_QR_PX
rot_setup_y = H - SETUP_QR_Y - SETUP_QR_PX
print("QR overlay constants (POST-180°-rotation) — keep these in sync with epd_driver.cpp:")
print(f" AP qr_x={rot_ap_x}, qr_y={rot_ap_y}, cell={AP_QR_CELL}, px={AP_QR_PX}")
print(f" Setup qr_x={rot_setup_x}, qr_y={rot_setup_y}, cell={SETUP_QR_CELL}, px={SETUP_QR_PX}")
-7
View File
@@ -117,13 +117,6 @@
// would silently keep displaying the prior owner's photos until the new // would silently keep displaying the prior owner's photos until the new
// owner happens to navigate to /setup/{mac}. // owner happens to navigate to /setup/{mac}.
#define NVS_KEY_JUST_PROVISIONED "just_prov" #define NVS_KEY_JUST_PROVISIONED "just_prov"
// TEMP: power-monitor — stores previous cycle's awake duration and
// epd_init() duration so the next boot can report them server-side as
// X-Prev-Awake-Ms / X-Prev-Panel-Init-Ms. Lets us verify PIN_PWR rail
// cut doesn't slow panel re-init or extend the awake window. Remove
// these keys + their reads/writes once the change is validated.
#define NVS_KEY_PREV_AWAKE_MS "tm_awk"
#define NVS_KEY_PREV_PANEL_INIT_MS "tm_pin"
// Bump when introducing a schema migration. Each new value can force a one-shot // Bump when introducing a schema migration. Each new value can force a one-shot
// recovery action on first boot of the new firmware. // recovery action on first boot of the new firmware.
-31
View File
@@ -116,12 +116,6 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
bool errBorder = prefs.getInt(NVS_KEY_ERR_BORDER, 0) != 0; bool errBorder = prefs.getInt(NVS_KEY_ERR_BORDER, 0) != 0;
int schemaV = prefs.getInt(NVS_KEY_SCHEMA_V, 0); int schemaV = prefs.getInt(NVS_KEY_SCHEMA_V, 0);
bool justProvisioned = prefs.getInt(NVS_KEY_JUST_PROVISIONED, 0) != 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(); prefs.end();
// Schema migration: on first boot under err-border-aware firmware, the // Schema migration: on first boot under err-border-aware firmware, the
@@ -168,17 +162,6 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
http.addHeader("X-Just-Provisioned", "1"); 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" }; const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id", "X-Image-Sha256", "X-Claimed" };
http.collectHeaders(collectHeaders, 4); http.collectHeaders(collectHeaders, 4);
int code = http.GET(); int code = http.GET();
@@ -261,12 +244,7 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
} }
displayInitialized = true; 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(); epd_init();
uint32_t panelInitMs = millis() - panelInitStart;
File r = LittleFS.open(IMAGE_PATH, "r"); File r = LittleFS.open(IMAGE_PATH, "r");
if (r) { epd_draw_image_from_file(r); r.close(); } if (r) { epd_draw_image_from_file(r); r.close(); }
@@ -275,7 +253,6 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
prefs.begin(NVS_NAMESPACE, false); prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0); prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
prefs.putInt(NVS_KEY_ERR_BORDER, 0); prefs.putInt(NVS_KEY_ERR_BORDER, 0);
prefs.putUInt(NVS_KEY_PREV_PANEL_INIT_MS, panelInitMs); // TEMP
prefs.end(); prefs.end();
} }
@@ -385,14 +362,6 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
// up, losing the saving. Released per-pin in epd_setup_pins() on // up, losing the saving. Released per-pin in epd_setup_pins() on
// wake via gpio_hold_dis(). // wake via gpio_hold_dis().
gpio_deep_sleep_hold_en(); 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(); esp_deep_sleep_start();
} }
+18 -3
View File
@@ -444,14 +444,29 @@ static void draw_from_lfs(const char* path, uint8_t fallback_color,
// rectangle exactly the size of QR_MODS × QR_CELL at (X, Y), and the // rectangle exactly the size of QR_MODS × QR_CELL at (X, Y), and the
// firmware paints the live QR into it. Mismatch = the QR draws over // firmware paints the live QR into it. Mismatch = the QR draws over
// decorative borders or the QR placeholder shows through. // decorative borders or the QR placeholder shows through.
//
// Coords are post-180°-rotation: the gen script rotates each .bin to
// compensate for the panel's ribbon-at-bottom physical mounting, so
// QR placeholders move to (W - old_x - QR_PX, H - old_y - QR_PX).
// AP (518 px QR): pre-rot 642,590 → post-rot 40,492
// Setup (574 px QR): pre-rot 313,750 → post-rot 313,276 (X centred)
// Setup screens (yellow AP / red retry / green setup) are mostly two-tone
// against a small palette. Transitioning from a full-color photo to one of
// these in a single DRF cycle leaves visible ghost of the previous image —
// Spectra-6's color particles need more discharge time than one refresh
// provides. Pre-clear to white first so the panel is fully reset, then draw
// the actual screen. Doubles the refresh time on setup events only.
void epd_draw_ap_screen(QRCode* qr) { void epd_draw_ap_screen(QRCode* qr) {
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 642, 590, 14); epd_fill(COLOR_WHITE);
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 40, 492, 14);
} }
void epd_draw_ap_screen_retry(QRCode* qr) { void epd_draw_ap_screen_retry(QRCode* qr) {
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 642, 590, 14); epd_fill(COLOR_WHITE);
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 40, 492, 14);
} }
void epd_draw_setup_screen(QRCode* qr) { void epd_draw_setup_screen(QRCode* qr) {
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 750, 14); epd_fill(COLOR_WHITE);
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 276, 14);
} }
+1 -1
View File
@@ -3,4 +3,4 @@
// Panel-specific firmware version for the Waveshare 13.3" Spectra-6 driver. // Panel-specific firmware version for the Waveshare 13.3" Spectra-6 driver.
// Bump on each driver change worth correlating with server-side reports. // Bump on each driver change worth correlating with server-side reports.
// Independent of the shared firmware version (HTTP / NVS / sleep / etc.). // Independent of the shared firmware version (HTTP / NVS / sleep / etc.).
#define PANEL_FW_VERSION "v1.0" #define PANEL_FW_VERSION "v1.0.0"
+1 -8
View File
@@ -8,7 +8,6 @@ extern int g_prefs_putint_seq; // sequence position of last putInt call
struct Preferences { struct Preferences {
std::map<std::string, int32_t> ints; std::map<std::string, int32_t> ints;
std::map<std::string, uint32_t> uints;
std::map<std::string, std::string> strings; std::map<std::string, std::string> strings;
bool _open = false; bool _open = false;
@@ -27,16 +26,10 @@ struct Preferences {
g_call_seq++; g_call_seq++;
} }
uint32_t getUInt(const char* key, uint32_t def = 0) {
auto it = uints.find(key);
return it != uints.end() ? it->second : def;
}
void putUInt(const char* key, uint32_t val) { uints[key] = val; }
String getString(const char* key, const char* def = "") { String getString(const char* key, const char* def = "") {
auto it = strings.find(key); auto it = strings.find(key);
return it != strings.end() ? String(it->second) : String(def); return it != strings.end() ? String(it->second) : String(def);
} }
void putString(const char* key, const String& val) { strings[key] = val._s; } void putString(const char* key, const String& val) { strings[key] = val._s; }
void clear() { ints.clear(); uints.clear(); strings.clear(); } void clear() { ints.clear(); strings.clear(); }
}; };