Files
spectra/docs/web-interface.md

315 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```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