Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e5ef7fe78 | |||
| f2af2de36f | |||
| d5a7849fbd | |||
| 1ec5364de4 |
@@ -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
+294
-93
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -10,7 +10,7 @@ export interface Device {
|
||||
mac: string
|
||||
name: string
|
||||
orientation: 'landscape' | 'portrait'
|
||||
rotationInterval: number
|
||||
rotationIntervalHours: number
|
||||
uniquenessWindow: number
|
||||
linkedAt: string
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum Orientation: string
|
||||
{
|
||||
case Landscape = 'landscape';
|
||||
case Portrait = 'portrait';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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 & finish setup</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 & 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 & 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>
|
||||
Reference in New Issue
Block a user