Add display simulator, orientation support, and per-image crop settings
- E-paper display simulator: Python port of the C++ Floyd-Steinberg dithering (same palette, same coefficients) with side-by-side preview in the web UI. Interactive pan/zoom controls with live re-rendering. - Frame orientation: landscape / portrait_cw / portrait_ccw setting controls logical display dimensions (800x480 vs 480x800). Images are rotated to match the physical buffer after processing. - Display modes: zoom (cover+crop) and letterbox (fit with padding), configurable globally and per-image. Zoom mode supports pan_x/pan_y (0.0-1.0) to control crop position. - Settings persistence: frame settings, per-image settings, and frame state stored as JSON, surviving restarts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
142
server/dither.py
Normal file
142
server/dither.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Floyd-Steinberg dithering for E Ink Spectra 6 (6-color) display.
|
||||||
|
|
||||||
|
This is a 1:1 Python port of the C++ dithering in firmware/src/dither.h.
|
||||||
|
Same palette RGB values, same error diffusion coefficients, same color
|
||||||
|
distance metric. The output should be pixel-identical to the firmware.
|
||||||
|
|
||||||
|
Native panel color codes (what the controller expects over SPI):
|
||||||
|
0x00 = Black
|
||||||
|
0x01 = White
|
||||||
|
0x02 = Yellow
|
||||||
|
0x03 = Red
|
||||||
|
0x04 = Orange (not used in Spectra 6, but accepted by controller)
|
||||||
|
0x05 = Blue
|
||||||
|
0x06 = Green
|
||||||
|
|
||||||
|
Pixel packing: 4bpp, 2 pixels per byte.
|
||||||
|
High nibble (bits 7-4) = LEFT pixel
|
||||||
|
Low nibble (bits 3-0) = RIGHT pixel
|
||||||
|
Scan order: left-to-right, top-to-bottom
|
||||||
|
|
||||||
|
Data path on the ESP32:
|
||||||
|
JPEG → decode to RGB888 → Floyd-Steinberg dither → 4bpp native buffer
|
||||||
|
→ writeNative(buf, invert=true) → SPI command 0x10 → panel refresh
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Palette: (R, G, B, native_panel_index)
|
||||||
|
# These RGB values must match firmware/src/dither.h PALETTE[] exactly.
|
||||||
|
PALETTE = [
|
||||||
|
( 0, 0, 0, 0), # Black
|
||||||
|
(255, 255, 255, 1), # White
|
||||||
|
(200, 30, 30, 3), # Red
|
||||||
|
( 0, 145, 0, 6), # Green
|
||||||
|
( 0, 0, 160, 5), # Blue
|
||||||
|
(230, 210, 0, 2), # Yellow
|
||||||
|
]
|
||||||
|
|
||||||
|
# Extract just the RGB values as a numpy array for vectorized distance calc
|
||||||
|
PALETTE_RGB = np.array([(r, g, b) for r, g, b, _ in PALETTE], dtype=np.int32)
|
||||||
|
PALETTE_INDICES = [idx for _, _, _, idx in PALETTE]
|
||||||
|
|
||||||
|
# Display RGB values — what the e-paper pigments actually look like.
|
||||||
|
# Used for rendering the simulator output. These may differ from the palette
|
||||||
|
# values above (which are what we compare against during dithering).
|
||||||
|
# For now they're the same, but can be tuned independently.
|
||||||
|
DISPLAY_RGB = {
|
||||||
|
0: ( 0, 0, 0), # Black
|
||||||
|
1: (255, 255, 255), # White
|
||||||
|
2: (230, 210, 0), # Yellow
|
||||||
|
3: (200, 30, 30), # Red
|
||||||
|
5: ( 0, 0, 160), # Blue
|
||||||
|
6: ( 0, 145, 0), # Green
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_closest_color(r: int, g: int, b: int) -> int:
|
||||||
|
"""Find the palette index with minimum squared Euclidean distance."""
|
||||||
|
best_idx = 0
|
||||||
|
best_dist = float('inf')
|
||||||
|
for i, (pr, pg, pb, _) in enumerate(PALETTE):
|
||||||
|
dr = r - pr
|
||||||
|
dg = g - pg
|
||||||
|
db = b - pb
|
||||||
|
dist = dr * dr + dg * dg + db * db
|
||||||
|
if dist < best_dist:
|
||||||
|
best_dist = dist
|
||||||
|
best_idx = i
|
||||||
|
return best_idx
|
||||||
|
|
||||||
|
|
||||||
|
def dither_floyd_steinberg(img: Image.Image) -> tuple[np.ndarray, Image.Image]:
|
||||||
|
"""
|
||||||
|
Apply Floyd-Steinberg dithering to an 800x480 RGB image.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
native_buffer: numpy array of shape (480, 400) dtype uint8
|
||||||
|
Each byte = 2 pixels packed as 4bpp native panel indices.
|
||||||
|
High nibble = left pixel, low nibble = right pixel.
|
||||||
|
preview_img: PIL Image showing what the e-paper display would look like.
|
||||||
|
"""
|
||||||
|
assert img.size == (800, 480), f"Image must be 800x480, got {img.size}"
|
||||||
|
|
||||||
|
width, height = 800, 480
|
||||||
|
# Work with int16 to allow negative error values
|
||||||
|
pixels = np.array(img, dtype=np.int16)
|
||||||
|
|
||||||
|
# Output: what native panel index each pixel gets
|
||||||
|
result = np.zeros((height, width), dtype=np.uint8)
|
||||||
|
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
r = int(np.clip(pixels[y, x, 0], 0, 255))
|
||||||
|
g = int(np.clip(pixels[y, x, 1], 0, 255))
|
||||||
|
b = int(np.clip(pixels[y, x, 2], 0, 255))
|
||||||
|
|
||||||
|
ci = find_closest_color(r, g, b)
|
||||||
|
pr, pg, pb, native_idx = PALETTE[ci]
|
||||||
|
result[y, x] = native_idx
|
||||||
|
|
||||||
|
# Quantization error
|
||||||
|
er = r - pr
|
||||||
|
eg = g - pg
|
||||||
|
eb = b - pb
|
||||||
|
|
||||||
|
# Floyd-Steinberg error diffusion (same coefficients as C++)
|
||||||
|
# Right: 7/16, Bottom-left: 3/16, Bottom: 5/16, Bottom-right: 1/16
|
||||||
|
if x + 1 < width:
|
||||||
|
pixels[y, x + 1, 0] += er * 7 // 16
|
||||||
|
pixels[y, x + 1, 1] += eg * 7 // 16
|
||||||
|
pixels[y, x + 1, 2] += eb * 7 // 16
|
||||||
|
if y + 1 < height:
|
||||||
|
if x - 1 >= 0:
|
||||||
|
pixels[y + 1, x - 1, 0] += er * 3 // 16
|
||||||
|
pixels[y + 1, x - 1, 1] += eg * 3 // 16
|
||||||
|
pixels[y + 1, x - 1, 2] += eb * 3 // 16
|
||||||
|
pixels[y + 1, x, 0] += er * 5 // 16
|
||||||
|
pixels[y + 1, x, 1] += eg * 5 // 16
|
||||||
|
pixels[y + 1, x, 2] += eb * 5 // 16
|
||||||
|
if x + 1 < width:
|
||||||
|
pixels[y + 1, x + 1, 0] += er * 1 // 16
|
||||||
|
pixels[y + 1, x + 1, 1] += eg * 1 // 16
|
||||||
|
pixels[y + 1, x + 1, 2] += eb * 1 // 16
|
||||||
|
|
||||||
|
# Pack into 4bpp buffer (2 pixels per byte, high nibble = left)
|
||||||
|
native_buffer = np.zeros((height, width // 2), dtype=np.uint8)
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(0, width, 2):
|
||||||
|
left = result[y, x]
|
||||||
|
right = result[y, x + 1]
|
||||||
|
native_buffer[y, x // 2] = (left << 4) | right
|
||||||
|
|
||||||
|
# Render preview image using display RGB values
|
||||||
|
preview = np.zeros((height, width, 3), dtype=np.uint8)
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
preview[y, x] = DISPLAY_RGB[result[y, x]]
|
||||||
|
preview_img = Image.fromarray(preview)
|
||||||
|
|
||||||
|
return native_buffer, preview_img
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
flask==3.1.*
|
flask==3.1.*
|
||||||
|
numpy>=1.26
|
||||||
pillow==11.*
|
pillow==11.*
|
||||||
|
|||||||
540
server/server.py
540
server/server.py
@@ -5,14 +5,20 @@ Serves photos for the ESP32 photo frame and provides a web UI for management.
|
|||||||
Tracks frame status via heartbeat reports from the ESP32.
|
Tracks frame status via heartbeat reports from the ESP32.
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
GET / Web UI — gallery, upload, frame status
|
GET / Web UI — gallery, upload, frame status, settings
|
||||||
GET /photo Random JPEG resized to 800x480 (called by ESP32)
|
GET /photo Random JPEG processed for the display (called by ESP32)
|
||||||
POST /heartbeat ESP32 reports what it displayed
|
POST /heartbeat ESP32 reports what it displayed
|
||||||
GET /api/status Frame status (for Home Assistant REST sensor)
|
GET /api/status Frame status (for Home Assistant REST sensor)
|
||||||
GET /api/photos List all photos as JSON
|
GET /api/photos List all photos as JSON
|
||||||
POST /api/upload Upload new photos
|
POST /api/upload Upload new photos
|
||||||
DELETE /api/photos/<name> Delete a photo
|
DELETE /api/photos/<name> Delete a photo
|
||||||
GET /health Health check
|
GET /api/settings Get frame settings
|
||||||
|
PUT /api/settings Update frame settings
|
||||||
|
GET /api/photos/<name>/settings Get per-image settings
|
||||||
|
PUT /api/photos/<name>/settings Update per-image settings
|
||||||
|
GET /preview/<name> Dithered e-paper preview image
|
||||||
|
GET /simulate/<name> Full simulator page with controls
|
||||||
|
GET /health Health check
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -20,7 +26,6 @@ import io
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -28,34 +33,156 @@ from flask import Flask, Response, jsonify, request, render_template_string
|
|||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from dither import dither_floyd_steinberg
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
PHOTOS_DIR: Path = Path(".")
|
PHOTOS_DIR: Path = Path(".")
|
||||||
TARGET_WIDTH = 800
|
PHYSICAL_WIDTH = 800
|
||||||
TARGET_HEIGHT = 480
|
PHYSICAL_HEIGHT = 480
|
||||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"}
|
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"}
|
||||||
MAX_UPLOAD_SIZE = 20 * 1024 * 1024 # 20MB
|
|
||||||
|
|
||||||
# Frame state — persisted to disk so it survives restarts
|
DATA_DIR = Path("/data")
|
||||||
STATE_FILE = Path("/data/frame_state.json")
|
STATE_FILE = DATA_DIR / "frame_state.json"
|
||||||
|
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||||
|
IMAGE_SETTINGS_FILE = DATA_DIR / "image_settings.json"
|
||||||
|
|
||||||
def load_state() -> dict:
|
# --- Persistence helpers ---
|
||||||
|
|
||||||
|
def _load_json(path: Path, default):
|
||||||
try:
|
try:
|
||||||
if STATE_FILE.exists():
|
if path.exists():
|
||||||
return json.loads(STATE_FILE.read_text())
|
return json.loads(path.read_text())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"last_update": None, "current_photo": None, "ip": None, "updates": 0}
|
return default() if callable(default) else default
|
||||||
|
|
||||||
def save_state(state: dict):
|
def _save_json(path: Path, data):
|
||||||
try:
|
try:
|
||||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
STATE_FILE.write_text(json.dumps(state))
|
path.write_text(json.dumps(data, indent=2))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: could not save state: {e}", file=sys.stderr)
|
print(f"Warning: could not save {path}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
frame_state = load_state()
|
# Frame state (runtime, updated by heartbeats)
|
||||||
|
frame_state = _load_json(STATE_FILE, lambda: {
|
||||||
|
"last_update": None, "current_photo": None, "ip": None, "updates": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
# Frame settings (user-configured)
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"orientation": "landscape", # landscape | portrait_cw | portrait_ccw
|
||||||
|
"default_mode": "zoom", # zoom | letterbox
|
||||||
|
"letterbox_color": [255, 255, 255], # white — looks like e-paper background
|
||||||
|
}
|
||||||
|
|
||||||
|
frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS))
|
||||||
|
|
||||||
|
# Per-image settings: { "filename.jpg": { "mode": "zoom", "pan_x": 0.5, "pan_y": 0.3 }, ... }
|
||||||
|
image_settings: dict = _load_json(IMAGE_SETTINGS_FILE, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_settings(name: str) -> dict:
|
||||||
|
"""Get effective settings for an image (per-image overrides merged with defaults)."""
|
||||||
|
defaults = {"mode": None, "pan_x": 0.5, "pan_y": 0.5}
|
||||||
|
per_image = image_settings.get(name, {})
|
||||||
|
merged = {**defaults, **per_image}
|
||||||
|
if merged["mode"] is None:
|
||||||
|
merged["mode"] = frame_settings.get("default_mode", "zoom")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
# --- Image processing ---
|
||||||
|
|
||||||
|
def get_logical_dimensions() -> tuple[int, int]:
|
||||||
|
"""Get the logical display dimensions based on frame orientation."""
|
||||||
|
orientation = frame_settings.get("orientation", "landscape")
|
||||||
|
if orientation == "landscape":
|
||||||
|
return PHYSICAL_WIDTH, PHYSICAL_HEIGHT # 800x480
|
||||||
|
else:
|
||||||
|
return PHYSICAL_HEIGHT, PHYSICAL_WIDTH # 480x800
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(img: Image.Image, settings: dict) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Resize and transform an image for the display.
|
||||||
|
|
||||||
|
Takes frame orientation into account:
|
||||||
|
- Determines logical dimensions (800x480 or 480x800)
|
||||||
|
- Applies zoom+pan or letterbox based on settings
|
||||||
|
- Rotates to match physical 800x480 buffer if portrait
|
||||||
|
|
||||||
|
Returns an 800x480 image ready for dithering / JPEG encoding.
|
||||||
|
"""
|
||||||
|
logical_w, logical_h = get_logical_dimensions()
|
||||||
|
mode = settings.get("mode", "zoom")
|
||||||
|
pan_x = settings.get("pan_x", 0.5)
|
||||||
|
pan_y = settings.get("pan_y", 0.5)
|
||||||
|
|
||||||
|
if mode == "zoom":
|
||||||
|
img = _zoom_and_pan(img, logical_w, logical_h, pan_x, pan_y)
|
||||||
|
else:
|
||||||
|
img = _letterbox(img, logical_w, logical_h)
|
||||||
|
|
||||||
|
# Rotate to match physical buffer if portrait
|
||||||
|
orientation = frame_settings.get("orientation", "landscape")
|
||||||
|
if orientation == "portrait_cw":
|
||||||
|
img = img.rotate(-90, expand=True)
|
||||||
|
elif orientation == "portrait_ccw":
|
||||||
|
img = img.rotate(90, expand=True)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _zoom_and_pan(img: Image.Image, tw: int, th: int,
|
||||||
|
pan_x: float, pan_y: float) -> Image.Image:
|
||||||
|
"""Scale to cover target, then crop at the specified pan position."""
|
||||||
|
target_ratio = tw / th
|
||||||
|
img_ratio = img.width / img.height
|
||||||
|
|
||||||
|
if img_ratio > target_ratio:
|
||||||
|
# Image is wider — fit height, crop width
|
||||||
|
new_height = th
|
||||||
|
new_width = int(img.width * (th / img.height))
|
||||||
|
else:
|
||||||
|
# Image is taller — fit width, crop height
|
||||||
|
new_width = tw
|
||||||
|
new_height = int(img.height * (tw / img.width))
|
||||||
|
|
||||||
|
img = img.resize((new_width, new_height), Image.LANCZOS)
|
||||||
|
|
||||||
|
# Pan-aware crop: pan_x/pan_y are 0.0–1.0, controlling crop position
|
||||||
|
max_left = new_width - tw
|
||||||
|
max_top = new_height - th
|
||||||
|
left = int(max_left * pan_x)
|
||||||
|
top = int(max_top * pan_y)
|
||||||
|
return img.crop((left, top, left + tw, top + th))
|
||||||
|
|
||||||
|
|
||||||
|
def _letterbox(img: Image.Image, tw: int, th: int) -> Image.Image:
|
||||||
|
"""Scale to fit inside target, pad with letterbox color."""
|
||||||
|
lb_color = tuple(frame_settings.get("letterbox_color", [255, 255, 255]))
|
||||||
|
target_ratio = tw / th
|
||||||
|
img_ratio = img.width / img.height
|
||||||
|
|
||||||
|
if img_ratio > target_ratio:
|
||||||
|
# Image is wider — fit width
|
||||||
|
new_width = tw
|
||||||
|
new_height = int(img.height * (tw / img.width))
|
||||||
|
else:
|
||||||
|
# Image is taller — fit height
|
||||||
|
new_height = th
|
||||||
|
new_width = int(img.width * (th / img.height))
|
||||||
|
|
||||||
|
img = img.resize((new_width, new_height), Image.LANCZOS)
|
||||||
|
|
||||||
|
canvas = Image.new("RGB", (tw, th), lb_color)
|
||||||
|
paste_x = (tw - new_width) // 2
|
||||||
|
paste_y = (th - new_height) // 2
|
||||||
|
canvas.paste(img, (paste_x, paste_y))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
def get_photo_list() -> list[Path]:
|
def get_photo_list() -> list[Path]:
|
||||||
@@ -66,25 +193,6 @@ def get_photo_list() -> list[Path]:
|
|||||||
return sorted(photos, key=lambda p: p.name)
|
return sorted(photos, key=lambda p: p.name)
|
||||||
|
|
||||||
|
|
||||||
def resize_and_crop(img: Image.Image) -> Image.Image:
|
|
||||||
target_ratio = TARGET_WIDTH / TARGET_HEIGHT
|
|
||||||
img_ratio = img.width / img.height
|
|
||||||
|
|
||||||
if img_ratio > target_ratio:
|
|
||||||
new_height = TARGET_HEIGHT
|
|
||||||
new_width = int(img.width * (TARGET_HEIGHT / img.height))
|
|
||||||
else:
|
|
||||||
new_width = TARGET_WIDTH
|
|
||||||
new_height = int(img.height * (TARGET_WIDTH / img.width))
|
|
||||||
|
|
||||||
img = img.resize((new_width, new_height), Image.LANCZOS)
|
|
||||||
|
|
||||||
left = (new_width - TARGET_WIDTH) // 2
|
|
||||||
top = (new_height - TARGET_HEIGHT) // 2
|
|
||||||
img = img.crop((left, top, left + TARGET_WIDTH, top + TARGET_HEIGHT))
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def make_thumbnail(img: Image.Image, size: int = 300) -> bytes:
|
def make_thumbnail(img: Image.Image, size: int = 300) -> bytes:
|
||||||
img.thumbnail((size, size), Image.LANCZOS)
|
img.thumbnail((size, size), Image.LANCZOS)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
@@ -102,9 +210,9 @@ def random_photo():
|
|||||||
|
|
||||||
chosen = random.choice(photos)
|
chosen = random.choice(photos)
|
||||||
try:
|
try:
|
||||||
img = Image.open(chosen)
|
img = Image.open(chosen).convert("RGB")
|
||||||
img = img.convert("RGB")
|
settings = get_image_settings(chosen.name)
|
||||||
img = resize_and_crop(img)
|
img = process_image(img, settings)
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
img.save(buf, format="JPEG", quality=85)
|
img.save(buf, format="JPEG", quality=85)
|
||||||
@@ -119,7 +227,6 @@ def random_photo():
|
|||||||
|
|
||||||
@app.route("/heartbeat", methods=["POST"])
|
@app.route("/heartbeat", methods=["POST"])
|
||||||
def heartbeat():
|
def heartbeat():
|
||||||
"""ESP32 reports after displaying a photo."""
|
|
||||||
global frame_state
|
global frame_state
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
frame_state["last_update"] = datetime.now(timezone.utc).isoformat()
|
frame_state["last_update"] = datetime.now(timezone.utc).isoformat()
|
||||||
@@ -127,15 +234,57 @@ def heartbeat():
|
|||||||
frame_state["ip"] = request.remote_addr
|
frame_state["ip"] = request.remote_addr
|
||||||
frame_state["free_heap"] = data.get("free_heap", None)
|
frame_state["free_heap"] = data.get("free_heap", None)
|
||||||
frame_state["updates"] = frame_state.get("updates", 0) + 1
|
frame_state["updates"] = frame_state.get("updates", 0) + 1
|
||||||
save_state(frame_state)
|
_save_json(STATE_FILE, frame_state)
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
# --- API endpoints ---
|
# --- Settings API ---
|
||||||
|
|
||||||
|
@app.route("/api/settings", methods=["GET"])
|
||||||
|
def api_get_settings():
|
||||||
|
return jsonify(frame_settings)
|
||||||
|
|
||||||
|
@app.route("/api/settings", methods=["PUT"])
|
||||||
|
def api_put_settings():
|
||||||
|
global frame_settings
|
||||||
|
data = request.get_json()
|
||||||
|
allowed_keys = {"orientation", "default_mode", "letterbox_color"}
|
||||||
|
for k, v in data.items():
|
||||||
|
if k in allowed_keys:
|
||||||
|
frame_settings[k] = v
|
||||||
|
_save_json(SETTINGS_FILE, frame_settings)
|
||||||
|
return jsonify(frame_settings)
|
||||||
|
|
||||||
|
@app.route("/api/photos/<name>/settings", methods=["GET"])
|
||||||
|
def api_get_image_settings(name: str):
|
||||||
|
fname = secure_filename(name)
|
||||||
|
return jsonify(get_image_settings(fname))
|
||||||
|
|
||||||
|
@app.route("/api/photos/<name>/settings", methods=["PUT"])
|
||||||
|
def api_put_image_settings(name: str):
|
||||||
|
fname = secure_filename(name)
|
||||||
|
data = request.get_json()
|
||||||
|
allowed_keys = {"mode", "pan_x", "pan_y"}
|
||||||
|
current = image_settings.get(fname, {})
|
||||||
|
for k, v in data.items():
|
||||||
|
if k in allowed_keys:
|
||||||
|
current[k] = v
|
||||||
|
image_settings[fname] = current
|
||||||
|
_save_json(IMAGE_SETTINGS_FILE, image_settings)
|
||||||
|
return jsonify(get_image_settings(fname))
|
||||||
|
|
||||||
|
@app.route("/api/photos/<name>/settings", methods=["DELETE"])
|
||||||
|
def api_delete_image_settings(name: str):
|
||||||
|
fname = secure_filename(name)
|
||||||
|
image_settings.pop(fname, None)
|
||||||
|
_save_json(IMAGE_SETTINGS_FILE, image_settings)
|
||||||
|
return jsonify(get_image_settings(fname))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Other API endpoints ---
|
||||||
|
|
||||||
@app.route("/api/status")
|
@app.route("/api/status")
|
||||||
def api_status():
|
def api_status():
|
||||||
"""Frame status for Home Assistant REST sensor."""
|
|
||||||
photos = get_photo_list()
|
photos = get_photo_list()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"state": "online" if frame_state.get("last_update") else "waiting",
|
"state": "online" if frame_state.get("last_update") else "waiting",
|
||||||
@@ -144,13 +293,20 @@ def api_status():
|
|||||||
"frame_ip": frame_state.get("ip"),
|
"frame_ip": frame_state.get("ip"),
|
||||||
"total_photos": len(photos),
|
"total_photos": len(photos),
|
||||||
"total_updates": frame_state.get("updates", 0),
|
"total_updates": frame_state.get("updates", 0),
|
||||||
|
"orientation": frame_settings.get("orientation", "landscape"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/photos")
|
@app.route("/api/photos")
|
||||||
def api_photos():
|
def api_photos():
|
||||||
photos = get_photo_list()
|
photos = get_photo_list()
|
||||||
return jsonify([{"name": p.name, "size": p.stat().st_size} for p in photos])
|
result = []
|
||||||
|
for p in photos:
|
||||||
|
entry = {"name": p.name, "size": p.stat().st_size}
|
||||||
|
if p.name in image_settings:
|
||||||
|
entry["settings"] = image_settings[p.name]
|
||||||
|
result.append(entry)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/upload", methods=["POST"])
|
@app.route("/api/upload", methods=["POST"])
|
||||||
@@ -176,6 +332,8 @@ def api_delete(name: str):
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
image_settings.pop(fname, None)
|
||||||
|
_save_json(IMAGE_SETTINGS_FILE, image_settings)
|
||||||
return jsonify({"deleted": fname})
|
return jsonify({"deleted": fname})
|
||||||
|
|
||||||
|
|
||||||
@@ -186,20 +344,238 @@ def thumbnail(name: str):
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return "", 404
|
return "", 404
|
||||||
try:
|
try:
|
||||||
|
size = request.args.get("size", 300, type=int)
|
||||||
|
size = min(size, 1600)
|
||||||
img = Image.open(path).convert("RGB")
|
img = Image.open(path).convert("RGB")
|
||||||
data = make_thumbnail(img)
|
data = make_thumbnail(img, size)
|
||||||
return Response(data, mimetype="image/jpeg")
|
return Response(data, mimetype="image/jpeg")
|
||||||
except Exception:
|
except Exception:
|
||||||
return "", 500
|
return "", 500
|
||||||
|
|
||||||
|
|
||||||
|
# --- Simulator / Preview ---
|
||||||
|
|
||||||
|
@app.route("/preview/<name>")
|
||||||
|
def preview_photo(name: str):
|
||||||
|
fname = secure_filename(name)
|
||||||
|
path = PHOTOS_DIR / fname
|
||||||
|
if not path.exists():
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
|
||||||
|
# Allow query param overrides for interactive simulator
|
||||||
|
settings = get_image_settings(fname)
|
||||||
|
if "mode" in request.args:
|
||||||
|
settings["mode"] = request.args["mode"]
|
||||||
|
if "pan_x" in request.args:
|
||||||
|
settings["pan_x"] = float(request.args["pan_x"])
|
||||||
|
if "pan_y" in request.args:
|
||||||
|
settings["pan_y"] = float(request.args["pan_y"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(path).convert("RGB")
|
||||||
|
img = process_image(img, settings)
|
||||||
|
_, preview = dither_floyd_steinberg(img)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
preview.save(buf, format="PNG")
|
||||||
|
buf.seek(0)
|
||||||
|
return Response(buf.getvalue(), mimetype="image/png")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Preview error: {e}", file=sys.stderr)
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/preview")
|
||||||
|
def preview_random():
|
||||||
|
photos = get_photo_list()
|
||||||
|
if not photos:
|
||||||
|
return jsonify({"error": "No photos found"}), 404
|
||||||
|
chosen = random.choice(photos)
|
||||||
|
return preview_photo(chosen.name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/simulate/<name>")
|
||||||
|
def simulate_page(name: str):
|
||||||
|
fname = secure_filename(name)
|
||||||
|
path = PHOTOS_DIR / fname
|
||||||
|
if not path.exists():
|
||||||
|
return "", 404
|
||||||
|
settings = get_image_settings(fname)
|
||||||
|
orientation = frame_settings.get("orientation", "landscape")
|
||||||
|
return render_template_string(SIMULATE_UI, photo_name=fname,
|
||||||
|
settings=settings, orientation=orientation)
|
||||||
|
|
||||||
|
|
||||||
|
SIMULATE_UI = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>E-Paper Simulator — {{ photo_name }}</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #1a1a1a; --card: #222; --border: #444; --accent: #6c8; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: var(--bg); color: #eee; font-family: -apple-system, system-ui, sans-serif;
|
||||||
|
display: flex; flex-direction: column; align-items: center; min-height: 100vh; padding: 2rem; }
|
||||||
|
h2 { font-size: 1.1rem; margin-bottom: 1rem; font-weight: 400; color: #999; }
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
background: #e8e4d9; border-radius: 4px; padding: 12px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.frame img { display: block; image-rendering: auto; }
|
||||||
|
.frame.landscape img { width: 800px; max-width: 90vw; height: auto; }
|
||||||
|
.frame.portrait { }
|
||||||
|
.frame.portrait img { height: 800px; max-height: 70vh; width: auto; }
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 1.25rem; margin-top: 1.5rem; width: 100%; max-width: 700px;
|
||||||
|
}
|
||||||
|
.controls h3 { font-size: 0.85rem; color: #888; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; margin-bottom: 0.75rem; }
|
||||||
|
.control-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.6rem; flex-wrap: wrap; }
|
||||||
|
.control-row label { font-size: 0.85rem; min-width: 80px; color: #aaa; }
|
||||||
|
.control-row select, .control-row input[type=range] { flex: 1; max-width: 300px; }
|
||||||
|
select { background: #333; color: #eee; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
padding: 0.3rem 0.5rem; font-size: 0.85rem; }
|
||||||
|
input[type=range] { accent-color: var(--accent); }
|
||||||
|
.range-value { font-size: 0.8rem; color: #888; min-width: 2.5rem; }
|
||||||
|
|
||||||
|
.btn-row { display: flex; gap: 0.75rem; margin-top: 1rem; }
|
||||||
|
.btn { padding: 0.4rem 1rem; border-radius: 4px; border: 1px solid var(--border);
|
||||||
|
background: #333; color: #eee; cursor: pointer; font-size: 0.85rem; }
|
||||||
|
.btn:hover { background: #444; }
|
||||||
|
.btn.primary { background: var(--accent); color: #111; border-color: var(--accent); }
|
||||||
|
.btn.primary:hover { opacity: 0.9; }
|
||||||
|
.btn.reset { color: #f80; border-color: #f80; }
|
||||||
|
|
||||||
|
.pan-controls { display: {{ 'flex' if settings.mode == 'zoom' else 'none' }}; flex-direction: column; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.back { margin-top: 1.5rem; color: var(--accent); text-decoration: none; font-size: 0.9rem; }
|
||||||
|
.back:hover { text-decoration: underline; }
|
||||||
|
.loading { color: #888; font-size: 0.8rem; margin-left: 0.5rem; display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>E-Paper Display Simulator</h2>
|
||||||
|
|
||||||
|
<div class="frame {{ 'portrait' if orientation.startswith('portrait') else 'landscape' }}">
|
||||||
|
<img id="preview" src="/preview/{{ photo_name }}" alt="E-paper preview">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<h3>Display Settings</h3>
|
||||||
|
|
||||||
|
<div class="control-row">
|
||||||
|
<label>Mode</label>
|
||||||
|
<select id="mode" onchange="onModeChange()">
|
||||||
|
<option value="zoom" {{ 'selected' if settings.mode == 'zoom' else '' }}>Zoom (fill frame, crop overflow)</option>
|
||||||
|
<option value="letterbox" {{ 'selected' if settings.mode == 'letterbox' else '' }}>Letterbox (show entire image)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pan-controls" id="pan-controls">
|
||||||
|
<div class="control-row">
|
||||||
|
<label>Pan X</label>
|
||||||
|
<input type="range" id="pan_x" min="0" max="1" step="0.01"
|
||||||
|
value="{{ settings.pan_x }}" oninput="onPanChange()">
|
||||||
|
<span class="range-value" id="pan_x_val">{{ '%.0f' % (settings.pan_x * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="control-row">
|
||||||
|
<label>Pan Y</label>
|
||||||
|
<input type="range" id="pan_y" min="0" max="1" step="0.01"
|
||||||
|
value="{{ settings.pan_y }}" oninput="onPanChange()">
|
||||||
|
<span class="range-value" id="pan_y_val">{{ '%.0f' % (settings.pan_y * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn primary" onclick="saveSettings()">Save</button>
|
||||||
|
<button class="btn reset" onclick="resetSettings()">Reset to default</button>
|
||||||
|
<span class="loading" id="loading">Rendering...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="back" href="/">← Back to gallery</a>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const photoName = {{ photo_name | tojson }};
|
||||||
|
let refreshTimer = null;
|
||||||
|
|
||||||
|
function onModeChange() {
|
||||||
|
const mode = document.getElementById('mode').value;
|
||||||
|
document.getElementById('pan-controls').style.display = mode === 'zoom' ? 'flex' : 'none';
|
||||||
|
scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPanChange() {
|
||||||
|
document.getElementById('pan_x_val').textContent = Math.round(document.getElementById('pan_x').value * 100) + '%';
|
||||||
|
document.getElementById('pan_y_val').textContent = Math.round(document.getElementById('pan_y').value * 100) + '%';
|
||||||
|
scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh() {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
refreshTimer = setTimeout(refreshPreview, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPreview() {
|
||||||
|
const mode = document.getElementById('mode').value;
|
||||||
|
const panX = document.getElementById('pan_x').value;
|
||||||
|
const panY = document.getElementById('pan_y').value;
|
||||||
|
const img = document.getElementById('preview');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
|
||||||
|
let url = `/preview/${encodeURIComponent(photoName)}?mode=${mode}`;
|
||||||
|
if (mode === 'zoom') url += `&pan_x=${panX}&pan_y=${panY}`;
|
||||||
|
|
||||||
|
loading.style.display = 'inline';
|
||||||
|
const newImg = new window.Image();
|
||||||
|
newImg.onload = () => { img.src = newImg.src; loading.style.display = 'none'; };
|
||||||
|
newImg.onerror = () => { loading.style.display = 'none'; };
|
||||||
|
newImg.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
const mode = document.getElementById('mode').value;
|
||||||
|
const body = { mode };
|
||||||
|
if (mode === 'zoom') {
|
||||||
|
body.pan_x = parseFloat(document.getElementById('pan_x').value);
|
||||||
|
body.pan_y = parseFloat(document.getElementById('pan_y').value);
|
||||||
|
}
|
||||||
|
fetch(`/api/photos/${encodeURIComponent(photoName)}/settings`, {
|
||||||
|
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
|
||||||
|
}).then(r => r.json()).then(() => { /* saved */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSettings() {
|
||||||
|
fetch(`/api/photos/${encodeURIComponent(photoName)}/settings`, { method: 'DELETE' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(s => {
|
||||||
|
document.getElementById('mode').value = s.mode;
|
||||||
|
document.getElementById('pan_x').value = s.pan_x;
|
||||||
|
document.getElementById('pan_y').value = s.pan_y;
|
||||||
|
onModeChange();
|
||||||
|
onPanChange();
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# --- Web UI ---
|
# --- Web UI ---
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
photos = get_photo_list()
|
photos = get_photo_list()
|
||||||
|
orientation = frame_settings.get("orientation", "landscape")
|
||||||
|
default_mode = frame_settings.get("default_mode", "zoom")
|
||||||
return render_template_string(WEB_UI, photos=photos, state=frame_state,
|
return render_template_string(WEB_UI, photos=photos, state=frame_state,
|
||||||
photo_count=len(photos))
|
photo_count=len(photos), orientation=orientation,
|
||||||
|
default_mode=default_mode)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
@@ -225,12 +601,17 @@ WEB_UI = """<!DOCTYPE html>
|
|||||||
.status-card {
|
.status-card {
|
||||||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||||
padding: 1rem 1.25rem; margin-bottom: 1.5rem; display: flex; gap: 2rem; flex-wrap: wrap;
|
padding: 1rem 1.25rem; margin-bottom: 1.5rem; display: flex; gap: 2rem; flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
.status-card .item { display: flex; flex-direction: column; gap: 0.2rem; }
|
.status-card .item { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
.status-card .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
|
.status-card .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
|
||||||
.status-card .value { font-size: 0.95rem; }
|
.status-card .value { font-size: 0.95rem; }
|
||||||
.status-card .online { color: var(--accent); }
|
.status-card .online { color: var(--accent); }
|
||||||
.status-card .waiting { color: #f80; }
|
.status-card .waiting { color: #f80; }
|
||||||
|
.status-card select {
|
||||||
|
background: #333; color: #eee; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.4rem; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-area {
|
.upload-area {
|
||||||
background: var(--card); border: 2px dashed var(--border); border-radius: 8px;
|
background: var(--card); border: 2px dashed var(--border); border-radius: 8px;
|
||||||
@@ -250,11 +631,15 @@ WEB_UI = """<!DOCTYPE html>
|
|||||||
padding: 0.5rem 0.6rem; display: flex; justify-content: space-between; align-items: center;
|
padding: 0.5rem 0.6rem; display: flex; justify-content: space-between; align-items: center;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.photo-card .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%; }
|
.photo-card .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 65%; }
|
||||||
.photo-card .delete {
|
.photo-card .actions { display: flex; gap: 0.3rem; }
|
||||||
background: none; border: none; color: #f55; cursor: pointer; font-size: 0.85rem;
|
.photo-card .btn {
|
||||||
|
background: none; border: none; cursor: pointer; font-size: 0.85rem;
|
||||||
padding: 0.2rem 0.4rem; border-radius: 4px;
|
padding: 0.2rem 0.4rem; border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.photo-card .simulate { color: #6c8; }
|
||||||
|
.photo-card .simulate:hover { background: rgba(102,204,136,0.15); }
|
||||||
|
.photo-card .delete { color: #f55; }
|
||||||
.photo-card .delete:hover { background: rgba(255,85,85,0.15); }
|
.photo-card .delete:hover { background: rgba(255,85,85,0.15); }
|
||||||
.photo-card.current { border-color: var(--accent); }
|
.photo-card.current { border-color: var(--accent); }
|
||||||
|
|
||||||
@@ -287,8 +672,19 @@ WEB_UI = """<!DOCTYPE html>
|
|||||||
<span class="value">{{ photo_count }}</span>
|
<span class="value">{{ photo_count }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<span class="label">Total Refreshes</span>
|
<span class="label">Orientation</span>
|
||||||
<span class="value">{{ state.get('updates', 0) }}</span>
|
<select onchange="updateSetting('orientation', this.value)">
|
||||||
|
<option value="landscape" {{ 'selected' if orientation == 'landscape' else '' }}>Landscape</option>
|
||||||
|
<option value="portrait_cw" {{ 'selected' if orientation == 'portrait_cw' else '' }}>Portrait (CW)</option>
|
||||||
|
<option value="portrait_ccw" {{ 'selected' if orientation == 'portrait_ccw' else '' }}>Portrait (CCW)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<span class="label">Default Mode</span>
|
||||||
|
<select onchange="updateSetting('default_mode', this.value)">
|
||||||
|
<option value="zoom" {{ 'selected' if default_mode == 'zoom' else '' }}>Zoom</option>
|
||||||
|
<option value="letterbox" {{ 'selected' if default_mode == 'letterbox' else '' }}>Letterbox</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,7 +700,10 @@ WEB_UI = """<!DOCTYPE html>
|
|||||||
<img src="/thumb/{{ p.name }}" loading="lazy" alt="{{ p.name }}">
|
<img src="/thumb/{{ p.name }}" loading="lazy" alt="{{ p.name }}">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="name" title="{{ p.name }}">{{ p.name }}</span>
|
<span class="name" title="{{ p.name }}">{{ p.name }}</span>
|
||||||
<button class="delete" onclick="deletePhoto('{{ p.name }}')" title="Delete">×</button>
|
<div class="actions">
|
||||||
|
<a class="btn simulate" href="/simulate/{{ p.name }}" title="Preview on e-paper">■</a>
|
||||||
|
<button class="btn delete" onclick="deletePhoto('{{ p.name }}')" title="Delete">×</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -336,12 +735,16 @@ function deletePhoto(name) {
|
|||||||
if (!confirm('Delete ' + name + '?')) return;
|
if (!confirm('Delete ' + name + '?')) return;
|
||||||
fetch('/api/photos/' + encodeURIComponent(name), { method: 'DELETE' })
|
fetch('/api/photos/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => { document.querySelector('[data-name="' + name + '"]')?.remove(); });
|
||||||
document.querySelector('[data-name="' + name + '"]')?.remove();
|
}
|
||||||
});
|
|
||||||
|
function updateSetting(key, value) {
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'PUT', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({[key]: value})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert UTC timestamps to local time
|
|
||||||
document.querySelectorAll('[data-utc]').forEach(el => {
|
document.querySelectorAll('[data-utc]').forEach(el => {
|
||||||
const d = new Date(el.dataset.utc);
|
const d = new Date(el.dataset.utc);
|
||||||
el.textContent = d.toLocaleString();
|
el.textContent = d.toLocaleString();
|
||||||
@@ -359,11 +762,24 @@ if __name__ == "__main__":
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
PHOTOS_DIR = Path(args.photos_dir)
|
PHOTOS_DIR = Path(args.photos_dir)
|
||||||
|
DATA_DIR = Path(args.photos_dir) / ".data"
|
||||||
|
STATE_FILE = DATA_DIR / "frame_state.json"
|
||||||
|
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||||
|
IMAGE_SETTINGS_FILE = DATA_DIR / "image_settings.json"
|
||||||
|
|
||||||
if not PHOTOS_DIR.exists():
|
if not PHOTOS_DIR.exists():
|
||||||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Reload with correct paths
|
||||||
|
frame_state = _load_json(STATE_FILE, lambda: {
|
||||||
|
"last_update": None, "current_photo": None, "ip": None, "updates": 0
|
||||||
|
})
|
||||||
|
frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS))
|
||||||
|
image_settings = _load_json(IMAGE_SETTINGS_FILE, dict)
|
||||||
|
|
||||||
print(f"Serving photos from: {PHOTOS_DIR.resolve()}")
|
print(f"Serving photos from: {PHOTOS_DIR.resolve()}")
|
||||||
print(f"Found {len(get_photo_list())} photos")
|
print(f"Found {len(get_photo_list())} photos")
|
||||||
|
print(f"Orientation: {frame_settings['orientation']}, Default mode: {frame_settings['default_mode']}")
|
||||||
print(f"Listening on port {args.port}")
|
print(f"Listening on port {args.port}")
|
||||||
|
|
||||||
app.run(host="0.0.0.0", port=args.port)
|
app.run(host="0.0.0.0", port=args.port)
|
||||||
|
|||||||
Reference in New Issue
Block a user