315 lines
14 KiB
Markdown
315 lines
14 KiB
Markdown
# 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
|