First commit with entire first rendition of the project

This commit is contained in:
Your Name
2026-06-25 21:18:07 -06:00
commit 551322a94b
42 changed files with 4346 additions and 0 deletions

103
docs/display-orientation.md Normal file
View File

@@ -0,0 +1,103 @@
# Display Orientation
## Purpose
Allow the user to configure the physical orientation of the Inky Impression
display so that images are displayed upright regardless of how the panel is
mounted. The orientation setting also drives the Unsplash orientation filter so
fetched photos match the display's effective aspect ratio (portrait vs.
landscape).
## Configuration
A new `display.orientation` key (integer, degrees clockwise) in
`config.yaml` / `DEFAULT_CONFIG`:
```yaml
display:
orientation: 0 # 0, 90, 180, 270
```
| Value | Unsplash filter | Effective aspect |
|-------|----------------|------------------|
| 0 | landscape | width > height |
| 90 | portrait | height > width |
| 180 | landscape | width > height |
| 270 | portrait | height > width |
The old `unsplash.orientation` config key is **no longer used** by
`refresh()` — orientation is now derived from `display.orientation` +
`display.resolution`.
## Image Processing Pipeline
1. **Fetch** raw photo from Unsplash (with orientation filter already matching
the effective aspect ratio — see below).
2. **`process_image()`** in `InkyDisplay`:
- Compute effective width/height by swapping physical dimensions when
orientation is 90° or 270°.
- Crop to effective aspect ratio.
- Resize to effective dimensions.
- Rotate by `-orientation` degrees (so the result matches the physical
resolution of the display panel).
3. **`show()`** sends the processed (and rotated) image to `inky.set_image()`.
No additional transformation is needed — the panel hardware always expects
its native `width × height` pixel grid.
## Unsplash Filter Derivation
In `cli.py:refresh()`, after reading `display.orientation` and
`display.resolution`:
1. If orientation is 90 or 270, swap width ↔ height to get effective
dimensions.
2. If effective height > effective width → request `"portrait"`.
3. If effective width > effective height → request `"landscape"`.
4. If roughly equal → request `"squarish"`.
This means the Unsplash API is sent `orientation=portrait` when the display is
mounted vertically, which returns taller photos that need less cropping.
## Hot-Reload
`_reload_config()` in `cli.py` detects changes to
`display.orientation` and updates `display.orientation` live. The next
`process_image()` call uses the new orientation. No display loop restart is
needed.
## Library Caching
`_save_to_library()` passes the **effective** width/height to
`save_unsplash_image()` (swapped when orientation is 90/270). The stored image
is processed to the effective dimensions at orientation=0 (no rotation). This
keeps library images in their "natural" viewing orientation.
The preview endpoint (`/api/preview/<id>`) creates an `InkyDisplay` with the
current orientation, so the preview matches what the physical display shows.
## UI
Settings page (Display article) shows a `<select>` with four options:
- 0° (Landscape)
- 90° (Portrait)
- 180° (Landscape)
- 270° (Portrait)
Saved via the existing `PUT /api/config/display/orientation` route.
The old `unsplash.orientation` dropdown has been **removed** from the settings
page — it is now auto-derived from the display orientation.
## Edge Cases
- **0° (default):** No rotation applied. Preserves existing behavior.
- **180°:** Image is cropped/resized at physical dimensions, then rotated
upside-down. No dimension swap. Useful for ceiling-mounted displays.
- **90° / 270°:** Width and height are swapped for cropping/resizing, then
rotated back to physical dimensions. Images fetched from Unsplash with the
matching portrait/landscape filter.
- **Preview:** Shows the rotated image so the user sees exactly what the
display will show.
- **rotate(expand=True):** Only used with 90° multiples so no fractional
pixels appear.

146
docs/mqtt.md Normal file
View File

@@ -0,0 +1,146 @@
# MQTT Integration
Spectra can be controlled and monitored over MQTT. The MQTT client runs as a background thread in the display loop process (`spectra` service) and connects to any standard MQTT broker.
## Configuration
Enable MQTT in `config.yaml`:
```yaml
mqtt:
enabled: true
broker: 192.168.1.100
port: 1883
topic_prefix: spectra
client_id: spectra-display
username: ""
password: ""
```
- **enabled** — set to `true` to start the MQTT client
- **broker** — hostname or IP of your MQTT broker
- **port** — broker port (default: 1883)
- **topic_prefix** — prefix for all MQTT topics (default: `spectra`)
- **client_id** — unique client identifier for the MQTT connection
- **username / password** — optional credentials for authenticated brokers
Changes to MQTT settings are hot-reloaded by the display loop — no service restart needed. If you disable MQTT, the client disconnects cleanly.
## Topics
All topics use the configured `topic_prefix`. The examples below assume the default prefix `spectra`.
### Commands (subscribe)
Spectra subscribes to `spectra/command/#` and dispatches based on the command suffix.
| Topic | Payload | Action |
|-------|---------|--------|
| `spectra/command/refresh` | *(ignored)* | Triggers an Unsplash refresh on the next display cycle |
| `spectra/command/clear` | *(ignored)* | Clears the display to white on the next cycle |
| `spectra/command/show/<id>` | *(ignored)* | Shows a library image by its database ID |
| `spectra/command/status` | *(ignored)* | Publishes a fresh status message |
Example commands:
```bash
# Refresh from Unsplash
mosquitto_pub -h 192.168.1.100 -t "spectra/command/refresh" -n
# Clear the display
mosquitto_pub -h 192.168.1.100 -t "spectra/command/clear" -n
# Show a specific image from the library (ID 5)
mosquitto_pub -h 192.168.1.100 -t "spectra/command/show/5" -n
# Request a status update
mosquitto_pub -h 192.168.1.100 -t "spectra/command/status" -n
```
### Status (publish)
Spectra publishes JSON status messages to `spectra/status`.
**Connection event** (published on successful connect):
```json
{
"action": "connected",
"status": "ok"
}
```
**Command acknowledgements** (published after processing each command):
```json
{
"action": "refresh",
"status": "triggered"
}
```
On error (e.g. invalid image ID):
```json
{
"action": "show",
"status": "error",
"error": "image_not_found"
}
```
**Heartbeat** (published every 5 minutes):
```json
{
"action": "heartbeat",
"status": "running",
"timestamp": 1719334800.0
}
```
## Home Assistant integration
You can integrate Spectra into Home Assistant using MQTT. Add the following to your `configuration.yaml`:
```yaml
mqtt:
sensor:
- name: "Spectra Status"
state_topic: "spectra/status"
value_template: "{{ value_json.action }}"
button:
- name: "Spectra Refresh"
command_topic: "spectra/command/refresh"
payload_press: ""
- name: "Spectra Clear"
command_topic: "spectra/command/clear"
payload_press: ""
```
## Example: Node-RED flow
A Node-RED flow can listen for commands via HTTP (from a web dashboard) and forward them to MQTT, or forward MQTT status events to a database.
```json
[{"id":"spectra-mqtt","type":"mqtt in","topic":"spectra/command/#","name":"Spectra Commands"}]
```
Subscribe to `spectra/status` to build a real-time dashboard with display state, last action, and connectivity status.
## Debugging
Check the display service logs for MQTT connection events:
```bash
journalctl -u spectra -f | grep MQTT
```
A successful connection logs:
```
MQTT connected to 192.168.1.100:1883
MQTT subscribed to spectra/command/#
```
A failed connection logs the return code (see [MQTT spec](https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html#_Toc398718035) for rc meanings).

314
docs/web-interface.md Normal file
View 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