Compare commits

...

4 Commits

Author SHA1 Message Date
football2801 2e5ef7fe78 feat(story-2.4): home screen device list with FrameCard component
CI / test (push) Has been cancelled
- FrameCard: large (single device, 5:3 preview + Add Photo CTA) and
  compact (52px thumb + name + count + icon pill) variants; WCAG-
  compliant offline/sync-fail status (color + text, never color alone)
- devices Pinia store: fetchDevices() → GET /api/devices
- HomeView: 0 devices → dashed empty-state card; 1 device → large
  FrameCard; 2+ → compact stack; add-photo wired (Epic 3 stub)
- Fix Device type: rotationInterval → rotationIntervalHours to match API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:50:46 -04:00
football2801 f2af2de36f feat(story-2.2+2.3): device setup page, account linking, naming & configuration
Story 2.2 — /setup/{mac} Twig page (no Vue, works JS-disabled):
- Register tab: creates account + logs in + links device → /setup/{mac}/configure
- Login tab: manual credential check via UserPasswordHasherInterface + Security::login()
  + links device → /setup/{mac}/configure
- Re-provisioning: DeviceService.linkToUser() atomically transfers ownership + stubs
  purgeDeviceHistory() (completed in Epic 3 when Image/Approval entities exist)

Story 2.3 — /setup/{mac}/configure (requires auth):
- GET: device name, orientation (landscape/portrait), rotation interval (6/12/24/48/168h),
  uniqueness window (5/10/20/50 cycles)
- POST: validates name, saves to Device entity, redirects to Vue SPA
- Device entity: mac, name, orientation (Orientation enum), rotationIntervalHours,
  uniquenessWindow, user (ManyToOne), linkedAt
- PATCH /api/devices/{id}: Vue SPA can edit any device field (Story 2.3 "edit from app")
- GET /api/devices: list authenticated user's devices
- Migration: create device table with Orientation enum column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:47:14 -04:00
football2801 d5a7849fbd 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>
2026-04-28 00:43:53 -04:00
football2801 1ec5364de4 feat(story-1.5): theme selection and persistence
- SpaController: injects data-theme on <html> and window.__PF_USER__ before JS
  hydrates — theme applied without FOUC; no initial API call needed for user data
- UserApiController: PATCH /api/user/theme validates against 6 allowed theme IDs,
  persists to user.theme column, returns {theme}
- useTheme composable: applyTheme() sets html[data-theme], saveTheme() calls API
  and falls back with toast on error
- SettingsView: 3-col theme grid with swatch previews, aria-checked radio semantics,
  active indicator; Sign out link; signed-in email display
- App.vue: onMounted syncs Pinia theme state with SpaController-stamped html[data-theme]

