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
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
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
- Web server writes trigger file via
write_trigger() (thread-safe with lock)
- Display loop checks trigger at top of each iteration via
handle_trigger()
- If trigger exists: execute action, then
clear_trigger() (delete file)
- If no trigger: default Unsplash refresh
Trigger file path resolution (in order of priority):
SPECTRA_CACHE_DIR environment variable
/var/cache/spectra/ (fallback if writable)
~/.cache/spectra/ (fallback for non-root/dev environments)
CLI
spectra (display — unchanged)
Full backward compatibility preserved. The display loop now additionally checks for trigger files before each refresh.
spectra web (web server — new)
Starts the Flask development server. Routes are registered inline in server.py via _register_routes().
systemd
spectra-web.service
spectra.service (unchanged)
Display loop — runs spectra without flags (continuous mode).
Dependencies
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:
- Inky library (Raspberry Pi only)
- Python dependencies (
pip install -r requirements.txt)
- Package install (
pip install -e .)
- Display service (copies unit, enables)
- 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)