# 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/
/` 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/
` | Single section (e.g. `display`, `schedule`) | | `PUT` | `/api/config/
/` | 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/` | Image metadata | | `DELETE` | `/api/images/` | Delete (removes DB record + file + rotation entries) | | `GET` | `/api/images//file` | Serve the image file | | `GET` | `/api/images//thumbnail` | Auto-generated 320×240 thumbnail | | `POST` | `/api/images//show` | Trigger display of this image | ### Display | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/preview/` | 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/` | Remove entry | | `PATCH` | `/api/rotation/` | 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` ```ini [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