feat(story-2.1): firmware provisioning — AP mode, captive portal, QR display, image pull
State machine:
- Boot: check 5s reset-button hold → wipe NVS creds; load saved SSID/pass
- If no creds (or reset): enter_provisioning() — WiFi.softAP + DNS redirect + WebServer
- If creds: attempt_wifi(); on success → normal_operation(); on fail → enter_provisioning()
- normal_operation(): HTTPS GET /api/device/{mac}/image → stream to LittleFS → display;
204 = keep current stored image; 404 = red fill; server error = yellow fill;
deep sleep 15 min between polls
Provisioning flow:
- AP SSID: "PictureFrame-{last4hex}" broadcast as open network
- QR on e-ink: WIFI:S:PictureFrame-XXXX;T:nopass;; → phone auto-joins AP
- Captive portal: redirect all DNS to 192.168.4.1; serve minimal HTML form
(handles iOS /hotspot-detect.html and Android /generate_204 redirects)
- POST /connect: async — respond immediately, attempt WiFi in loop()
Success: save NVS, show Phase 2 setup QR (green bg) → 2min delay → normal_operation()
Failure: red fill → restart AP
EPD driver refactor:
- Extracted epd_init/sleep/fill/draw_qr/draw_image_from_file into epd.h + epd.cpp
- epd_draw_qr(): ricmoo/QRCode library; computes modules inline per pixel row
- epd_fill(): solid color in one pass (used for red=no-wifi, yellow=sync-fail)
- epd_draw_image_from_file(): streams LittleFS binary directly to display
Removed: convert_photo.py (pre-rendering moved to server-side Imagick), image.h (PROGMEM array)
Added: config.h, epd.h, epd.cpp; updated platformio.ini (QRCode lib, littlefs fs)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download a photo, letterbox to 800×480, Floyd-Steinberg dither to the
|
||||
Waveshare 7.3" 6-colour palette, and write src/image.h as a PROGMEM array."""
|
||||
|
||||
import io
|
||||
import math
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
W, H = 480, 800 # portrait canvas; firmware rotates 90° CW onto landscape display
|
||||
OUT = Path(__file__).parent / "src" / "image.h"
|
||||
|
||||
URL = (
|
||||
"https://d3eguztg5751m.cloudfront.net/as/assets-mem-com/cmi/3/0/0/7/11557003"
|
||||
"/20231128_115457370_0_orig.jpg/-/kenneth-edholm-fort-wayne-in-obituary.jpg"
|
||||
"?maxheight=650"
|
||||
)
|
||||
|
||||
# Waveshare 6-colour palette: (display code, match RGB)
|
||||
# RGB values tuned for photographic content — skin tones sit between
|
||||
# white and yellow/red, so those two anchors matter most.
|
||||
PALETTE = [
|
||||
(0x0, ( 0, 0, 0)), # Black
|
||||
(0x1, (255, 255, 255)), # White
|
||||
(0x2, (255, 220, 0)), # Yellow
|
||||
(0x3, (220, 40, 40)), # Red
|
||||
(0x5, ( 20, 80, 200)), # Blue
|
||||
(0x6, ( 50, 160, 50)), # Green
|
||||
]
|
||||
CODES = np.array([c for c, _ in PALETTE], dtype=np.uint8)
|
||||
PAL_RGB = np.array([rgb for _, rgb in PALETTE], dtype=np.float32) # (6,3)
|
||||
|
||||
|
||||
def floyd_steinberg(img_rgb: np.ndarray) -> np.ndarray:
|
||||
"""In-place F-S dither; returns (H,W) array of display colour codes."""
|
||||
arr = img_rgb.astype(np.float32)
|
||||
out = np.zeros((H, W), dtype=np.uint8)
|
||||
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
px = np.clip(arr[y, x], 0, 255)
|
||||
diffs = PAL_RGB - px
|
||||
idx = int(np.argmin((diffs ** 2).sum(axis=1)))
|
||||
out[y, x] = CODES[idx]
|
||||
|
||||
err = px - PAL_RGB[idx]
|
||||
if x + 1 < W:
|
||||
arr[y, x + 1] += err * (7 / 16)
|
||||
if y + 1 < H:
|
||||
if x > 0:
|
||||
arr[y + 1, x - 1] += err * (3 / 16)
|
||||
arr[y + 1, x] += err * (5 / 16)
|
||||
if x + 1 < W:
|
||||
arr[y + 1, x + 1] += err * (1 / 16)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"Downloading {URL} ...")
|
||||
req = urllib.request.Request(URL, headers={"User-Agent": "pictureFrame/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
img_bytes = r.read()
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
print(f"Downloaded: {img.size[0]}×{img.size[1]}")
|
||||
|
||||
# Letterbox: fit inside 800×480, centre on white background
|
||||
scale = min(W / img.size[0], H / img.size[1])
|
||||
new_w = int(img.size[0] * scale)
|
||||
new_h = int(img.size[1] * scale)
|
||||
resized = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
canvas = Image.new("RGB", (W, H), (255, 255, 255))
|
||||
canvas.paste(resized, ((W - new_w) // 2, (H - new_h) // 2))
|
||||
canvas.save("/tmp/picture_frame_preview.png")
|
||||
print("Preview → /tmp/picture_frame_preview.png")
|
||||
|
||||
print(f"Dithering {W}×{H} to 6-colour palette (this takes ~60s) ...")
|
||||
arr = np.array(canvas, dtype=np.float32)
|
||||
codes = floyd_steinberg(arr)
|
||||
|
||||
# Colour distribution
|
||||
names = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||
for i, (code, name) in enumerate(zip(CODES, names)):
|
||||
cnt = int((codes == code).sum())
|
||||
print(f" {name:7s} ({code:#04x}): {cnt:7d} px ({100*cnt/(W*H):.1f}%)")
|
||||
|
||||
# Pack 4bpp: high nibble = even column, low nibble = odd column
|
||||
high = codes[:, 0::2].astype(np.uint8)
|
||||
low = codes[:, 1::2].astype(np.uint8)
|
||||
packed = ((high << 4) | low)
|
||||
packed_bytes = packed.tobytes()
|
||||
assert len(packed_bytes) == H * (W // 2)
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Writing {OUT} ...")
|
||||
ROW = W // 2
|
||||
with OUT.open("w") as f:
|
||||
f.write("#pragma once\n")
|
||||
f.write("#include <pgmspace.h>\n\n")
|
||||
f.write(f"// {W}×{H} photo, 4bpp Waveshare 7.3\" 6-colour\n")
|
||||
f.write("// Packing: byte[x/2] = (code[x]<<4)|code[x+1], even col = high nibble\n")
|
||||
f.write(f"#define IMAGE_ROW {ROW} // bytes per display row\n\n")
|
||||
f.write("const uint8_t IMAGE_DATA[] PROGMEM = {\n")
|
||||
for y in range(H):
|
||||
row = packed_bytes[y * ROW:(y + 1) * ROW]
|
||||
for off in range(0, ROW, 16):
|
||||
chunk = row[off:off + 16]
|
||||
f.write(" " + ",".join(f"0x{b:02X}" for b in chunk) + ",\n")
|
||||
f.write("};\n")
|
||||
|
||||
kb = len(packed_bytes) / 1024
|
||||
print(f"Done — {len(packed_bytes):,} bytes ({kb:.1f} KB) → {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -5,3 +5,6 @@ framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_port = /dev/ttyUSB0
|
||||
monitor_port = /dev/ttyUSB0
|
||||
board_build.filesystem = littlefs
|
||||
lib_deps =
|
||||
ricmoo/QRCode@^0.0.1
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// ── EPD pins (Waveshare 7.3" ACeP) ──────────────────────────────────────────
|
||||
#define EPD_WIDTH 800
|
||||
#define EPD_HEIGHT 480
|
||||
#define PIN_SCK 18
|
||||
#define PIN_MOSI 23
|
||||
#define PIN_CS 5
|
||||
#define PIN_DC 17
|
||||
#define PIN_RST 16
|
||||
#define PIN_BUSY 4
|
||||
|
||||
// ── Reset button (BOOT button = GPIO 0 on most dev boards) ──────────────────
|
||||
#define PIN_BTN_RESET 0
|
||||
#define RESET_HOLD_MS 5000
|
||||
|
||||
// ── EPD color nibbles ────────────────────────────────────────────────────────
|
||||
#define COLOR_BLACK 0x0
|
||||
#define COLOR_WHITE 0x1
|
||||
#define COLOR_GREEN 0x2
|
||||
#define COLOR_BLUE 0x3
|
||||
#define COLOR_RED 0x4
|
||||
#define COLOR_YELLOW 0x5
|
||||
#define COLOR_ORANGE 0x6
|
||||
|
||||
// ── NVS ──────────────────────────────────────────────────────────────────────
|
||||
#define NVS_NAMESPACE "pf"
|
||||
#define NVS_KEY_SSID "ssid"
|
||||
#define NVS_KEY_PASS "pass"
|
||||
|
||||
// ── Network ──────────────────────────────────────────────────────────────────
|
||||
#define APP_BASE_URL "https://pictureframe.edholm.me"
|
||||
#define AP_IP "192.168.4.1"
|
||||
#define WIFI_TIMEOUT_MS 30000
|
||||
#define FETCH_INTERVAL_MS 900000 // 15 min deep sleep between polls
|
||||
#define IMAGE_PATH "/img.bin"
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "epd.h"
|
||||
#include "config.h"
|
||||
#include <qrcode.h>
|
||||
|
||||
static uint8_t s_row[EPD_WIDTH / 2];
|
||||
|
||||
static void wait_busy() {
|
||||
while (digitalRead(PIN_BUSY) == LOW) delay(5);
|
||||
}
|
||||
static void cmd(uint8_t c) {
|
||||
digitalWrite(PIN_DC, LOW);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
SPI.transfer(c);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
static void dat(uint8_t d) {
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
SPI.transfer(d);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
|
||||
void epd_init() {
|
||||
digitalWrite(PIN_RST, HIGH); delay(20);
|
||||
digitalWrite(PIN_RST, LOW); delay(2);
|
||||
digitalWrite(PIN_RST, HIGH); delay(20);
|
||||
wait_busy(); delay(30);
|
||||
|
||||
cmd(0xAA);
|
||||
dat(0x49); dat(0x55); dat(0x20);
|
||||
dat(0x08); dat(0x09); dat(0x18);
|
||||
cmd(0x01); dat(0x3F);
|
||||
cmd(0x00); dat(0x5F); dat(0x69);
|
||||
cmd(0x03); dat(0x00); dat(0x54); dat(0x00); dat(0x44);
|
||||
cmd(0x05); dat(0x40); dat(0x1F); dat(0x1F); dat(0x2C);
|
||||
cmd(0x06); dat(0x6F); dat(0x1F); dat(0x17); dat(0x49);
|
||||
cmd(0x08); dat(0x6F); dat(0x1F); dat(0x1F); dat(0x22);
|
||||
cmd(0x30); dat(0x03);
|
||||
cmd(0x50); dat(0x3F);
|
||||
cmd(0x60); dat(0x02); dat(0x00);
|
||||
cmd(0x61); dat(0x03); dat(0x20); dat(0x01); dat(0xE0);
|
||||
cmd(0x84); dat(0x01);
|
||||
cmd(0xE3); dat(0x2F);
|
||||
cmd(0x04); wait_busy();
|
||||
}
|
||||
|
||||
void epd_sleep() {
|
||||
cmd(0x02); dat(0x00); wait_busy();
|
||||
cmd(0x07); dat(0xA5);
|
||||
}
|
||||
|
||||
static void epd_refresh() {
|
||||
cmd(0x04); wait_busy();
|
||||
cmd(0x12); dat(0x00); wait_busy();
|
||||
}
|
||||
|
||||
void epd_fill(uint8_t color) {
|
||||
uint8_t byte = (color << 4) | color;
|
||||
cmd(0x10);
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
for (int i = 0; i < EPD_WIDTH * EPD_HEIGHT / 2; i++) {
|
||||
SPI.transfer(byte);
|
||||
}
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
epd_refresh();
|
||||
}
|
||||
|
||||
void epd_draw_image_from_file(fs::File& f) {
|
||||
cmd(0x10);
|
||||
uint8_t buf[512];
|
||||
while (f.available()) {
|
||||
size_t n = f.read(buf, sizeof(buf));
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
SPI.writeBytes(buf, n);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
epd_refresh();
|
||||
}
|
||||
|
||||
// Inline helper: returns the EPD color nibble for display pixel (px, py)
|
||||
// given a centered QR code with cellPx pixels per module.
|
||||
static inline uint8_t qr_nibble(QRCode* qr, int px, int py, int offX, int offY, int cellPx,
|
||||
uint8_t bg, uint8_t fg) {
|
||||
int qx = (px - offX) / cellPx;
|
||||
int qy = (py - offY) / cellPx;
|
||||
if (qx >= 0 && qx < qr->size && qy >= 0 && qy < qr->size) {
|
||||
return qrcode_getModule(qr, qx, qy) ? fg : bg;
|
||||
}
|
||||
return bg;
|
||||
}
|
||||
|
||||
void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg) {
|
||||
int qrPx = qr->size * cellPx;
|
||||
int offX = (EPD_WIDTH - qrPx) / 2;
|
||||
int offY = (EPD_HEIGHT - qrPx) / 2;
|
||||
|
||||
cmd(0x10);
|
||||
for (int y = 0; y < EPD_HEIGHT; y++) {
|
||||
for (int x = 0; x < EPD_WIDTH; x += 2) {
|
||||
uint8_t hi = qr_nibble(qr, x, y, offX, offY, cellPx, bg, fg);
|
||||
uint8_t lo = qr_nibble(qr, x+1, y, offX, offY, cellPx, bg, fg);
|
||||
s_row[x / 2] = (hi << 4) | lo;
|
||||
}
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
SPI.writeBytes(s_row, sizeof(s_row));
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
}
|
||||
epd_refresh();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <FS.h>
|
||||
|
||||
void epd_init();
|
||||
void epd_sleep();
|
||||
void epd_fill(uint8_t color);
|
||||
void epd_draw_image_from_file(fs::File& f);
|
||||
|
||||
// Draw a QR code centred on the display.
|
||||
// bg/fg are EPD color nibbles (COLOR_WHITE / COLOR_BLACK).
|
||||
struct QRCode;
|
||||
void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg);
|
||||
-12009
File diff suppressed because it is too large
Load Diff
+290
-89
@@ -1,119 +1,320 @@
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include "image.h"
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#include <DNSServer.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <Preferences.h>
|
||||
#include <LittleFS.h>
|
||||
#include <qrcode.h>
|
||||
#include "config.h"
|
||||
#include "epd.h"
|
||||
|
||||
#define EPD_WIDTH 800
|
||||
#define EPD_HEIGHT 480
|
||||
#define IMG_W 480 // portrait image dimensions
|
||||
#define IMG_H 800
|
||||
#define PIN_SCK 18
|
||||
#define PIN_MOSI 23
|
||||
#define PIN_CS 5
|
||||
#define PIN_DC 17
|
||||
#define PIN_RST 16
|
||||
#define PIN_BUSY 4
|
||||
// ── Globals ──────────────────────────────────────────────────────────────────
|
||||
|
||||
static uint8_t row_buf[EPD_WIDTH / 2];
|
||||
static WebServer server(80);
|
||||
static DNSServer dns;
|
||||
static Preferences prefs;
|
||||
|
||||
void wait_busy() { while (digitalRead(PIN_BUSY) == LOW) delay(5); }
|
||||
static bool g_provisioning = false;
|
||||
static bool g_connect_req = false;
|
||||
static String g_req_ssid;
|
||||
static String g_req_pass;
|
||||
|
||||
void send_command(uint8_t cmd) {
|
||||
digitalWrite(PIN_DC, LOW);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
SPI.transfer(cmd);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
// ── QR helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
static void show_ap_qr(const String& apSsid) {
|
||||
// Encode open WiFi network so phone can auto-join
|
||||
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_qr(&qr, 8, COLOR_WHITE, COLOR_BLACK);
|
||||
}
|
||||
|
||||
void send_data(uint8_t data) {
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
SPI.transfer(data);
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
static void show_setup_qr(const String& mac) {
|
||||
String url = String(APP_BASE_URL) + "/setup/" + mac;
|
||||
|
||||
QRCode qr;
|
||||
uint8_t buf[qrcode_getBufferSize(6)];
|
||||
qrcode_initText(&qr, buf, 6, ECC_LOW, url.c_str());
|
||||
// Green background indicates success
|
||||
epd_draw_qr(&qr, 7, COLOR_GREEN, COLOR_BLACK);
|
||||
}
|
||||
|
||||
void epd_reset() {
|
||||
digitalWrite(PIN_RST, HIGH); delay(20);
|
||||
digitalWrite(PIN_RST, LOW); delay(2);
|
||||
digitalWrite(PIN_RST, HIGH); delay(20);
|
||||
// ── Captive portal HTML ───────────────────────────────────────────────────────
|
||||
|
||||
static const char PORTAL_HTML[] PROGMEM = R"html(
|
||||
<!DOCTYPE html><html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>pictureFrame Setup</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;
|
||||
min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22}
|
||||
.card{width:100%;max-width:340px;margin:1rem;padding:1.5rem;background:#fff9f2;
|
||||
border:1px solid #e8d9c4;border-radius:16px}
|
||||
h1{font-size:1.25rem;margin:0 0 1.25rem}
|
||||
label{display:block;font-size:.8125rem;font-weight:600;color:#8a7060;margin-bottom:.3rem}
|
||||
input{width:100%;min-height:44px;padding:0 .875rem;border:1px solid #e8d9c4;
|
||||
border-radius:10px;background:#fff;font-size:1rem;color:#3a2e22;box-sizing:border-box;margin-bottom:1rem}
|
||||
button{width:100%;min-height:44px;background:#c97c3a;color:#fff;border:none;
|
||||
border-radius:9999px;font-size:1rem;font-weight:700;cursor:pointer}
|
||||
p{font-size:.875rem;color:#8a7060;margin-top:1rem;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Connect to WiFi</h1>
|
||||
<form method="POST" action="/connect">
|
||||
<label for="s">WiFi network name</label>
|
||||
<input id="s" name="ssid" type="text" autocomplete="off" placeholder="e.g. HomeNetwork">
|
||||
<label for="p">Password</label>
|
||||
<input id="p" name="pass" type="password" autocomplete="off">
|
||||
<button type="submit">Connect</button>
|
||||
</form>
|
||||
<p>pictureFrame will join your network and display a setup QR code.</p>
|
||||
</div>
|
||||
</body></html>
|
||||
)html";
|
||||
|
||||
static const char CONNECTING_HTML[] PROGMEM = R"html(
|
||||
<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<title>Connecting…</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;
|
||||
min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22;text-align:center}
|
||||
.card{padding:2rem;max-width:300px}</style></head>
|
||||
<body><div class="card"><h2>Connecting…</h2>
|
||||
<p>The frame is joining your network.<br>Watch the display for a setup QR code.</p></div></body></html>
|
||||
)html";
|
||||
|
||||
// ── Web server handlers ───────────────────────────────────────────────────────
|
||||
|
||||
static void handle_root() {
|
||||
server.send_P(200, "text/html", PORTAL_HTML);
|
||||
}
|
||||
|
||||
void epd_init() {
|
||||
epd_reset();
|
||||
wait_busy();
|
||||
delay(30);
|
||||
send_command(0xAA);
|
||||
send_data(0x49); send_data(0x55); send_data(0x20);
|
||||
send_data(0x08); send_data(0x09); send_data(0x18);
|
||||
send_command(0x01); send_data(0x3F);
|
||||
send_command(0x00); send_data(0x5F); send_data(0x69);
|
||||
send_command(0x03);
|
||||
send_data(0x00); send_data(0x54); send_data(0x00); send_data(0x44);
|
||||
send_command(0x05);
|
||||
send_data(0x40); send_data(0x1F); send_data(0x1F); send_data(0x2C);
|
||||
send_command(0x06);
|
||||
send_data(0x6F); send_data(0x1F); send_data(0x17); send_data(0x49);
|
||||
send_command(0x08);
|
||||
send_data(0x6F); send_data(0x1F); send_data(0x1F); send_data(0x22);
|
||||
send_command(0x30); send_data(0x03);
|
||||
send_command(0x50); send_data(0x3F);
|
||||
send_command(0x60); send_data(0x02); send_data(0x00);
|
||||
send_command(0x61);
|
||||
send_data(0x03); send_data(0x20); send_data(0x01); send_data(0xE0);
|
||||
send_command(0x84); send_data(0x01);
|
||||
send_command(0xE3); send_data(0x2F);
|
||||
send_command(0x04);
|
||||
wait_busy();
|
||||
}
|
||||
|
||||
void epd_sleep() {
|
||||
send_command(0x02); send_data(0x00); wait_busy();
|
||||
send_command(0x07); send_data(0xA5);
|
||||
}
|
||||
|
||||
void show_image() {
|
||||
send_command(0x10);
|
||||
digitalWrite(PIN_DC, HIGH);
|
||||
digitalWrite(PIN_CS, LOW);
|
||||
|
||||
// 90° CW rotation: display(x,y) → portrait(IMG_W-1-y, x)
|
||||
// Portrait image is IMG_W×IMG_H, IMAGE_ROW = IMG_W/2 bytes per portrait row.
|
||||
for (int y = 0; y < EPD_HEIGHT; y++) {
|
||||
int pcol = (IMG_W - 1) - y;
|
||||
for (int x = 0; x < EPD_WIDTH; x++) {
|
||||
uint32_t bidx = (uint32_t)x * IMAGE_ROW + pcol / 2;
|
||||
uint8_t b = pgm_read_byte(&IMAGE_DATA[bidx]);
|
||||
uint8_t code = (pcol & 1) ? (b & 0x0F) : (b >> 4);
|
||||
if (x & 1)
|
||||
row_buf[x / 2] = (row_buf[x / 2] & 0xF0) | code;
|
||||
else
|
||||
row_buf[x / 2] = (code << 4) | (row_buf[x / 2] & 0x0F);
|
||||
static void handle_connect() {
|
||||
if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) {
|
||||
server.send(400, "text/plain", "Missing ssid");
|
||||
return;
|
||||
}
|
||||
SPI.writeBytes(row_buf, sizeof(row_buf));
|
||||
}
|
||||
digitalWrite(PIN_CS, HIGH);
|
||||
|
||||
send_command(0x04); wait_busy();
|
||||
send_command(0x12); send_data(0x00); wait_busy();
|
||||
g_req_ssid = server.arg("ssid");
|
||||
g_req_pass = server.arg("pass");
|
||||
g_connect_req = true;
|
||||
server.send_P(200, "text/html", CONNECTING_HTML);
|
||||
}
|
||||
|
||||
// Captive portal detection endpoints — all redirect to portal
|
||||
static void handle_captive() {
|
||||
server.sendHeader("Location", "http://" AP_IP "/");
|
||||
server.send(302, "text/plain", "");
|
||||
}
|
||||
|
||||
// ── WiFi provisioning ─────────────────────────────────────────────────────────
|
||||
|
||||
static void enter_provisioning(const String& mac) {
|
||||
// Derive AP name from last 4 hex chars of MAC (no colons)
|
||||
String suffix = mac;
|
||||
suffix.replace(":", "");
|
||||
suffix = suffix.substring(suffix.length() - 4);
|
||||
suffix.toUpperCase();
|
||||
String apSsid = "PictureFrame-" + suffix;
|
||||
|
||||
Serial.println("AP: " + apSsid);
|
||||
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_AP);
|
||||
WiFi.softAP(apSsid.c_str());
|
||||
delay(500);
|
||||
|
||||
// Redirect all DNS to this device
|
||||
dns.setErrorReplyCode(DNSReplyCode::NoError);
|
||||
dns.start(53, "*", WiFi.softAPIP());
|
||||
|
||||
server.on("/", HTTP_GET, handle_root);
|
||||
server.on("/connect", HTTP_POST, handle_connect);
|
||||
server.on("/generate_204", handle_captive);
|
||||
server.on("/hotspot-detect.html", handle_captive);
|
||||
server.on("/ncsi.txt", handle_captive);
|
||||
server.onNotFound(handle_root);
|
||||
server.begin();
|
||||
|
||||
epd_init();
|
||||
show_ap_qr(apSsid);
|
||||
epd_sleep();
|
||||
|
||||
g_provisioning = true;
|
||||
}
|
||||
|
||||
static bool attempt_wifi(const String& ssid, const String& pass) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
|
||||
uint32_t start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
if (millis() - start > WIFI_TIMEOUT_MS) return false;
|
||||
delay(200);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Normal operation ──────────────────────────────────────────────────────────
|
||||
|
||||
static void normal_operation(const String& mac) {
|
||||
String url = String(APP_BASE_URL) + "/api/device/" + mac + "/image";
|
||||
|
||||
WiFiClientSecure client;
|
||||
client.setInsecure(); // V1: no cert pinning for personal-scale device
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(client, url);
|
||||
int code = http.GET();
|
||||
|
||||
epd_init();
|
||||
|
||||
if (code == 200) {
|
||||
// Stream new image to LittleFS and display it
|
||||
File f = LittleFS.open(IMAGE_PATH, "w", true);
|
||||
if (f) {
|
||||
http.writeToStream(&f);
|
||||
f.close();
|
||||
}
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) {
|
||||
epd_draw_image_from_file(r);
|
||||
r.close();
|
||||
}
|
||||
} else if (code == 204) {
|
||||
// No new image — display whatever is stored
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) {
|
||||
epd_draw_image_from_file(r);
|
||||
r.close();
|
||||
}
|
||||
// No stored image yet: keep current display (e-ink is persistent)
|
||||
} else if (code == 404) {
|
||||
// Device not registered — show red border indication
|
||||
epd_fill(COLOR_RED);
|
||||
} else {
|
||||
// Server error / timeout: yellow border indication (server reachable, sync failed)
|
||||
epd_fill(COLOR_YELLOW);
|
||||
}
|
||||
|
||||
http.end();
|
||||
epd_sleep();
|
||||
|
||||
// Deep sleep until next poll
|
||||
esp_sleep_enable_timer_wakeup((uint64_t)FETCH_INTERVAL_MS * 1000ULL);
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
Serial.println("pictureFrame");
|
||||
Serial.println("pictureFrame boot");
|
||||
|
||||
// Init GPIO
|
||||
pinMode(PIN_CS, OUTPUT);
|
||||
pinMode(PIN_DC, OUTPUT);
|
||||
pinMode(PIN_RST, OUTPUT);
|
||||
pinMode(PIN_BUSY, INPUT);
|
||||
pinMode(PIN_BTN_RESET, INPUT_PULLUP);
|
||||
|
||||
SPI.begin(PIN_SCK, -1, PIN_MOSI, PIN_CS);
|
||||
SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
|
||||
epd_init();
|
||||
show_image();
|
||||
epd_sleep();
|
||||
Serial.println("Done.");
|
||||
LittleFS.begin(true); // format on first use
|
||||
|
||||
// Check reset button: if held at boot, wipe credentials
|
||||
uint32_t hold_start = millis();
|
||||
bool clear_creds = false;
|
||||
while (digitalRead(PIN_BTN_RESET) == LOW) {
|
||||
if (millis() - hold_start >= RESET_HOLD_MS) {
|
||||
clear_creds = true;
|
||||
break;
|
||||
}
|
||||
delay(50);
|
||||
}
|
||||
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
if (clear_creds) {
|
||||
prefs.clear();
|
||||
Serial.println("Credentials cleared — entering provisioning");
|
||||
}
|
||||
|
||||
String mac = WiFi.macAddress();
|
||||
String ssid = prefs.getString(NVS_KEY_SSID, "");
|
||||
String pass = prefs.getString(NVS_KEY_PASS, "");
|
||||
prefs.end();
|
||||
|
||||
if (ssid.isEmpty()) {
|
||||
enter_provisioning(mac);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to join saved network
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
uint32_t start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
if (millis() - start > WIFI_TIMEOUT_MS) break;
|
||||
delay(200);
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
normal_operation(mac);
|
||||
// normal_operation calls deep_sleep — never returns
|
||||
} else {
|
||||
// Can't reach saved network — enter provisioning to get new credentials
|
||||
enter_provisioning(mac);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loop (provisioning mode only) ────────────────────────────────────────────
|
||||
|
||||
void loop() {
|
||||
delay(60000);
|
||||
if (!g_provisioning) return;
|
||||
|
||||
dns.processNextRequest();
|
||||
server.handleClient();
|
||||
|
||||
if (!g_connect_req) return;
|
||||
g_connect_req = false;
|
||||
|
||||
dns.stop();
|
||||
server.stop();
|
||||
|
||||
String mac = WiFi.macAddress();
|
||||
bool ok = attempt_wifi(g_req_ssid, g_req_pass);
|
||||
|
||||
if (ok) {
|
||||
// Save credentials for future boots
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putString(NVS_KEY_SSID, g_req_ssid);
|
||||
prefs.putString(NVS_KEY_PASS, g_req_pass);
|
||||
prefs.end();
|
||||
|
||||
// Show Phase 2 QR and transition to polling loop
|
||||
epd_init();
|
||||
show_setup_qr(mac);
|
||||
epd_sleep();
|
||||
|
||||
g_provisioning = false;
|
||||
|
||||
// Give user time to scan the QR, then start normal operation
|
||||
delay(120000); // 2 minutes
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user