Files
spectra/docs/web-interface.md
Your Name 8015baa7b3
All checks were successful
Run tests / test (push) Successful in 25s
fix formatting in readme files
2026-06-25 21:41:30 -06:00

14 KiB
Raw Blame History

Spectra Web Interface

Status

Step Description Status Date
1 Scaffold: web module, Flask app factory, models, templates, CLI integration Done 2026-06-25
2 Config API: read/write config.yaml from web UI Done 2026-06-25
3 Image upload: endpoint, validation, gallery Done 2026-06-25
4 Display trigger: trigger file in cli.py, Show Now Done 2026-06-25
5 Rotation queue: select images, weight system Done 2026-06-25
6 Schedule editor: full schedule in UI Done 2026-06-25
7 systemd service + installer updates Done 2026-06-25
8 Polish: preview, responsiveness, error handling Done 2026-06-25
9 MQTT integration: commands, status, heartbeat Done 2026-06-25
10 Drag-and-drop multi-image upload Done 2026-06-25
11 Config hot-reload in display loop Done 2026-06-25
12 Unsplash image caching to library DB Done 2026-06-25

Architecture

                          Raspberry Pi
┌───────────────────────────────────────────────────────────┐
│                                                           │
│  ┌──────────┐    ┌──────────────┐    ┌─────────────────┐  │
│  │  Browser │◄──►│  Flask App   │───►│  Display Loop   │  │
│  │  (GUI)   │    │  (port 5000) │    │  (cli.py)       │  │
│  └──────────┘    │              │    │                 │  │
│                  │  /api/*      │    │  ┌───────────┐  │  │
│                  │  /gallery    │    │  │ fetcher   │  │  │
│                  │  /settings   │    │  │ display   │  │  │
│                  │  /upload     │    │  └───────────┘  │  │
│                  └──────┬───────┘    └────────┬────────┘  │
│                         │                     │           │
│                    ┌────▼─────┐         ┌─────▼──────┐    │
│                    │  SQLite  │         │ trigger    │    │
│                    │  web.db  │         │ .json      │    │
│                    └──────────┘         └────────────┘    │
│                         │                                 │
│                    ┌────▼─────┐                           │
│                    │ uploads/ │                           │
│                    │ (images) │                           │
│                    └──────────┘                           │
└───────────────────────────────────────────────────────────┘

Key decisions

Decision Choice Rationale
Web framework Flask Lightweight, zero-config, well-suited for embedded, huge ecosystem
Frontend Server-rendered HTML + htmx No build step, minimal CPU/RAM on Pi, dynamic UI with minimal JS
Styling Pico CSS (CDN) Minimal, responsive, classless CSS framework — no custom CSS needed for basics
Database SQLite via Flask-SQLAlchemy No daemon needed, single file, built into Python. ORM keeps code clean
Image validation Pillow verify() + extension whitelist Double-check: extension filter prevents upload of non-image files, Pillow verify catches corrupted data
Config persistence ConfigManager class wraps config.py Single point for read/write, falls back to ~/.config/spectra/config.yaml when /etc/ is not writable
Cache directory SPECTRA_CACHE_DIR env var, fallback ~/.cache/spectra/ Allows override in constrained environments, default works without root
Display loop integration File-based trigger (trigger.json) Minimal changes to existing loop, no DB dependency in the display loop, survives restarts
CLI integration spectra web subcommand via sys.argv check Preserves full backward compatibility — spectra --once still works unchanged
Config API PUT for individual keys PUT /api/config/<section>/<key> with {"value": ...} body Simple, type-safe, avoids parsing complex nested patches on the frontend

Module structure

spectra/
├── __init__.py
├── __main__.py
├── cli.py               # CLI entry point, display loop, trigger handling
├── config.py            # Config loader (read-only, shared)
├── config_manager.py    # NEW — read/write config.yaml (write support)
├── display.py           # Display abstraction (+ show_file, clear)
├── fetcher.py           # Unsplash API client
├── trigger.py           # Shared trigger file read/write (thread-safe)
├── mqtt.py              # MQTT client: commands + status publishing
├── library.py           # Unsplash image caching to SQLite library
├── web/
│   ├── __init__.py
│   ├── server.py        # Flask app factory + all routes
│   ├── models.py        # SQLAlchemy models (Image, Rotation, Setting)
│   ├── templates/
│   │   ├── base.html    # Layout with Pico CSS nav
│   │   ├── index.html   # Dashboard
│   │   ├── gallery.html # Image gallery
│   │   ├── upload.html  # Upload form
│   │   ├── settings.html# Settings editor
│   │   └── preview.html # Image preview
│   └── static/
│       ├── css/app.css
│       └── js/app.js
└── systemd/
    ├── spectra.service       # Existing — display loop
    └── spectra-web.service   # NEW — web server

Database

Managed by Flask-SQLAlchemy. Auto-creates on first create_app(). Three tables:

image

Column Type Notes
id INTEGER PK autoincrement
filename TEXT stored filename on disk
source TEXT 'upload' or 'unsplash'
unsplash_id TEXT nullable, Unsplash photo ID
title TEXT user-friendly title
author TEXT credit line
width INTEGER image pixel width
height INTEGER image pixel height
filepath TEXT absolute path to cached file
created_at DATETIME auto-set on creation
seen_count INTEGER times shown on display
last_shown DATETIME nullable, last display time

rotation

Column Type Notes
id INTEGER PK autoincrement
image_id INTEGER FK → image.id
weight INTEGER higher = shown more often
active BOOLEAN toggle on/off without removing
created_at DATETIME auto-set

setting

Column Type Notes
key TEXT PK setting name
value TEXT setting value (stringified)

API endpoints

Config

Method Path Description
GET /api/config Full config dict
PATCH /api/config Deep-merge changes into config and save
GET /api/config/<section> Single section (e.g. display, schedule)
PUT /api/config/<section>/<key> Set a single value ({"value": ...})

Images

Method Path Description
GET /api/images Paginated list (?page=&per_page=&source=)
POST /api/images Upload (multipart: file + title + author + show_now)
GET /api/images/<id> Image metadata
DELETE /api/images/<id> Delete (removes DB record + file + rotation entries)
GET /api/images/<id>/file Serve the image file
GET /api/images/<id>/thumbnail Auto-generated 320×240 thumbnail
POST /api/images/<id>/show Trigger display of this image

Display

Method Path Description
GET /api/preview/<id> E-paper processed preview (crop + resize applied)
POST /api/display/refresh Trigger Unsplash refresh
POST /api/display/clear Clear display (white)
GET /api/display/status Current state (simulation, resolution, pending trigger, last shown)

Rotation

Method Path Description
GET /api/rotation All rotation entries with nested image data
POST /api/rotation Add images ({"image_ids": [1,2,3]})
DELETE /api/rotation/<id> Remove entry
PATCH /api/rotation/<id> Update weight or active

Trigger mechanism

The display loop (cli.py) and web server (web/server.py) coordinate via a JSON trigger file at SPECTRA_CACHE_DIR/trigger.json.

Trigger actions

Action Payload Effect
refresh {"action": "refresh"} Next loop iteration fetches from Unsplash
show_upload {"action": "show_upload", "path": "...", "image_id": 1} Loads the given image file and displays it
clear {"action": "clear"} Sets display to blank white

Flow

  1. Web server writes trigger file via write_trigger() (thread-safe with lock)
  2. Display loop checks trigger at top of each iteration via handle_trigger()
  3. If trigger exists: execute action, then clear_trigger() (delete file)
  4. If no trigger: default Unsplash refresh

Trigger file path resolution (in order of priority):

  1. SPECTRA_CACHE_DIR environment variable
  2. /var/cache/spectra/ (fallback if writable)
  3. ~/.cache/spectra/ (fallback for non-root/dev environments)

CLI

spectra (display — unchanged)

spectra [-h] [-c CONFIG] [--once] [--simulate] [--width WIDTH] [--height HEIGHT] [-v]

Full backward compatibility preserved. The display loop now additionally checks for trigger files before each refresh.

spectra web (web server — new)

spectra web [-h] [-c CONFIG] [--host HOST] [--port PORT] [-v]

Starts the Flask development server. Routes are registered inline in server.py via _register_routes().


systemd

spectra-web.service

[Unit]
Description=Spectra Web Interface
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/spectra web
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

spectra.service (unchanged)

Display loop — runs spectra without flags (continuous mode).


Dependencies

# requirements.txt (additions)
Flask>=3.0.0
Flask-SQLAlchemy>=3.1.0

Flask and Flask-SQLAlchemy are the only new dependencies — Pico CSS and htmx are loaded from CDN (no npm/build step needed).


Installation

install.sh updated:

  1. Inky library (Raspberry Pi only)
  2. Python dependencies (pip install -r requirements.txt)
  3. Package install (pip install -e .)
  4. Display service (copies unit, enables)
  5. Web service (copies spectra-web.service, enables)

Security

  • Web server binds to 0.0.0.0:5000 by default (Pi's LAN)
  • No built-in authentication (recommend nginx reverse proxy + HTTP basic auth for production)
  • File uploads: extension whitelist + Pillow verify() — no arbitrary code execution
  • Uploaded images stored outside web root (/var/cache/spectra/uploads/ or ~/.cache/spectra/uploads/)
  • Unsplash API key stored in config (permissions on /etc/spectra/config.yaml or ~/.config/spectra/config.yaml)

Known limitations

  • No multi-user auth: The web server has no authentication. Recommended for LAN use only, or behind an nginx reverse proxy with HTTP basic auth.
  • Resolution changes need hardware re-init: If display resolution changes in the config while using real hardware (not simulation), the Inky display must be re-initialized. The display service picks up the new values but the hardware may not resize until next boot.

Implementation notes

  • Cache directory fallback: The project uses /var/cache/spectra/ when run as root (Raspberry Pi), and ~/.cache/spectra/ when run as a regular user (dev). Override with SPECTRA_CACHE_DIR env var.
  • Config write safety: ConfigManager resolves the config path by checking search paths in order, falling back to ~/.config/spectra/config.yaml for writes when none exist. Always uses os.path.abspath() to avoid directory resolution issues.
  • Image upload validation: Two-stage — extension check against whitelist ({png, jpg, jpeg, gif, bmp, webp}) then Pillow verify() to catch corrupted files. Max file size: 20MB.
  • Thumbnail generation: 320×240 PNG thumbnails generated on first request and cached alongside the source file as filename.thumb. Uses Image.thumbnail() for aspect-ratio preservation.
  • No CSRF protection: Current implementation has no CSRF tokens since htmx sends POST/DELETE requests. Add Flask-WTF or a simple token check before production deployment on a public network.
  • display.clear(): Creates a blank palette-mode ("P") white image and pushes it to the display. Avoids relying on internal _buf attributes that differ across Inky board revisions.

Future work (post-v1 — not implemented)

  • WebSocket push for live display status updates
  • S3/Nextcloud import sources
  • Multi-user auth via nginx reverse proxy
  • OTA software updates
  • Touchscreen kiosk mode (mirrors the display locally)
  • zrok/Cloudflare Tunnel for secure remote access