Verified: data-theme injected on / load; PATCH saves to DB; reload shows persisted theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:37:59 -04:00
26 changed files with 1706 additions and 12232 deletions
-119
View File
@@ -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()
+3
View File
@@ -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
+36
View File
@@ -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"
+112
View File
@@ -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();
}
+14
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+294 -93
View File
@@ -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);
}
SPI.writeBytes(row_buf, sizeof(row_buf));
static void handle_connect() {
if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) {
server.send(400, "text/plain", "Missing ssid");
return;
}
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");
pinMode(PIN_CS, OUTPUT);
pinMode(PIN_DC, OUTPUT);
pinMode(PIN_RST, OUTPUT);
pinMode(PIN_BUSY, INPUT);
// 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);
}
}
+16
View File
@@ -5,6 +5,22 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import BottomNav from '@/components/BottomNav.vue'
import BaseToast from '@/components/BaseToast.vue'
import { useAuthStore } from '@/stores/auth'
import { useTheme } from '@/composables/useTheme'
const auth = useAuthStore()
const { applyTheme } = useTheme()
onMounted(() => {
// Sync Vue's theme state with whatever SpaController stamped on <html>
const stamped = document.documentElement.dataset.theme
if (stamped && auth.user) {
auth.user.theme = stamped
} else if (auth.user?.theme) {
applyTheme(auth.user.theme)
}
})
</script>
+184
View File
@@ -0,0 +1,184 @@
<template>
<div
:class="[
'frame-card',
`frame-card--${size}`,
status !== 'ok' && `frame-card--${status}`,
]"
>
<!-- Status badge (color + text never color alone) -->
<div v-if="status !== 'ok'" class="frame-card__status-badge" aria-live="polite">
<span class="frame-card__status-dot" aria-hidden="true" />
{{ status === 'offline' ? 'Offline' : 'Sync issue' }}
</div>
<!-- Preview area -->
<div class="frame-card__preview">
<img
v-if="thumbnailUrl"
:src="thumbnailUrl"
:alt="`Current photo on ${name}`"
class="frame-card__img"
/>
<div v-else class="frame-card__empty-preview" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
</div>
</div>
<div class="frame-card__body">
<p class="frame-card__name">{{ name }}</p>
<p v-if="size === 'compact'" class="frame-card__count">
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
</p>
<BaseButton
:variant="size === 'large' ? 'primary' : 'icon-pill'"
:aria-label="size === 'large' ? `Add photo to ${name}` : `Add photo to ${name}`"
class="frame-card__add-btn"
@click="$emit('add-photo', deviceId)"
>
<span v-if="size === 'large'">+ Add Photo</span>
<span v-else aria-hidden="true">+</span>
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import BaseButton from '@/components/BaseButton.vue'
defineProps<{
deviceId: number
name: string
size: 'large' | 'compact'
status: 'ok' | 'offline' | 'sync-fail'
thumbnailUrl?: string
photoCount?: number
}>()
defineEmits<{ 'add-photo': [deviceId: number] }>()
</script>
<style scoped lang="scss">
.frame-card {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--duration-fast);
&--offline { border-color: #c0392b; }
&--sync-fail { border-color: #c49a20; }
&__status-badge {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 700;
background: var(--color-surface-2);
.frame-card--offline & { color: #c0392b; }
.frame-card--sync-fail & { color: #8a6a00; }
}
&__status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
.frame-card--offline & { background: #c0392b; }
.frame-card--sync-fail & { background: #c49a20; }
}
// ── Large (single device) ────────────────────────────────────────────────
&--large &__preview {
aspect-ratio: 5/3;
background: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
}
&--large &__img {
width: 100%;
height: 100%;
object-fit: cover;
}
&--large &__empty-preview {
color: var(--color-text-muted);
opacity: 0.5;
}
&--large &__body {
padding: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
&--large &__name {
font-size: var(--text-md);
font-weight: 700;
}
// ── Compact (multi device) ───────────────────────────────────────────────
&--compact {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
}
&--compact &__preview {
width: 52px;
height: 52px;
border-radius: var(--radius-sm);
background: var(--color-surface-2);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
&--compact &__img {
width: 100%;
height: 100%;
object-fit: cover;
}
&--compact &__empty-preview {
color: var(--color-text-muted);
opacity: 0.4;
}
&--compact &__body {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
&--compact &__name {
font-size: var(--text-base);
font-weight: 700;
}
&--compact &__count {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
&__add-btn { flex-shrink: 0; }
}
</style>
+45
View File
@@ -0,0 +1,45 @@
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
export interface ThemeOption {
id: string
label: string
primary: string
bg: string
text: string
}
export const THEMES: ThemeOption[] = [
{ id: 'warm-craft', label: 'Warm Craft', primary: '#c97c3a', bg: '#fdf6ee', text: '#3a2e22' },
{ id: 'playful-pop', label: 'Playful Pop', primary: '#d63aab', bg: '#fff0fb', text: '#2d0a28' },
{ id: 'sage-cream', label: 'Sage & Cream', primary: '#4e7c3a', bg: '#f6f8f3', text: '#1e2b1a' },
{ id: 'dusty-mauve', label: 'Dusty Mauve', primary: '#8e4a84', bg: '#f6f0f4', text: '#2a1828' },
{ id: 'ocean-dusk', label: 'Ocean Dusk', primary: '#1a6ea8', bg: '#eef3f8', text: '#0e2030' },
{ id: 'honey-slate', label: 'Honey & Slate', primary: '#c49a20', bg: '#f2f2ee', text: '#1c1c18' },
]
export function useTheme() {
const auth = useAuthStore()
const toast = useToastStore()
function applyTheme(themeId: string) {
document.documentElement.dataset.theme = themeId
if (auth.user) auth.user.theme = themeId
}
async function saveTheme(themeId: string) {
applyTheme(themeId)
try {
const res = await fetch('/api/user/theme', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: themeId }),
})
if (!res.ok) throw new Error('Failed to save theme')
} catch {
toast.show('Could not save theme — try again', 'error')
}
}
return { THEMES, applyTheme, saveTheme }
}
+25
View File
@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Device } from '@/types'
export const useDevicesStore = defineStore('devices', () => {
const devices = ref<Device[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchDevices() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/devices')
if (!res.ok) throw new Error('Failed to load devices')
devices.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
return { devices, loading, error, fetchDevices }
})
+1 -1
View File
@@ -10,7 +10,7 @@ export interface Device {
mac: string
name: string
orientation: 'landscape' | 'portrait'
rotationInterval: number
rotationIntervalHours: number
uniquenessWindow: number
linkedAt: string
}
+120 -3
View File
@@ -1,9 +1,126 @@
<template>
<main class="view">
<h1>Home</h1>
<main class="home-view">
<!-- Loading -->
<div v-if="devicesStore.loading" class="home-view__loading" aria-live="polite">
Loading
</div>
<!-- Empty state -->
<div v-else-if="devicesStore.devices.length === 0" class="home-view__empty">
<div class="home-view__empty-card">
<svg class="home-view__empty-icon" width="48" height="48" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
<p class="home-view__empty-title">Set up your first frame</p>
<p class="home-view__empty-sub">
Power on your pictureFrame device and scan the QR code it displays to get started.
</p>
</div>
</div>
<!-- Single device large card -->
<div v-else-if="devicesStore.devices.length === 1" class="home-view__single">
<FrameCard
:deviceId="devicesStore.devices[0].id"
:name="devicesStore.devices[0].name"
size="large"
status="ok"
@add-photo="onAddPhoto"
/>
</div>
<!-- Multiple devices compact stack -->
<div v-else class="home-view__list">
<FrameCard
v-for="device in devicesStore.devices"
:key="device.id"
:deviceId="device.id"
:name="device.name"
size="compact"
status="ok"
@add-photo="onAddPhoto"
/>
</div>
</main>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useDevicesStore } from '@/stores/devices'
import FrameCard from '@/components/FrameCard.vue'
const devicesStore = useDevicesStore()
onMounted(() => devicesStore.fetchDevices())
function onAddPhoto(deviceId: number) {
// Photo upload flow — Epic 3
console.log('add-photo', deviceId)
}
</script>
<style scoped lang="scss">
.view { padding: var(--space-4); }
.home-view {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
&__loading {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-4) 0;
text-align: center;
}
&__empty {
display: flex;
justify-content: center;
padding-top: var(--space-6);
}
&__empty-card {
background: var(--color-surface);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6) var(--space-5);
text-align: center;
max-width: 320px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
&__empty-icon {
color: var(--color-text-muted);
opacity: 0.5;
}
&__empty-title {
font-size: var(--text-md);
font-weight: 700;
}
&__empty-sub {
font-size: var(--text-sm);
color: var(--color-text-muted);
line-height: 1.5;
}
&__single {
display: flex;
flex-direction: column;
}
&__list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
}
</style>
+169 -3
View File
@@ -1,9 +1,175 @@
<template>
<main class="view">
<h1>Settings</h1>
<main class="settings">
<h1 class="settings__title">Settings</h1>
<section class="settings__section">
<h2 class="settings__section-title">Theme</h2>
<div class="theme-grid" role="radiogroup" aria-label="Choose theme">
<button
v-for="t in THEMES"
:key="t.id"
type="button"
role="radio"
:aria-checked="currentTheme === t.id"
:aria-label="t.label"
:class="['theme-swatch', { 'theme-swatch--active': currentTheme === t.id }]"
:style="{ '--swatch-bg': t.bg, '--swatch-primary': t.primary, '--swatch-text': t.text }"
@click="select(t.id)"
>
<span class="theme-swatch__preview" aria-hidden="true">
<span class="theme-swatch__bar" />
<span class="theme-swatch__dot" />
</span>
<span class="theme-swatch__label">{{ t.label }}</span>
<span v-if="currentTheme === t.id" class="theme-swatch__check" aria-hidden="true"></span>
</button>
</div>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Account</h2>
<div class="settings__row">
<span class="settings__row-label">Signed in as</span>
<span class="settings__row-value">{{ auth.user?.email }}</span>
</div>
<a href="/logout" class="settings__logout">Sign out</a>
</section>
</main>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useTheme, THEMES } from '@/composables/useTheme'
const auth = useAuthStore()
const { saveTheme } = useTheme()
const currentTheme = computed(() => auth.user?.theme ?? 'warm-craft')
function select(themeId: string) {
saveTheme(themeId)
}
</script>
<style scoped lang="scss">
.view { padding: var(--space-4); }
.settings {
padding: var(--space-4) var(--space-4) calc(64px + var(--space-6));
max-width: 480px;
margin: 0 auto;
&__title {
font-size: var(--text-xl);
font-weight: 700;
margin-bottom: var(--space-6);
}
&__section {
margin-bottom: var(--space-6);
}
&__section-title {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--space-3);
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border);
font-size: var(--text-base);
}
&__row-label { color: var(--color-text-muted); }
&__row-value { font-weight: 600; }
&__logout {
display: flex;
align-items: center;
min-height: var(--touch-min);
padding: var(--space-3) 0;
color: var(--color-destructive);
font-weight: 600;
text-decoration: none;
font-size: var(--text-base);
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
}
.theme-swatch {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
background: var(--swatch-bg);
border: 2px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--duration-fast);
min-height: var(--touch-min);
&--active {
border-color: var(--swatch-primary);
}
&__preview {
width: 100%;
height: 36px;
border-radius: var(--radius-sm);
background: var(--swatch-bg);
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 0 6px;
border: 1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);
}
&__bar {
display: block;
height: 6px;
border-radius: 3px;
background: var(--swatch-primary);
width: 60%;
}
&__dot {
display: block;
height: 4px;
border-radius: 2px;
background: var(--swatch-text);
width: 80%;
opacity: 0.4;
}
&__label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
text-align: center;
line-height: 1.2;
}
&__check {
position: absolute;
top: var(--space-1);
right: var(--space-2);
font-size: var(--text-sm);
color: var(--swatch-primary);
font-weight: 700;
}
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260428044656 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE device (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, mac VARCHAR(17) NOT NULL, name VARCHAR(100) NOT NULL, orientation VARCHAR(255) NOT NULL, rotation_interval_hours INT NOT NULL, uniqueness_window INT NOT NULL, linked_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_92FB68E1713EB65 ON device (mac)');
$this->addSql('CREATE INDEX IDX_92FB68EA76ED395 ON device (user_id)');
$this->addSql('ALTER TABLE device ADD CONSTRAINT FK_92FB68EA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE device DROP CONSTRAINT FK_92FB68EA76ED395');
$this->addSql('DROP TABLE device');
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Device;
use App\Entity\User;
use App\Enum\Orientation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/devices')]
#[IsGranted('ROLE_USER')]
class DeviceApiController extends AbstractController
{
#[Route('', name: 'api_devices_list', methods: ['GET'])]
public function list(EntityManagerInterface $em): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$devices = $em->getRepository(Device::class)->findBy(['user' => $user]);
return $this->json(array_map($this->serialize(...), $devices));
}
#[Route('/{id}', name: 'api_device_update', methods: ['PATCH'])]
public function update(int $id, Request $request, EntityManagerInterface $em): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);
if (!$device) {
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
}
$body = json_decode($request->getContent(), true) ?? [];
if (isset($body['name'])) {
$name = trim((string) $body['name']);
if (empty($name)) {
return $this->json(['error' => 'Name cannot be empty'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$device->setName($name);
}
if (isset($body['orientation'])) {
$orientation = Orientation::tryFrom($body['orientation']);
if (!$orientation) {
return $this->json(['error' => 'Invalid orientation'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$device->setOrientation($orientation);
}
if (isset($body['rotationIntervalHours'])) {
$device->setRotationIntervalHours(max(1, (int) $body['rotationIntervalHours']));
}
if (isset($body['uniquenessWindow'])) {
$device->setUniquenessWindow(max(1, (int) $body['uniquenessWindow']));
}
$em->flush();
return $this->json($this->serialize($device));
}
private function serialize(Device $d): array
{
return [
'id' => $d->getId(),
'mac' => $d->getMac(),
'name' => $d->getName(),
'orientation' => $d->getOrientation()->value,
'rotationIntervalHours' => $d->getRotationIntervalHours(),
'uniquenessWindow' => $d->getUniquenessWindow(),
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
];
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Device;
use App\Entity\User;
use App\Enum\Orientation;
use App\Form\RegistrationFormType;
use App\Service\DeviceService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/setup/{mac}', requirements: ['mac' => '[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}'])]
class SetupController extends AbstractController
{
#[Route('', name: 'setup_index', methods: ['GET', 'POST'])]
public function index(
string $mac,
Request $request,
UserPasswordHasherInterface $hasher,
EntityManagerInterface $em,
Security $security,
DeviceService $deviceService,
): Response {
// If already authenticated, link device and proceed to configure
if ($this->getUser()) {
/** @var User $user */
$user = $this->getUser();
$device = $deviceService->linkToUser($mac, $user);
if (empty($device->getName())) {
return $this->redirectToRoute('setup_configure', ['mac' => $mac]);
}
return $this->redirectToRoute('spa');
}
$regForm = $this->createForm(RegistrationFormType::class, new User(), [
'action' => $this->generateUrl('setup_register', ['mac' => $mac]),
]);
$loginError = $request->getSession()->get('_setup_login_error');
$request->getSession()->remove('_setup_login_error');
return $this->render('setup/index.html.twig', [
'mac' => $mac,
'reg_form' => $regForm,
'login_error' => $loginError,
]);
}
#[Route('/register', name: 'setup_register', methods: ['POST'])]
public function register(
string $mac,
Request $request,
UserPasswordHasherInterface $hasher,
EntityManagerInterface $em,
Security $security,
DeviceService $deviceService,
): Response {
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $plain */
$plain = $form->get('plainPassword')->getData();
$user->setPassword($hasher->hashPassword($user, $plain));
$em->persist($user);
$em->flush();
$security->login($user, 'form_login', 'main');
$deviceService->linkToUser($mac, $user);
return $this->redirectToRoute('setup_configure', ['mac' => $mac]);
}
return $this->render('setup/index.html.twig', [
'mac' => $mac,
'reg_form' => $form,
'login_error' => null,
]);
}
#[Route('/login', name: 'setup_login', methods: ['POST'])]
public function login(
string $mac,
Request $request,
UserPasswordHasherInterface $hasher,
EntityManagerInterface $em,
Security $security,
DeviceService $deviceService,
): Response {
$email = trim((string) $request->request->get('_username', ''));
$password = (string) $request->request->get('_password', '');
$user = $em->getRepository(User::class)->findOneBy(['email' => $email]);
if ($user && $hasher->isPasswordValid($user, $password)) {
$security->login($user, 'form_login', 'main');
$deviceService->linkToUser($mac, $user);
return $this->redirectToRoute('setup_configure', ['mac' => $mac]);
}
$request->getSession()->set('_setup_login_error', 'Incorrect email or password');
return $this->redirectToRoute('setup_index', ['mac' => $mac]);
}
#[Route('/configure', name: 'setup_configure', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function configure(
string $mac,
Request $request,
EntityManagerInterface $em,
DeviceService $deviceService,
): Response {
/** @var User $user */
$user = $this->getUser();
$device = $deviceService->linkToUser($mac, $user);
if ($request->isMethod('POST')) {
$name = trim((string) $request->request->get('name', ''));
$orient = $request->request->get('orientation', Orientation::Landscape->value);
$interval = (int) $request->request->get('rotation_interval_hours', 24);
$window = (int) $request->request->get('uniqueness_window', 10);
if (empty($name)) {
return $this->render('setup/configure.html.twig', [
'device' => $device,
'error' => 'Please enter a name for your frame.',
]);
}
$device->setName($name);
$device->setOrientation(Orientation::from($orient));
$device->setRotationIntervalHours(max(1, $interval));
$device->setUniquenessWindow(max(1, $window));
$em->flush();
return $this->redirectToRoute('spa');
}
return $this->render('setup/configure.html.twig', [
'device' => $device,
'error' => null,
]);
}
}
+21 -4
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
@@ -32,9 +33,25 @@ class SpaController extends AbstractController
throw $this->createNotFoundException('SPA not built — run npm run build in frontend/.');
}
return new Response(
content: (string) file_get_contents($indexFile),
headers: ['Content-Type' => 'text/html; charset=utf-8'],
);
/** @var User $user */
$user = $this->getUser();
$theme = $user->getTheme() ?? 'warm-craft';
$userData = json_encode([
'id' => $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'theme' => $theme,
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
$html = (string) file_get_contents($indexFile);
// Inject theme on <html> so CSS applies before JS hydrates (no FOUC)
$html = str_replace('<html lang="en">', '<html lang="en" data-theme="' . htmlspecialchars($theme, ENT_QUOTES) . '">', $html);
// Bootstrap current user into window so Pinia auth store needs no initial API call
$html = str_replace('</head>', '<script>window.__PF_USER__=' . $userData . ';</script></head>', $html);
return new Response($html, headers: ['Content-Type' => 'text/html; charset=utf-8']);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/user')]
#[IsGranted('ROLE_USER')]
class UserApiController extends AbstractController
{
private const VALID_THEMES = [
'warm-craft',
'playful-pop',
'sage-cream',
'dusty-mauve',
'ocean-dusk',
'honey-slate',
];
#[Route('/theme', name: 'api_user_theme', methods: ['PATCH'])]
public function updateTheme(Request $request, EntityManagerInterface $em): JsonResponse
{
$body = json_decode($request->getContent(), true);
$theme = $body['theme'] ?? null;
if (!is_string($theme) || !in_array($theme, self::VALID_THEMES, true)) {
return $this->json(['error' => 'Invalid theme'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @var User $user */
$user = $this->getUser();
$user->setTheme($theme);
$em->flush();
return $this->json(['theme' => $theme]);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\Orientation;
use App\Repository\DeviceRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DeviceRepository::class)]
class Device
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 17, unique: true)]
private string $mac = '';
#[ORM\Column(length: 100)]
private string $name = '';
#[ORM\Column(enumType: Orientation::class)]
private Orientation $orientation = Orientation::Landscape;
/** Hours between rotation cycles. */
#[ORM\Column]
private int $rotationIntervalHours = 24;
/** Number of display cycles before an image may repeat. */
#[ORM\Column]
private int $uniquenessWindow = 10;
#[ORM\ManyToOne(inversedBy: 'devices')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column]
private \DateTimeImmutable $linkedAt;
public function __construct()
{
$this->linkedAt = new \DateTimeImmutable();
}
public function getId(): ?int { return $this->id; }
public function getMac(): string { return $this->mac; }
public function setMac(string $mac): static { $this->mac = strtoupper($mac); return $this; }
public function getName(): string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }
public function getOrientation(): Orientation { return $this->orientation; }
public function setOrientation(Orientation $orientation): static { $this->orientation = $orientation; return $this; }
public function getRotationIntervalHours(): int { return $this->rotationIntervalHours; }
public function setRotationIntervalHours(int $h): static { $this->rotationIntervalHours = $h; return $this; }
public function getUniquenessWindow(): int { return $this->uniquenessWindow; }
public function setUniquenessWindow(int $w): static { $this->uniquenessWindow = $w; return $this; }
public function getUser(): ?User { return $this->user; }
public function setUser(?User $user): static { $this->user = $user; return $this; }
public function getLinkedAt(): \DateTimeImmutable { return $this->linkedAt; }
public function setLinkedAt(\DateTimeImmutable $dt): static { $this->linkedAt = $dt; return $this; }
}
+14
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -33,6 +35,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 50, nullable: true)]
private ?string $theme = null;
/** @var Collection<int, Device> */
#[ORM\OneToMany(targetEntity: Device::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $devices;
public function getId(): ?int
{
return $this->id;
@@ -92,4 +98,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
}
public function eraseCredentials(): void {}
/** @return Collection<int, Device> */
public function getDevices(): Collection { return $this->devices; }
public function __construct()
{
$this->devices = new ArrayCollection();
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum Orientation: string
{
case Landscape = 'landscape';
case Portrait = 'portrait';
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Device;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Device> */
class DeviceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Device::class);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Device;
use App\Entity\User;
use App\Repository\DeviceRepository;
use Doctrine\ORM\EntityManagerInterface;
class DeviceService
{
public function __construct(
private readonly DeviceRepository $repo,
private readonly EntityManagerInterface $em,
) {}
/**
* Atomically link a MAC address to a user.
* If the device was previously owned by a different user, image history is purged.
*/
public function linkToUser(string $mac, User $newOwner): Device
{
$mac = strtoupper($mac);
$device = $this->repo->findOneBy(['mac' => $mac]);
if ($device === null) {
$device = new Device();
$device->setMac($mac);
} elseif ($device->getUser() !== null && $device->getUser()->getId() !== $newOwner->getId()) {
// Ownership transfer: purge prior image history for this device.
// Full purge logic added in Epic 3 when Image/Approval entities exist.
$this->purgeDeviceHistory($device);
}
$device->setUser($newOwner);
$device->setLinkedAt(new \DateTimeImmutable());
$this->em->persist($device);
$this->em->flush();
return $device;
}
/** Remove all image approvals and rendered assets associated with this device. */
private function purgeDeviceHistory(Device $device): void
{
// Stub: will cascade-delete via ORM relationships once Image/Approval entities are added in Epic 3.
// Doctrine cascade on the Device→Approval relationship handles this automatically.
}
}
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Name your frame — pictureFrame</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; min-height: 100dvh; background: #fdf6ee; color: #3a2e22;
display: flex; align-items: flex-start; justify-content: center; padding: 2rem 1rem; }
.card { width: 100%; max-width: 400px; }
h1 { font-size: 1.4rem; font-weight: 700; margin-bottom: .25rem; }
.subtitle { font-size: .875rem; color: #8a7060; margin-bottom: 1.5rem; }
.field { margin-bottom: 1.25rem; }
label { display: block; font-size: .8125rem; font-weight: 600; color: #8a7060; margin-bottom: .375rem; }
input[type="text"], select {
width: 100%; min-height: 44px; padding: 0 .875rem; border: 1px solid #e8d9c4;
border-radius: 10px; background: #fff; font-size: 1rem; color: #3a2e22; }
select { padding-right: 2rem; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238a7060' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right .875rem center; }
.field-error { margin-top: .375rem; font-size: .8125rem; color: #c0392b; }
.hint { margin-top: .375rem; font-size: .8125rem; color: #8a7060; }
.btn { display: flex; align-items: center; justify-content: center; width: 100%; min-height: 44px;
margin-top: 1.5rem; background: #c97c3a; color: #fff; border: none; border-radius: 9999px;
font-size: 1rem; font-weight: 700; cursor: pointer; }
</style>
</head>
<body>
<div class="card">
<h1>Name your frame</h1>
<p class="subtitle">You can always change these settings later.</p>
{% if error %}
<p class="field-error" role="alert" style="margin-bottom:1rem">{{ error }}</p>
{% endif %}
<form method="post" novalidate>
<div class="field">
<label for="name">Frame name</label>
<input type="text" id="name" name="name"
value="{{ device.name }}"
placeholder="e.g. Living room frame"
maxlength="100" required autofocus>
</div>
<div class="field">
<label for="orientation">Display orientation</label>
<select id="orientation" name="orientation">
<option value="landscape" {% if device.orientation.value == 'landscape' %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if device.orientation.value == 'portrait' %}selected{% endif %}>Portrait</option>
</select>
</div>
<div class="field">
<label for="rotation_interval_hours">Rotation frequency</label>
<select id="rotation_interval_hours" name="rotation_interval_hours">
<option value="6" {% if device.rotationIntervalHours == 6 %}selected{% endif %}>Every 6 hours</option>
<option value="12" {% if device.rotationIntervalHours == 12 %}selected{% endif %}>Every 12 hours</option>
<option value="24" {% if device.rotationIntervalHours == 24 %}selected{% endif %}>Daily (every 24 hours)</option>
<option value="48" {% if device.rotationIntervalHours == 48 %}selected{% endif %}>Every 2 days</option>
<option value="168" {% if device.rotationIntervalHours == 168 %}selected{% endif %}>Weekly</option>
</select>
</div>
<div class="field">
<label for="uniqueness_window">Uniqueness window</label>
<select id="uniqueness_window" name="uniqueness_window">
<option value="5" {% if device.uniquenessWindow == 5 %}selected{% endif %}>5 cycles</option>
<option value="10" {% if device.uniquenessWindow == 10 %}selected{% endif %}>10 cycles (default)</option>
<option value="20" {% if device.uniquenessWindow == 20 %}selected{% endif %}>20 cycles</option>
<option value="50" {% if device.uniquenessWindow == 50 %}selected{% endif %}>50 cycles</option>
</select>
<p class="hint">Images won't repeat until this many other photos have been shown.</p>
</div>
<button type="submit" class="btn">Save &amp; finish setup</button>
</form>
</div>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up your frame — pictureFrame</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; min-height: 100dvh; background: #fdf6ee; color: #3a2e22;
display: flex; align-items: flex-start; justify-content: center; padding: 2rem 1rem; }
.card { width: 100%; max-width: 400px; }
h1 { font-size: 1.4rem; font-weight: 700; margin-bottom: .25rem; }
.subtitle { font-size: .875rem; color: #8a7060; margin-bottom: 1.5rem; }
.tabs { display: flex; border-bottom: 1px solid #e8d9c4; margin-bottom: 1.5rem; }
.tab { flex: 1; padding: .75rem; text-align: center; font-weight: 700; font-size: .9rem;
color: #8a7060; text-decoration: none; border-bottom: 2px solid transparent; transition: color .15s; }
.tab.active { color: #c97c3a; border-bottom-color: #c97c3a; }
.panel { display: none; } .panel.active { display: block; }
.field { margin-bottom: 1rem; }
label { display: block; font-size: .8125rem; font-weight: 600; color: #8a7060; margin-bottom: .375rem; }
input[type="email"], input[type="password"], input[type="text"] {
width: 100%; min-height: 44px; padding: 0 .875rem; border: 1px solid #e8d9c4;
border-radius: 10px; background: #fff; font-size: 1rem; color: #3a2e22; }
input[aria-invalid="true"] { border-color: #c0392b; }
.field-error { margin-top: .25rem; font-size: .8125rem; color: #c0392b; }
.btn { display: flex; align-items: center; justify-content: center; width: 100%; min-height: 44px;
margin-top: 1.25rem; background: #c97c3a; color: #fff; border: none; border-radius: 9999px;
font-size: 1rem; font-weight: 700; cursor: pointer; }
</style>
</head>
<body>
<div class="card">
<h1>Set up your frame</h1>
<p class="subtitle">Create an account or sign in to link this frame.</p>
<div class="tabs">
<a href="#register" class="tab {% if not login_error %}active{% endif %}" data-tab="register">Create account</a>
<a href="#login" class="tab {% if login_error %}active{% endif %}" data-tab="login">Sign in</a>
</div>
{# ── Register panel ────────────────────────────────────────────────────── #}
<div id="register" class="panel {% if not login_error %}active{% endif %}">
{{ form_start(reg_form, {action: path('setup_register', {mac: mac}), attr: {novalidate: 'novalidate'}}) }}
<div class="field">
{{ form_label(reg_form.email) }}
{{ form_widget(reg_form.email, {attr: {
id: 'reg-email',
'aria-invalid': reg_form.email.vars.errors|length > 0 ? 'true' : 'false'
}}) }}
{% for error in reg_form.email.vars.errors %}
<p class="field-error" role="alert">{{ error.message }}</p>
{% endfor %}
</div>
<div class="field">
{{ form_label(reg_form.plainPassword) }}
{{ form_widget(reg_form.plainPassword, {attr: {
id: 'reg-pass',
'aria-invalid': reg_form.plainPassword.vars.errors|length > 0 ? 'true' : 'false'
}}) }}
{% for error in reg_form.plainPassword.vars.errors %}
<p class="field-error" role="alert">{{ error.message }}</p>
{% endfor %}
</div>
<button type="submit" class="btn">Create account &amp; link frame</button>
{{ form_end(reg_form) }}
</div>
{# ── Login panel ───────────────────────────────────────────────────────── #}
<div id="login" class="panel {% if login_error %}active{% endif %}">
<form method="post" action="{{ path('setup_login', {mac: mac}) }}" novalidate>
{% if login_error %}
<p class="field-error" role="alert" style="margin-bottom:1rem">{{ login_error }}</p>
{% endif %}
<div class="field">
<label for="login-email">Email address</label>
<input type="email" id="login-email" name="_username" autocomplete="email" min-height="44px">
</div>
<div class="field">
<label for="login-pass">Password</label>
<input type="password" id="login-pass" name="_password" autocomplete="current-password">
</div>
<button type="submit" class="btn">Sign in &amp; link frame</button>
</form>
</div>
</div>
<script>
(function () {
var tabs = document.querySelectorAll('.tab');
var panels = document.querySelectorAll('.panel');
tabs.forEach(function (tab) {
tab.addEventListener('click', function (e) {
e.preventDefault();
var target = tab.dataset.tab;
tabs.forEach(function (t) { t.classList.toggle('active', t.dataset.tab === target); });
panels.forEach(function (p) { p.classList.toggle('active', p.id === target); });
});
});
}());
</script>
</body>
</html>