First commit with entire first rendition of the project
This commit is contained in:
314
docs/web-interface.md
Normal file
314
docs/web-interface.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user