Compare commits
7 Commits
9d256788bc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3230ec5bb4 | ||
|
|
fa7efae346 | ||
|
|
0cd0d95612 | ||
|
|
82f43f75ba | ||
|
|
fb0980aa88 | ||
|
|
25cf4e3322 | ||
|
|
77db2bc565 |
@@ -16,6 +16,17 @@ LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
|
||||
# MQTT Broker Configuration
|
||||
MQTT_BROKER=<your-mqtt-broker-host-or-ip> # Change to your MQTT server IP
|
||||
MQTT_PORT=1883
|
||||
# Broker login used by simclient to connect to MQTT
|
||||
MQTT_USER=<broker-username>
|
||||
MQTT_PASSWORD_BROKER=<broker-password>
|
||||
# Optional per-device identity credentials (legacy fallback)
|
||||
MQTT_USERNAME=infoscreen-client-<client-uuid-prefix>
|
||||
MQTT_PASSWORD=<set-per-device-20-char-random-password>
|
||||
MQTT_TLS_ENABLED=0 # 1 when broker TLS is enabled for this client
|
||||
# MQTT_TLS_CA_CERT=/etc/infoscreen/mqtt/ca.crt
|
||||
# MQTT_TLS_CERT=/etc/infoscreen/mqtt/client.crt
|
||||
# MQTT_TLS_KEY=/etc/infoscreen/mqtt/client.key
|
||||
# MQTT_TLS_INSECURE=0 # only for controlled test environments
|
||||
|
||||
# Timing Configuration (quieter intervals for productive test)
|
||||
HEARTBEAT_INTERVAL=60 # Heartbeat frequency in seconds
|
||||
@@ -41,14 +52,32 @@ CEC_TURN_OFF_DELAY=30 # Seconds to wait before turning off TV after last
|
||||
CEC_POWER_ON_WAIT=5 # Seconds to wait after power ON command (for TV to boot up)
|
||||
CEC_POWER_OFF_WAIT=5 # Seconds to wait after power OFF command (increased for slower TVs)
|
||||
|
||||
# Optional: MQTT authentication (if your broker requires username/password)
|
||||
#MQTT_USERNAME=
|
||||
#MQTT_PASSWORD=
|
||||
# TV Power Intent (MQTT-based coordinated power control, Phase 1)
|
||||
# Controls how the display manager decides whether to use local CEC or server-issued intent.
|
||||
# local — ignore MQTT intents; all power decisions are local (safe default for rollout)
|
||||
# hybrid — prefer MQTT intent when present and valid; fall back to local CEC if not
|
||||
# mqtt — MQTT intent is authoritative; local CEC only fires as last-resort guard
|
||||
# See README.md "TV Power Intent — Rollout Runbook" before changing from 'local'.
|
||||
POWER_CONTROL_MODE=hybrid # local | hybrid | mqtt
|
||||
|
||||
# Reboot/Shutdown command handling
|
||||
# Helper installed by ./scripts/install-command-helper.sh
|
||||
COMMAND_HELPER_PATH=/usr/local/bin/infoscreen-cmd-helper.sh
|
||||
# Mock mode (safe canary): uncomment next line and comment the live path above
|
||||
# COMMAND_HELPER_PATH=/home/olafn/infoscreen-dev/scripts/mock-command-helper.sh
|
||||
# Timeout for helper execution (seconds)
|
||||
COMMAND_EXEC_TIMEOUT_SEC=15
|
||||
# Test mode: for reboot_host with mock helper, send completed without restart (0/1)
|
||||
COMMAND_MOCK_REBOOT_IMMEDIATE_COMPLETE=0
|
||||
# Command deduplication retention window (hours)
|
||||
COMMAND_DEDUPE_TTL_HOURS=24
|
||||
# Maximum processed command IDs kept in dedupe cache
|
||||
COMMAND_DEDUPE_MAX_ENTRIES=5000
|
||||
|
||||
# MQTT authentication
|
||||
# Use a per-client service account. Keep this file mode 600 on the device.
|
||||
|
||||
# Optional TLS settings (if using secure MQTT)
|
||||
#MQTT_TLS_CA_CERT=
|
||||
#MQTT_TLS_CERT=
|
||||
#MQTT_TLS_KEY=
|
||||
|
||||
# Notes:
|
||||
# - Keep actual secrets and host-specific values in a local .env file that is NOT committed.
|
||||
|
||||
643
.github/copilot-instructions.md
vendored
643
.github/copilot-instructions.md
vendored
@@ -1,575 +1,128 @@
|
||||
# Copilot Instructions - Infoscreen Client
|
||||
|
||||
## Quick Start for AI Assistants
|
||||
## Purpose
|
||||
This file defines durable, high-signal instructions for AI assistants working in this repository.
|
||||
|
||||
### Critical Rules
|
||||
- ✅ **ALWAYS use Impressive** for PDF presentations (has native auto-advance/loop)
|
||||
- ❌ **NEVER suggest xdotool** approaches (failed on Raspberry Pi due to focus issues)
|
||||
- ❌ **NEVER suggest video conversion** (adds complexity, had black screen issues)
|
||||
- ✅ **Virtual environment MUST have** pygame + pillow (required for Impressive)
|
||||
- ✅ **Client-side resize/compress** screenshots before MQTT transmission
|
||||
- ✅ **Server renders PPTX → PDF via Gotenberg** (client only displays PDFs, no LibreOffice needed)
|
||||
- ✅ **Keep screenshot consent notice in docs** when describing dashboard screenshot feature
|
||||
- ✅ **Event-start/event-stop screenshots must preserve metadata** - See SCREENSHOT_MQTT_FIX.md for critical race condition that was fixed
|
||||
- ✅ **Screenshot updates must keep `latest.jpg` and `meta.json` in sync** (simclient prefers `latest.jpg`)
|
||||
## Instruction File Design Rules
|
||||
|
||||
### Key Files & Locations
|
||||
- **Display logic**: `src/display_manager.py` (controls presentations/video/web)
|
||||
- **MQTT client**: `src/simclient.py` (event management, heartbeat, discovery)
|
||||
- **Runtime state**: `src/current_event.json` (current active event)
|
||||
- **Process health bridge**: `src/current_process_health.json` (display_manager -> simclient)
|
||||
- **Config**: `src/config/client_uuid.txt`, `src/config/last_group_id.txt`, `.env`
|
||||
- **Logs**: `logs/display_manager.log`, `logs/simclient.log`, `logs/monitoring.log`
|
||||
- **Screenshots**: `src/screenshots/` (shared volume between processes)
|
||||
Treat this file as policy, not as project handbook.
|
||||
|
||||
### Common Tasks Quick Reference
|
||||
| Task | File | Key Method/Section |
|
||||
|------|------|-------------------|
|
||||
| Add event type | `display_manager.py` | `start_display_for_event()` |
|
||||
| Modify presentation | `display_manager.py` | `start_presentation()` |
|
||||
| Modify process monitoring | `display_manager.py` | `ProcessHealthState`, `process_events()` |
|
||||
| Publish health/log topics | `simclient.py` | `read_health_state()`, `publish_health_message()`, `publish_log_message()` |
|
||||
| Change MQTT topics | `simclient.py` | Topic constants/handlers |
|
||||
| Update screenshot | `display_manager.py` | `_capture_screenshot()` |
|
||||
| File downloads | `simclient.py` | `resolve_file_url()` |
|
||||
- Scope rule: keep only durable constraints, architectural invariants, and high-value task pointers for assistants.
|
||||
- Size rule: target 80-140 lines; hard cap 180 lines.
|
||||
- Canonical-doc rule: link to specialist docs for operational depth instead of copying their content.
|
||||
- Single-source rule: each topic has one canonical document; this file should only reference it.
|
||||
- No shadow-README rule: do not add long setup guides, full command catalogs, troubleshooting playbooks, or large directory trees.
|
||||
|
||||
---
|
||||
Allowed content:
|
||||
|
||||
## Project Overview
|
||||
**Infoscreen Client** - Digital signage system for Raspberry Pi. Displays presentations, videos, and web content in kiosk mode. Server-managed via MQTT for educational/research environments with multiple displays.
|
||||
- Critical do/don't rules.
|
||||
- Short architecture snapshot.
|
||||
- Runtime coordination file map.
|
||||
- Minimal task pointers to key methods.
|
||||
- Documentation policy for where detailed content belongs.
|
||||
|
||||
**Architecture**: Two-process design
|
||||
- `simclient.py` - MQTT communication (container/native)
|
||||
- `display_manager.py` - Display control (host OS with X11/Wayland access)
|
||||
Disallowed content:
|
||||
|
||||
## Architecture & Technology Stack
|
||||
- Comprehensive installation/deployment tutorials.
|
||||
- Large environment-variable reference sections.
|
||||
- Extended troubleshooting matrices.
|
||||
- Repeated feature deep-dives already documented elsewhere.
|
||||
- Historical release notes (keep those in `CHANGELOG.md`).
|
||||
|
||||
### Core Technologies
|
||||
- **Python 3.x** - Main application language
|
||||
- **MQTT (paho-mqtt)** - Real-time messaging with server
|
||||
- **Impressive** - PDF presenter with native auto-advance and loop support
|
||||
- **Environment Variables** - Configuration management via `.env` files
|
||||
- **JSON** - Data exchange format for events and configuration
|
||||
- **Base64** - Screenshot transmission encoding
|
||||
- **Threading** - Background services (screenshot monitoring)
|
||||
Update checklist for contributors:
|
||||
|
||||
### System Components
|
||||
- **Main Client** (`simclient.py`) - Core MQTT client and event processor
|
||||
- **Display Manager** (`display_manager.py`) - Controls display applications (presentations, videos, web)
|
||||
- **Discovery System** - Automatic client registration with server
|
||||
- **Heartbeat Monitoring** - Regular status updates and keepalive
|
||||
- **Event Processing** - Handles presentation/content switching commands
|
||||
- **Screenshot Service** - Dashboard monitoring via image capture (captured by display_manager.py, transmitted by simclient.py)
|
||||
- **File Management** - Downloads and manages presentation files
|
||||
- **Group Management** - Supports organizing clients into groups
|
||||
1. Is the new text a durable assistant rule or invariant?
|
||||
2. If it is operational detail, did you place it in the specialist doc and only link it here?
|
||||
3. Did you avoid duplicating existing docs?
|
||||
4. Does this file remain below the hard cap?
|
||||
|
||||
## Key Features & Functionality
|
||||
Use specialist docs for deep operational details:
|
||||
|
||||
### MQTT Communication Patterns
|
||||
- **Discovery**: `infoscreen/discovery` → `infoscreen/{client_id}/discovery_ack`
|
||||
- **Heartbeat**: Regular `infoscreen/{client_id}/heartbeat` messages
|
||||
- **Health**: `infoscreen/{client_id}/health` (event/process/pid/status)
|
||||
- **Client logs**: `infoscreen/{client_id}/logs/error|warn` (selective forwarding)
|
||||
### MQTT Reconnection & Heartbeat (Nov 2025)
|
||||
- The client uses Paho MQTT v2 callback API with `client.loop_start()` and `client.reconnect_delay_set()` to handle automatic reconnection.
|
||||
- `on_connect` re-subscribes to all topics (`discovery_ack`, `config`, `group_id`, current group events) and re-sends discovery on reconnect to re-register with the server.
|
||||
- Heartbeats are gated by `client.is_connected()` and retry once on `NO_CONN` (rc=4). Occasional rc=4 warnings are normal right after broker restarts or brief network stalls and typically followed by a successful heartbeat.
|
||||
- Do not treat single rc=4 heartbeat warnings as failures. Investigate only if multiple consecutive heartbeats fail without recovery.
|
||||
- **Dashboard**: Screenshot transmission via `infoscreen/{client_id}/dashboard` (includes base64-encoded screenshot, timestamp, client status, system info)
|
||||
- **Group Assignment**: Server sends group via `infoscreen/{client_id}/group_id`
|
||||
- **Events**: Content commands via `infoscreen/events/{group_id}`
|
||||
- `README.md` (landing page + docs map)
|
||||
- `TV_POWER_RUNBOOK.md` (TV power rollout and canary)
|
||||
- `TV_POWER_INTENT_SERVER_CONTRACT_V1.md` (frozen contract)
|
||||
- `IMPRESSIVE_INTEGRATION.md` (presentation behavior)
|
||||
- `HDMI_CEC_SETUP.md` (CEC setup/troubleshooting)
|
||||
- `SCREENSHOT_MQTT_FIX.md` (screenshot race-condition fixes)
|
||||
- `src/README.md` (developer-focused architecture/debugging)
|
||||
- `SERVER_TEAM_ACTIONS.md` (server-side integration action items)
|
||||
|
||||
### Event Types Supported
|
||||
```json
|
||||
{
|
||||
"presentation": {
|
||||
"files": [{"url": "https://server/file.pptx", "filename": "file.pptx"}],
|
||||
"auto_advance": true,
|
||||
"slide_interval": 10,
|
||||
"loop": true
|
||||
},
|
||||
"web": {
|
||||
"url": "https://example.com"
|
||||
},
|
||||
"video": {
|
||||
"url": "https://server/video.mp4",
|
||||
"loop": false,
|
||||
"autoplay": true,
|
||||
"volume": 0.8
|
||||
}
|
||||
}
|
||||
```
|
||||
## Critical Rules
|
||||
|
||||
### Presentation System (Impressive-Based)
|
||||
- **Server-side conversion**: PPTX files are converted to PDF by the server using Gotenberg
|
||||
- **Client receives PDFs**: All presentations arrive as pre-rendered PDF files
|
||||
- **Direct display**: PDF files are displayed natively with Impressive (no client-side conversion)
|
||||
- **Auto-advance**: Native Impressive `--auto` parameter (no xdotool needed)
|
||||
- **Loop mode**: Impressive `--wrap` parameter for infinite looping
|
||||
- **Auto-quit**: Impressive `--autoquit` parameter to exit after last slide
|
||||
- **Virtual Environment**: Uses venv with pygame + pillow for reliable operation
|
||||
- **Reliable**: Works consistently on Raspberry Pi without window focus issues
|
||||
- ALWAYS use Impressive for PDF presentations.
|
||||
- NEVER suggest xdotool-based slideshow control.
|
||||
- NEVER suggest converting presentations to video as a workaround.
|
||||
- Virtual environment must include `pygame` and `pillow` for Impressive.
|
||||
- Keep screenshot consent notice in docs when describing dashboard screenshots.
|
||||
- Keep screenshot updates consistent between `latest.jpg` and `meta.json`.
|
||||
- Event-trigger screenshots must preserve metadata and send quickly (`send_immediately=true`).
|
||||
- Dashboard payload must stay grouped v2 (`message/content/runtime/metadata`, `schema_version="2.0"`).
|
||||
- Dashboard payload assembly is centralized in `_build_dashboard_payload()`.
|
||||
- Root `README.md` is a landing page; do not re-expand it into a full manual.
|
||||
- TV power rollout guidance lives in `TV_POWER_RUNBOOK.md`.
|
||||
- TV power contract truth lives in `TV_POWER_INTENT_SERVER_CONTRACT_V1.md`.
|
||||
- `MQTT_USER`/`MQTT_PASSWORD_BROKER` are broker login credentials; `MQTT_USERNAME`/`MQTT_PASSWORD` are legacy identity fields. Never confuse the two.
|
||||
|
||||
### Client Identification
|
||||
- **Hardware Token**: SHA256 hash of serial number + MAC addresses
|
||||
- **Persistent UUID**: Stored in `config/client_uuid.txt`
|
||||
- **Group Membership**: Persistent group assignment in `config/last_group_id.txt`
|
||||
## Architecture Snapshot
|
||||
|
||||
## Directory Structure
|
||||
```
|
||||
~/infoscreen-dev/
|
||||
├── .env # Environment configuration
|
||||
├── README.md # Complete project documentation
|
||||
├── IMPRESSIVE_INTEGRATION.md # Presentation system details
|
||||
├── QUICK_REFERENCE.md # Quick command reference
|
||||
├── .github/ # GitHub configuration
|
||||
│ └── copilot-instructions.md
|
||||
├── src/ # Source code
|
||||
│ ├── simclient.py # MQTT client (event management)
|
||||
│ ├── display_manager.py # Display controller (Impressive integration)
|
||||
│ ├── current_event.json # Current active event (runtime)
|
||||
│ ├── config/ # Persistent client data
|
||||
│ │ ├── client_uuid.txt
|
||||
│ │ └── last_group_id.txt
|
||||
│ ├── presentation/ # Downloaded presentation files & PDFs
|
||||
│ └── screenshots/ # Screenshot captures for monitoring
|
||||
├── scripts/ # Production & testing utilities
|
||||
│ ├── start-dev.sh # Start development client
|
||||
│ ├── start-display-manager.sh # Start Display Manager
|
||||
│ ├── test-display-manager.sh # Interactive testing menu
|
||||
│ ├── test-impressive.sh # Test Impressive (auto-quit)
|
||||
│ ├── test-impressive-loop.sh # Test Impressive (loop mode)
|
||||
│ ├── test-mqtt.sh # MQTT connectivity test
|
||||
│ ├── test-screenshot.sh # Screenshot capture test
|
||||
│ └── present-pdf-auto-advance.sh # PDF presentation wrapper
|
||||
├── logs/ # Application logs
|
||||
│ ├── simclient.log
|
||||
│ └── display_manager.log
|
||||
└── venv/ # Python virtual environment
|
||||
```
|
||||
Two-process design:
|
||||
|
||||
## Configuration & Environment Variables
|
||||
- `src/simclient.py`: MQTT communication, discovery, group assignment, event intake, heartbeat, dashboard publish, power intent ingestion, remote command intake.
|
||||
- `src/display_manager.py`: content display lifecycle, HDMI-CEC, screenshot capture, runtime process health.
|
||||
|
||||
### Development vs Production
|
||||
- **Development**: `ENV=development`, verbose logging, frequent heartbeats
|
||||
- **Production**: `ENV=production`, minimal logging, longer intervals
|
||||
Runtime coordination files:
|
||||
|
||||
HDMI-CEC behavior:
|
||||
- In development mode (`ENV=development`) the Display Manager automatically disables HDMI-CEC to avoid constantly switching the TV during local testing. The test helper `scripts/test-hdmi-cec.sh` also respects this: option 5 (Display Manager CEC integration) detects dev mode and skips running CEC commands. Manual options (1–4) still work for direct `cec-client` checks.
|
||||
- `src/current_event.json` (active event)
|
||||
- `src/current_process_health.json` (health bridge)
|
||||
- `src/power_intent_state.json` (simclient -> display_manager)
|
||||
- `src/power_state.json` (display_manager -> simclient -> MQTT)
|
||||
- `src/screenshots/meta.json` and `src/screenshots/latest.jpg`
|
||||
|
||||
### Key Environment Variables
|
||||
```bash
|
||||
# Environment
|
||||
ENV=development|production
|
||||
DEBUG_MODE=1|0
|
||||
LOG_LEVEL=DEBUG|INFO|WARNING|ERROR
|
||||
## TV Power Coordination Rules
|
||||
|
||||
# MQTT Configuration
|
||||
MQTT_BROKER=192.168.1.100 # Primary MQTT broker
|
||||
MQTT_PORT=1883 # MQTT port
|
||||
MQTT_BROKER_FALLBACKS=host1,host2 # Fallback brokers
|
||||
- `POWER_CONTROL_MODE` supports: `local`, `hybrid`, `mqtt`.
|
||||
- Phase 1 intent topic is group-scoped: `infoscreen/groups/{group_id}/power/intent`.
|
||||
- In hybrid mode, valid fresh MQTT intent is preferred with local fallback behavior.
|
||||
- Retained clear is an empty payload and should be handled cleanly (not as broken JSON).
|
||||
- Use `scripts/test-power-intent.sh` for ON/OFF, stale, malformed, retained-clear, and telemetry checks.
|
||||
|
||||
# Timing (seconds)
|
||||
HEARTBEAT_INTERVAL=10 # Status update frequency
|
||||
SCREENSHOT_INTERVAL=30 # Dashboard screenshot transmission frequency (simclient.py)
|
||||
SCREENSHOT_CAPTURE_INTERVAL=30 # Screenshot capture frequency (display_manager.py)
|
||||
## HDMI-CEC Rules
|
||||
|
||||
# Screenshot Configuration
|
||||
SCREENSHOT_MAX_WIDTH=800 # Downscale width (preserves aspect ratio)
|
||||
SCREENSHOT_JPEG_QUALITY=70 # JPEG compression quality (1-95)
|
||||
SCREENSHOT_MAX_FILES=20 # Number of screenshots to keep (rotation)
|
||||
SCREENSHOT_ALWAYS=0 # Force capture even when no display active (testing)
|
||||
|
||||
# File/API Server (used to download presentation files)
|
||||
# Defaults to the same host as MQTT_BROKER, port 8000, scheme http.
|
||||
# If incoming event URLs use host 'server' (or are host-less), simclient rewrites them to this server.
|
||||
FILE_SERVER_HOST= # optional; if empty, defaults to MQTT_BROKER
|
||||
FILE_SERVER_PORT=8000 # default API port
|
||||
FILE_SERVER_SCHEME=http # http or https
|
||||
# FILE_SERVER_BASE_URL= # optional full override, e.g., http://192.168.1.100:8000
|
||||
```
|
||||
- In `ENV=development`, display manager automatically disables CEC.
|
||||
- `scripts/test-hdmi-cec.sh` integration path respects development mode; manual CEC options still work.
|
||||
- Keep delayed turn-off behavior safe across adjacent events.
|
||||
|
||||
### File Server URL Resolution
|
||||
- The MQTT client (`simclient.py`) downloads presentation files listed in events.
|
||||
- To avoid DNS issues when event URLs use `http://server:8000/...`, the client normalizes such URLs to the configured file server.
|
||||
- By default, the file server host is the same as `MQTT_BROKER`, with port `8000` and scheme `http`.
|
||||
- You can override behavior using `.env` variables above; `FILE_SERVER_BASE_URL` takes precedence over individual host/port/scheme.
|
||||
- Inline comments in `.env` are supported; keep comments after a space and `#` so values stay clean.
|
||||
## Screenshot System Rules
|
||||
|
||||
## Development Patterns & Best Practices
|
||||
- Capture is performed by `display_manager.py`; transmission by `simclient.py`.
|
||||
- Keep event-trigger screenshot behavior intact (`event_start` / `event_stop`).
|
||||
- Maintain one-second responsiveness for triggered send handling.
|
||||
- Prefer `latest.jpg` for dashboard transmission, with safe fallback to newest timestamped file.
|
||||
|
||||
### Error Handling
|
||||
- Robust MQTT connection with fallbacks and retries
|
||||
- Graceful degradation when services unavailable
|
||||
- Comprehensive logging with rotating file handlers
|
||||
- Exception handling for all external operations
|
||||
## Common Task Pointers
|
||||
|
||||
### State Management
|
||||
- Event state persisted in `current_event.json`
|
||||
- Client configuration persisted across restarts
|
||||
- Group membership maintained with server synchronization
|
||||
- Clean state transitions (delete old events on group changes)
|
||||
- Add event type: `src/display_manager.py` -> `start_display_for_event()`
|
||||
- Presentation behavior: `src/display_manager.py` -> `start_presentation()`
|
||||
- Power intent validation: `src/simclient.py` -> `validate_power_intent_payload()`
|
||||
- Power intent application: `src/display_manager.py` -> `_apply_mqtt_power_intent()`
|
||||
- Screenshot capture logic: `src/display_manager.py` -> `_capture_screenshot()`
|
||||
- Dashboard payload: `src/simclient.py` -> `_build_dashboard_payload()`
|
||||
- Remote command intake: `src/simclient.py` -> `on_command_message()`
|
||||
- Command validation: `src/simclient.py` -> `validate_command_payload()`
|
||||
- File URL rewriting: `src/simclient.py` -> `resolve_file_url()`
|
||||
|
||||
## Documentation Policy
|
||||
|
||||
### Threading Architecture
|
||||
- Main thread: MQTT communication and heartbeat
|
||||
- Background thread: Screenshot monitoring service
|
||||
- Thread-safe operations for shared resources
|
||||
When updating docs:
|
||||
|
||||
- Keep `README.md` concise and link-heavy.
|
||||
- Put rollout/runbook content into specialist docs (for example `TV_POWER_RUNBOOK.md`).
|
||||
- Keep implementation history in `CHANGELOG.md`.
|
||||
- Prefer updating one canonical doc per topic instead of duplicating the same content in multiple files.
|
||||
|
||||
### File Operations
|
||||
- Automatic directory creation for all output paths
|
||||
- Safe file operations with proper exception handling
|
||||
- Atomic writes for configuration files
|
||||
- Automatic cleanup of temporary/outdated files
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Local Development Setup
|
||||
1. Clone repository to `~/infoscreen-dev`
|
||||
2. Create virtual environment: `python3 -m venv venv`
|
||||
3. Install dependencies: `pip install -r src/requirements.txt` (includes pygame + pillow for PDF slideshows)
|
||||
4. Configure `.env` file with MQTT broker settings
|
||||
5. Use `./scripts/start-dev.sh` for MQTT client or `./scripts/start-display-manager.sh` for display manager
|
||||
6. **Important**: Virtual environment must include pygame and pillow for PDF auto-advance to work
|
||||
|
||||
### Testing Components
|
||||
- `./scripts/test-mqtt.sh` - MQTT connectivity
|
||||
- `./scripts/test-screenshot.sh` - Screenshot capture
|
||||
- `./scripts/test-display-manager.sh` - Interactive testing menu
|
||||
- `./scripts/test-impressive.sh` - Test auto-quit presentation mode
|
||||
- `./scripts/test-impressive-loop.sh` - Test loop presentation mode
|
||||
- `./scripts/test-utc-timestamps.sh` - Event timing validation
|
||||
- Manual event testing via mosquitto_pub or test-display-manager.sh
|
||||
|
||||
### Production Deployment
|
||||
- Docker containerization available (`docker-compose.production.yml`)
|
||||
- Systemd service integration for auto-start
|
||||
- Resource limits and health checks configured
|
||||
- Persistent volume mounts for data
|
||||
### System Dependencies
|
||||
- Python 3.x runtime + virtual environment
|
||||
- MQTT broker connectivity
|
||||
- Display server: X11 or Wayland (for screenshots)
|
||||
- **Impressive** - PDF presenter (primary tool, requires pygame + pillow in venv)
|
||||
- **Chromium/Chrome** - Web kiosk mode
|
||||
- **VLC** - Video playback (python-vlc preferred, vlc binary fallback)
|
||||
- **Screenshot tools**:
|
||||
- X11: `scrot` or `import` (ImageMagick) or `xwd`+`convert`
|
||||
- Wayland: `grim` or `gnome-screenshot` or `spectacle`
|
||||
|
||||
**Note:** LibreOffice is NOT required on the client. PPTX→PDF conversion is handled server-side by Gotenberg.
|
||||
|
||||
### Video Playback (python-vlc)
|
||||
- **Preferred**: python-vlc (programmatic control: autoplay, loop, volume)
|
||||
- **Fallback**: External vlc binary
|
||||
- **Fields**: `url`, `autoplay` (bool), `loop` (bool), `volume` (0.0-1.0 → 0-100)
|
||||
- **URL rewriting**: `server` host → configured file server
|
||||
- **Fullscreen**: enforced for python-vlc on startup (with short retry toggles); external fallback uses `--fullscreen`
|
||||
- **External VLC audio**: `muted=true` (or effective volume 0%) starts with `--no-audio`; otherwise startup loudness is applied via `--gain=<0.00-1.00>`
|
||||
- **Runtime volume semantics**: python-vlc supports live updates; external VLC fallback is startup-parameter based
|
||||
- **Monitoring PID semantics**: python-vlc runs in-process, so PID is `display_manager.py` runtime PID; external fallback uses external `vlc` PID
|
||||
- **HW decode errors**: `h264_v4l2m2m` failures are normal if V4L2 M2M unavailable; use software decode
|
||||
- Robust payload parsing with fallbacks
|
||||
- Topic-specific message handlers
|
||||
- Retained message support where appropriate
|
||||
|
||||
### Logging & Timestamp Policy (Mar 2026)
|
||||
- Client logs are standardized to UTC with `Z` suffix to avoid DST/localtime drift.
|
||||
- Applies to `display_manager.log`, `simclient.log`, and `monitoring.log`.
|
||||
- MQTT payload timestamps for heartbeat/dashboard/health/log messages are UTC ISO timestamps.
|
||||
- Screenshot metadata timestamps included by `simclient.py` are UTC ISO timestamps.
|
||||
- Prefer UTC-aware calls (`datetime.now(timezone.utc)`) and UTC log formatters for new code.
|
||||
|
||||
## Hardware Considerations
|
||||
|
||||
### Target Platform
|
||||
- **Primary**: Raspberry Pi 4/5 with desktop environment
|
||||
- **Storage**: SSD recommended for performance
|
||||
- **Display**: HDMI output for presentation display
|
||||
- **Network**: WiFi or Ethernet connectivity required
|
||||
|
||||
### System Dependencies
|
||||
- Python 3.x runtime
|
||||
- Network connectivity for MQTT
|
||||
- Display server (X11 or Wayland) for screenshot capture
|
||||
- **Impressive** - PDF presenter with auto-advance (primary presentation tool)
|
||||
- **pygame** - Required for Impressive (installed in venv)
|
||||
- **Pillow/PIL** - Required for Impressive PDF rendering (installed in venv)
|
||||
- Chromium/Chrome - Web content display (kiosk mode)
|
||||
- VLC or MPV - Video playback
|
||||
|
||||
**Note:** LibreOffice is NOT needed on the client. The server converts PPTX to PDF using Gotenberg.
|
||||
|
||||
### Video playback details (python-vlc)
|
||||
|
||||
- The Display Manager now prefers using python-vlc (libvlc) when available for video playback. This enables programmatic control (autoplay, loop, volume) and cleaner termination/cleanup. If python-vlc is not available, the external `vlc` binary is used as a fallback.
|
||||
- Supported video event fields: `url`, `autoplay` (boolean), `loop` (boolean), `volume` (float 0.0-1.0). The manager converts `volume` to VLC's 0-100 scale.
|
||||
- External VLC fallback applies audio at startup: `--no-audio` when muted/effective 0%, otherwise `--gain` from effective volume.
|
||||
- Live volume adjustments are reliable in python-vlc mode; external VLC fallback uses startup parameters and should be treated as static per launch.
|
||||
- URLs using the placeholder host `server` (for example `http://server:8000/...`) are rewritten to the configured file server before playback. The resolution priority is: `FILE_SERVER_BASE_URL` > `FILE_SERVER_HOST` (or `MQTT_BROKER`) + `FILE_SERVER_PORT` + `FILE_SERVER_SCHEME`.
|
||||
- Hardware-accelerated decoding errors (e.g., `h264_v4l2m2m`) may appear when the platform does not expose a V4L2 M2M device. To avoid these errors the Display Manager can be configured to disable hw-decoding (see README env var `VLC_HW_ACCEL`). By default the manager will attempt hw-acceleration when libvlc supports it.
|
||||
- Fullscreen / kiosk: the manager will attempt to make libVLC windows fullscreen (remove decorations) when using python-vlc, and the README contains recommended system-level kiosk/session setup for a truly panel-free fullscreen experience.
|
||||
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
### Data Protection
|
||||
- Hardware identification via cryptographic hash
|
||||
- No sensitive data in plain text logs
|
||||
- Local storage of minimal required data only
|
||||
- Secure MQTT communication (configurable)
|
||||
|
||||
### Network Security
|
||||
- Configurable MQTT authentication (if broker requires)
|
||||
- Firewall-friendly design (outbound connections only)
|
||||
- Multiple broker fallback for reliability
|
||||
|
||||
## Presentation System Architecture
|
||||
|
||||
### How It Works
|
||||
1. **Server-side Conversion** → Server converts PPTX to PDF using Gotenberg
|
||||
2. **Event Received** → Client receives event with pre-rendered PDF file reference
|
||||
3. **Download PDF** → Client downloads PDF from file server
|
||||
4. **Cache PDF** → Downloaded PDF stored in `presentation/` directory
|
||||
5. **Display with Impressive** → Launch with venv environment and parameters:
|
||||
- `--fullscreen` - Full screen mode
|
||||
- `--nooverview` - No slide overview
|
||||
- `--auto N` - Auto-advance every N seconds
|
||||
- `--wrap` - Loop infinitely (if `loop: true`)
|
||||
- `--autoquit` - Exit after last slide (if `loop: false`)
|
||||
|
||||
### Key Parameters
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `auto_advance` | boolean | `false` | Enable automatic slide advancement |
|
||||
| `slide_interval` | integer | `10` | Seconds between slides |
|
||||
| `loop` | boolean | `false` | Loop presentation vs. quit after last slide |
|
||||
|
||||
### Why Impressive?
|
||||
- ✅ **Native auto-advance** - No xdotool or window management hacks
|
||||
- ✅ **Built-in loop support** - Reliable `--wrap` parameter
|
||||
- ✅ **Works on Raspberry Pi** - No focus/window issues
|
||||
- ✅ **Simple integration** - Clean command-line interface
|
||||
- ✅ **Maintainable** - ~50 lines of code vs. 200+ with xdotool approaches
|
||||
|
||||
### Implementation Location
|
||||
- **File**: `src/display_manager.py`
|
||||
- **Method**: `start_presentation()`
|
||||
- **Key Logic**:
|
||||
1. Receive event with PDF file reference (server already converted PPTX)
|
||||
2. Download PDF file if not cached
|
||||
3. Set up virtual environment for Impressive (pygame + pillow)
|
||||
4. Build Impressive command with appropriate parameters
|
||||
5. Launch process and monitor
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
When working on this codebase:
|
||||
|
||||
1. **Adding new event types**: Extend the event processing logic in `display_manager.py` → `start_display_for_event()`
|
||||
2. **Modifying presentation behavior**: Update `display_manager.py` → `start_presentation()`
|
||||
3. **Configuration changes**: Update environment variable parsing and validation
|
||||
4. **MQTT topics**: Follow the established `infoscreen/` namespace pattern
|
||||
5. **Error handling**: Always include comprehensive logging and graceful fallbacks
|
||||
6. **State persistence**: Use the established `config/` directory pattern
|
||||
7. **Testing**: Use `./scripts/test-display-manager.sh` for interactive testing
|
||||
8. **Presentation testing**: Use `./scripts/test-impressive*.sh` scripts
|
||||
9. **File download host resolution**: If the API server differs from the MQTT broker or uses HTTPS, set `FILE_SERVER_*` in `.env` or adjust `resolve_file_url()` in `src/simclient.py`.
|
||||
|
||||
## Troubleshooting Guidelines
|
||||
|
||||
### Common Issues
|
||||
- **MQTT Connection**: Check broker reachability, try fallback brokers
|
||||
- **Screenshots**: Verify display environment and permissions
|
||||
- **File Downloads**: Check network connectivity and disk space
|
||||
- If event URLs use host `server` and DNS fails, the client rewrites to `MQTT_BROKER` by default.
|
||||
- Ensure `MQTT_BROKER` points to the correct server IP; if the API differs, set `FILE_SERVER_HOST` or `FILE_SERVER_BASE_URL`.
|
||||
- Match scheme/port via `FILE_SERVER_SCHEME`/`FILE_SERVER_PORT` for HTTPS or non-default ports.
|
||||
- **Group Changes**: Monitor log for group assignment messages
|
||||
- **Service Startup**: Check systemd logs and environment configuration
|
||||
|
||||
### Debugging Tools
|
||||
- Log files in `logs/simclient.log` and `logs/display_manager.log` with rotation
|
||||
- MQTT message monitoring with mosquitto_sub
|
||||
- Interactive testing menu: `./scripts/test-display-manager.sh`
|
||||
- Component test scripts: `test-impressive*.sh`, `test-mqtt.sh`, etc.
|
||||
- Process monitoring: Check for `impressive`, `libreoffice`, `chromium`, `vlc` processes
|
||||
|
||||
### File download URL troubleshooting
|
||||
- Symptoms:
|
||||
- `Failed to resolve 'server'` or `NameResolutionError` when downloading files
|
||||
- `Invalid URL 'http # http or https://...'` in `simclient.log`
|
||||
- What to check:
|
||||
- Look for lines like `Lade Datei herunter von:` in `logs/simclient.log` to see the effective URL used
|
||||
- Ensure the URL host is the MQTT broker IP (or your configured file server), not `server`
|
||||
- Verify `.env` values don’t include inline comments as part of the value (e.g., keep `FILE_SERVER_SCHEME=http` on its own line)
|
||||
- Fixes:
|
||||
- If your API is on the same host as the broker: leave `FILE_SERVER_HOST` empty (defaults to `MQTT_BROKER`), keep `FILE_SERVER_PORT=8000`, and set `FILE_SERVER_SCHEME=http` or `https`
|
||||
- To override fully, set `FILE_SERVER_BASE_URL` (e.g., `http://192.168.1.100:8000`); this takes precedence
|
||||
- After changing `.env`, restart the simclient process
|
||||
- Expected healthy log sequence:
|
||||
- `Lade Datei herunter von: http://<broker-ip>:8000/...`
|
||||
- Followed by `"GET /... HTTP/1.1" 200` and `Datei erfolgreich heruntergeladen:`
|
||||
|
||||
### Screenshot MQTT Transmission Issue (Event-Start/Event-Stop)
|
||||
- **Symptom**: Event-triggered screenshots (event_start, event_stop) are NOT appearing on the dashboard, only periodic screenshots transmitted
|
||||
- **Root Cause**: Race condition in metadata/file-pointer handling where periodic captures can overwrite event-triggered metadata or `latest.jpg` before simclient processes it (See SCREENSHOT_MQTT_FIX.md for details)
|
||||
- **What to check**:
|
||||
- Display manager logs show event_start/event_stop screenshots ARE being captured: `Screenshot captured: ... type=event_start`
|
||||
- But `meta.json` is stale or `latest.jpg` does not move
|
||||
- MQTT heartbeats lack screenshot data at event transitions
|
||||
- **How to verify the fix**:
|
||||
- Run: `./test-screenshot-meta-fix.sh` should output `[SUCCESS] Event-triggered metadata preserved!`
|
||||
- Check display_manager.py: `_write_screenshot_meta()` has protection logic to skip periodic overwrites of event metadata
|
||||
- Check display_manager.py: periodic `latest.jpg` updates are also protected when triggered metadata is pending
|
||||
- Check simclient.py: `screenshot_service_thread()` logs show pending event-triggered captures being processed immediately
|
||||
- **Permanent Fix**: Already applied in display_manager.py and simclient.py. Prevents periodic captures from overwriting pending trigger state and includes stale-trigger self-healing.
|
||||
|
||||
### Screenshot Capture After Restart (No Active Event)
|
||||
- In `ENV=development`, display_manager performs periodic idle captures so dashboard does not appear dead during no-event windows.
|
||||
- In `ENV=production`, periodic captures remain event/process-driven unless `SCREENSHOT_ALWAYS=1`.
|
||||
- If display_manager is started from non-interactive shells (systemd/nohup/ssh), it now attempts `DISPLAY=:0` and `XAUTHORITY=~/.Xauthority` fallback for X11 capture tools.
|
||||
|
||||
## Important Notes for AI Assistants
|
||||
|
||||
### Virtual Environment Requirements (Critical)
|
||||
- **pygame and pillow MUST be installed in venv** - Required for Impressive to work
|
||||
- **Display manager uses venv context** - Ensures Impressive has access to dependencies
|
||||
- **Installation command**: `pip install pygame pillow` (already in requirements.txt)
|
||||
- **If pygame missing**: Impressive will fail with "No module named 'pygame'" error
|
||||
|
||||
### Presentation System
|
||||
- **ALWAYS use Impressive** for PDF presentations (primary solution)
|
||||
- **DO NOT suggest xdotool approaches** - they failed on Raspberry Pi due to window focus issues
|
||||
- **DO NOT suggest video conversion** - adds complexity, had black screen issues
|
||||
- **All presentations are PDFs** - server converts PPTX to PDF using Gotenberg
|
||||
- **No client-side conversion** - client only displays pre-rendered PDFs
|
||||
- **Virtual environment is required** - pygame + pillow must be available for Impressive
|
||||
- **Loop mode uses `--wrap`** - not custom scripts or workarounds
|
||||
- **Auto-quit uses `--autoquit`** - native Impressive parameter
|
||||
|
||||
### Testing Approach
|
||||
- Use `./scripts/test-display-manager.sh` for interactive testing
|
||||
- Use `./scripts/test-impressive-loop.sh` to verify loop functionality
|
||||
- Test individual components with specific test scripts
|
||||
- Always check logs in `logs/` directory for debugging
|
||||
|
||||
CEC testing notes:
|
||||
- In development mode, the CEC integration path is skipped on purpose. To test end-to-end, either set `ENV=production` temporarily or use the manual options (1–4) in `scripts/test-hdmi-cec.sh`.
|
||||
|
||||
### Code Changes
|
||||
- Display logic is in `src/display_manager.py`, not `simclient.py`
|
||||
- MQTT client (`simclient.py`) writes events to `current_event.json`
|
||||
- Display Manager reads `current_event.json` and launches appropriate applications
|
||||
- Two separate processes: simclient.py (MQTT) + display_manager.py (display control)
|
||||
|
||||
### Documentation
|
||||
- **README.md** - Start here for comprehensive overview
|
||||
- **IMPRESSIVE_INTEGRATION.md** - Deep dive into presentation system
|
||||
- **QUICK_REFERENCE.md** - Quick commands and examples
|
||||
- Source code has extensive comments and logging
|
||||
|
||||
This system is designed for reliability and ease of maintenance in educational environments with multiple deployed clients. The Impressive-based presentation solution provides native auto-advance and loop support without complex window management hacks.
|
||||
|
||||
## Screenshot System (Nov 2025)
|
||||
|
||||
The screenshot capture and transmission system has been implemented with separation of concerns:
|
||||
|
||||
### Architecture
|
||||
- **Capture**: `display_manager.py` captures screenshots in a background thread and writes to shared `screenshots/` directory
|
||||
- **Transmission**: `simclient.py` reads latest screenshot from shared directory and transmits via MQTT dashboard topic
|
||||
- **Sharing**: Volume-based sharing between display_manager (host OS) and simclient (container)
|
||||
|
||||
### Capture Strategy (display_manager.py)
|
||||
- **Session Detection**: Automatically detects Wayland vs X11 session
|
||||
- **Wayland Tools**: Tries `grim`, `gnome-screenshot`, `spectacle` (in order)
|
||||
- **X11 Tools**: Tries `scrot`, `import` (ImageMagick), `xwd`+`convert` (in order)
|
||||
- **Processing**: Downscales to max width (default 800px), JPEG compresses (default quality 70)
|
||||
- **Output**: Creates timestamped files (`screenshot_YYYYMMDD_HHMMSS.jpg`) plus `latest.jpg` symlink
|
||||
- **Rotation**: Keeps max N files (default 20), deletes older
|
||||
- **Timing**: Production captures when display process is active (unless `SCREENSHOT_ALWAYS=1`); development allows periodic idle captures to keep dashboard fresh
|
||||
- **Reliability**: Stale/invalid pending trigger metadata is ignored automatically to avoid lock-up of periodic updates
|
||||
|
||||
### Transmission Strategy (simclient.py)
|
||||
- **Source**: Prefers `screenshots/latest.jpg` if present, falls back to newest timestamped file
|
||||
- **Topic**: `infoscreen/{client_id}/dashboard`
|
||||
- **Format**: JSON with base64-encoded image data
|
||||
- **Payload Structure**:
|
||||
```json
|
||||
{
|
||||
"timestamp": "ISO datetime",
|
||||
"client_id": "UUID",
|
||||
"status": "alive",
|
||||
"screenshot": {
|
||||
"filename": "latest.jpg",
|
||||
"data": "base64...",
|
||||
"timestamp": "ISO datetime",
|
||||
"size": 12345
|
||||
},
|
||||
"system_info": {
|
||||
"hostname": "...",
|
||||
"ip": "...",
|
||||
"uptime": 123456.78
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Logging**: Logs publish success/failure with file size for monitoring
|
||||
|
||||
### Scalability Considerations
|
||||
- **Client-side resize/compress**: Reduces bandwidth and broker load (recommended for 50+ clients)
|
||||
- **Recommended production settings**: `SCREENSHOT_CAPTURE_INTERVAL=60`, `SCREENSHOT_MAX_WIDTH=800`, `SCREENSHOT_JPEG_QUALITY=60-70`
|
||||
- **Future optimization**: Hash-based deduplication to skip identical screenshots
|
||||
- **Alternative for large scale**: HTTP storage + MQTT metadata (200+ clients)
|
||||
|
||||
### Testing
|
||||
- Install capture tools: `sudo apt install scrot imagemagick` (X11) or `sudo apt install grim gnome-screenshot` (Wayland)
|
||||
- Force capture for testing: `export SCREENSHOT_ALWAYS=1`
|
||||
- Check logs: `tail -f logs/display_manager.log logs/simclient.log`
|
||||
- Verify files: `ls -lh src/screenshots/`
|
||||
|
||||
### Troubleshooting
|
||||
- **No screenshots**: Check session type in logs, install appropriate tools
|
||||
- **Large payloads**: Reduce `SCREENSHOT_MAX_WIDTH` or `SCREENSHOT_JPEG_QUALITY`
|
||||
- **Stale screenshots**: Check `latest.jpg` symlink, verify display_manager is running
|
||||
- **MQTT errors**: Check dashboard topic logs for publish return codes
|
||||
- **Pulse overflow in remote sessions**: warnings like `pulse audio output error: overflow, flushing` can occur with NoMachine/dummy displays; if HDMI playback is stable, treat as environment-related
|
||||
- **After restarts**: Ensure both processes are restarted (`simclient.py` and `display_manager.py`) so metadata consumption and capture behavior use the same code version
|
||||
### Testing & Troubleshooting
|
||||
**Setup:**
|
||||
- X11: `sudo apt install scrot imagemagick`
|
||||
- Wayland: `sudo apt install grim gnome-screenshot`
|
||||
- Force capture: `export SCREENSHOT_ALWAYS=1`
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
tail -f logs/display_manager.log | grep screenshot # Check capture
|
||||
tail -f logs/simclient.log | grep dashboard # Check transmission
|
||||
ls -lh src/screenshots/ # Check files
|
||||
```
|
||||
|
||||
**Common Issues:**
|
||||
| Issue | Check | Fix |
|
||||
|-------|-------|-----|
|
||||
| No screenshots | Session type in logs | Install tools for X11/Wayland |
|
||||
| Large payloads | File sizes | Reduce `SCREENSHOT_MAX_WIDTH` or `SCREENSHOT_JPEG_QUALITY` |
|
||||
| Stale data | `latest.jpg` timestamp | Restart display_manager |
|
||||
| MQTT errors | Publish return codes | Check broker connectivity |
|
||||
|
||||
---
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Event Types (Scheduler Integration)
|
||||
- **Supported**: `presentation`, `webuntis`, `webpage`, `website`
|
||||
- **Auto-scroll**: Only for `event_type: "website"` (CDP injection + Chrome extension fallback)
|
||||
- **Display manager**: Uses `event_type` to select renderer when available
|
||||
|
||||
### HDMI-CEC Behavior
|
||||
- **Development mode**: CEC auto-disabled (prevents TV cycling during testing)
|
||||
- **Test script**: `test-hdmi-cec.sh` option 5 skips integration test in dev mode
|
||||
- **Manual testing**: Options 1-4 work regardless of mode
|
||||
|
||||
### Code Modification Guidelines
|
||||
- **Presentations**: Always use Impressive (`--auto`, `--wrap`, `--autoquit`)
|
||||
- **Web autoscroll**: Only for `event_type: "website"`, keep CDP optional
|
||||
- **Screenshot changes**: Remember two-process architecture (capture ≠ transmission)
|
||||
- **URL resolution**: Handle `server` host placeholder in file/video URLs
|
||||
## Assistant Workflow Expectations
|
||||
|
||||
- Prefer minimal, targeted changes.
|
||||
- Preserve existing behavior unless explicitly changing it.
|
||||
- Validate changes with relevant scripts/log checks where possible.
|
||||
- Keep references and examples aligned with current files and topics.
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -135,3 +135,5 @@ logs/
|
||||
|
||||
src/pi-dev-setup-new.sh
|
||||
src/current_process_health.json
|
||||
src/power_intent_state.json
|
||||
src/power_state.json
|
||||
|
||||
70
CHANGELOG.md
Normal file
70
CHANGELOG.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## April 2026
|
||||
|
||||
### Remote Command Intake
|
||||
|
||||
- Added MQTT command intake on `infoscreen/{client_id}/commands` (supports `reboot` and `shutdown`).
|
||||
- Added command acknowledgement publishing to `infoscreen/{client_id}/commands/ack` and `infoscreen/{client_id}/command/ack` with states `accepted`, `rejected`, `execution_started`, `completed`, `failed`.
|
||||
- Added `COMMAND_HELPER_PATH` environment variable; command execution delegated to an external shell helper so `simclient.py` requires no elevated privileges.
|
||||
- Added deduplication of commands by `command_id` with configurable TTL (`COMMAND_DEDUPE_TTL_HOURS`) and max-entries cap (`COMMAND_DEDUPE_MAX_ENTRIES`).
|
||||
- Added execution timeout (`COMMAND_EXEC_TIMEOUT_SEC`).
|
||||
- Added `COMMAND_MOCK_REBOOT_IMMEDIATE_COMPLETE` flag for canary and test environments — immediately completes a mock reboot without waiting for process restart. Safety-guarded: only activates when the helper basename is `mock-command-helper.sh`.
|
||||
|
||||
### MQTT Broker Authentication Split
|
||||
|
||||
- Split broker connection credentials (`MQTT_USER`, `MQTT_PASSWORD_BROKER`) from legacy per-device identity fields (`MQTT_USERNAME`, `MQTT_PASSWORD`).
|
||||
- `configure_mqtt_security()` now prefers `MQTT_USER`/`MQTT_PASSWORD_BROKER` for broker login, with fallback to legacy vars if broker-specific vars are absent.
|
||||
|
||||
### Systemd Service Units
|
||||
|
||||
- Added `scripts/infoscreen-simclient.service` — systemd unit for `simclient.py` with `Type=notify`, `WatchdogSec=60`, `Restart=on-failure`, `StartLimitBurst=5`.
|
||||
- Added `scripts/start-simclient.sh` — launcher script mirroring `start-display-manager.sh`.
|
||||
- Updated `scripts/infoscreen-display.service` with `OnFailure=infoscreen-notify-failure@%n.service`.
|
||||
- Updated `src/pi-setup.sh` to install and enable both units plus the failure notifier template.
|
||||
|
||||
### Process Watchdog (Gap 1 — Hung Process Detection)
|
||||
|
||||
- Added zero-dependency `_sd_notify()` raw socket helper in `simclient.py` (no `systemd-python` package required).
|
||||
- Sends `READY=1` on main loop entry and `WATCHDOG=1` on every 5-second iteration.
|
||||
- Service unit uses `Type=notify` and `WatchdogSec=60`; systemd will restart the process if it stops sending keepalives for 60 seconds.
|
||||
|
||||
### OnFailure MQTT Notifier (Gap 3 — systemd Give-Up Detection)
|
||||
|
||||
- Added `scripts/infoscreen-notify-failure@.service` — systemd template unit triggered by `OnFailure=`.
|
||||
- Added `scripts/infoscreen-notify-failure.sh` — publishes a retained JSON payload to `infoscreen/{uuid}/service_failed` via `mosquitto_pub` so the monitoring dashboard gets an alert even when the process is fully dead.
|
||||
- Payload: `{"event":"service_failed","unit":"<unit-name>","client_uuid":"...","failed_at":"<ISO-UTC>"}`.
|
||||
|
||||
### Health Payload Broker Connection Block (Gap 2 — Broker vs. Process Ambiguity)
|
||||
|
||||
- Added `broker_connection` block to the health payload: `broker_reachable`, `reconnect_count`, `connect_count`, `last_disconnect_at`.
|
||||
- `simclient.py` now tracks `reconnect_count` and `connect_count` on every `on_connect` callback and `last_disconnect` timestamp on `on_disconnect`.
|
||||
- `publish_health_message()` accepts an optional `connection_state` parameter; both heartbeat-success call sites pass the enriched state.
|
||||
|
||||
### TV Power Coordination
|
||||
|
||||
- Added Phase 1 TV power coordination on `infoscreen/groups/{group_id}/power/intent`.
|
||||
- Added `POWER_CONTROL_MODE` with `local`, `hybrid`, and `mqtt` behavior.
|
||||
- Added `src/power_intent_state.json` and `src/power_state.json` for power IPC and telemetry.
|
||||
- Added `infoscreen/{client_id}/power/state` publishing from `simclient.py`.
|
||||
- Added turn-off guard logic to avoid unintended TV-off races at event boundaries.
|
||||
- Added [TV_POWER_RUNBOOK.md](TV_POWER_RUNBOOK.md) and test tooling in `scripts/test-power-intent.sh`.
|
||||
|
||||
## March 2026
|
||||
|
||||
- Hardened event-trigger screenshots (`event_start`, `event_stop`) against periodic overwrite races.
|
||||
- Improved `latest.jpg` and `meta.json` synchronization for reliable dashboard updates.
|
||||
- Added self-healing for stale or invalid pending screenshot trigger metadata.
|
||||
- Improved display environment fallbacks (`DISPLAY=:0`, `XAUTHORITY`) for non-interactive starts.
|
||||
- Allowed periodic idle captures in development mode so dashboard previews stay fresh without active events.
|
||||
- Added content-type-aware trigger delays for event screenshots.
|
||||
- Changed screenshot transmission to a 1-second polling tick so triggered sends fire within <=1s.
|
||||
- Migrated dashboard payload to grouped schema v2 (`message`, `content`, `runtime`, `metadata`).
|
||||
|
||||
## November 2025
|
||||
|
||||
- Implemented the two-process screenshot pipeline (`display_manager.py` capture, `simclient.py` transmission).
|
||||
- Added Wayland/X11 screenshot tool fallback chains.
|
||||
- Extended dashboard payloads with screenshot and system metadata.
|
||||
- Extended scheduler event type support for `presentation`, `webuntis`, `webpage`, and `website`.
|
||||
- Added website autoscroll support via CDP injection and extension fallback.
|
||||
@@ -1,5 +1,7 @@
|
||||
# HDMI-CEC Development Mode Behavior
|
||||
|
||||
This is a focused reference for development-mode behavior. For the canonical HDMI-CEC setup and operator guide, use [HDMI_CEC_SETUP.md](HDMI_CEC_SETUP.md).
|
||||
|
||||
## Overview
|
||||
HDMI-CEC TV control is **automatically disabled** in development mode to prevent constantly switching the TV on/off during testing and development work.
|
||||
|
||||
@@ -152,7 +154,7 @@ vim .env # Set ENV=production
|
||||
- **Implementation**: `src/display_manager.py` (lines 48-76)
|
||||
- **Configuration**: `.env` (CEC section)
|
||||
- **Testing**: `scripts/test-hdmi-cec.sh`, `scripts/test-tv-response.sh`
|
||||
- **Documentation**: `HDMI_CEC_SETUP.md`, `HDMI_CEC_IMPLEMENTATION.md`
|
||||
- **Documentation**: `HDMI_CEC_SETUP.md`, `HDMI_CEC_FLOW_DIAGRAM.md`
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -1,473 +0,0 @@
|
||||
# HDMI-CEC Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
Added automatic TV power control via HDMI-CEC to the Infoscreen Client. The system now automatically turns the connected TV on when events start and off (with configurable delay) when events end.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Core Implementation (display_manager.py)
|
||||
|
||||
#### New Class: HDMICECController
|
||||
|
||||
Located at lines ~60-280 in `src/display_manager.py`
|
||||
|
||||
**Features:**
|
||||
- Automatic TV power on/off via CEC commands
|
||||
- Configurable turn-off delay to prevent rapid on/off cycles
|
||||
- State tracking to avoid redundant commands
|
||||
- Threaded delayed turn-off with cancellation support
|
||||
- Graceful fallback if cec-client not available
|
||||
|
||||
**Key Methods:**
|
||||
- `turn_on()` - Turn TV on immediately
|
||||
- `turn_off(delayed=False)` - Turn TV off (optionally with delay)
|
||||
- `cancel_turn_off()` - Cancel pending delayed turn-off
|
||||
- `_detect_tv_state()` - Query current TV power status
|
||||
- `_run_cec_command()` - Execute CEC commands via cec-client
|
||||
|
||||
#### Integration Points
|
||||
|
||||
**DisplayManager.__init__()** (line ~435)
|
||||
- Initialize HDMICECController instance
|
||||
- Pass configuration from environment variables
|
||||
|
||||
**DisplayManager._signal_handler()** (line ~450)
|
||||
- Turn off TV on service shutdown (with delay)
|
||||
|
||||
**DisplayManager.stop_current_display()** (line ~580)
|
||||
- Added `turn_off_tv` parameter
|
||||
- Schedule TV turn-off when stopping display
|
||||
|
||||
**DisplayManager.process_events()** (line ~1350)
|
||||
- Turn on TV before starting new event
|
||||
- Cancel turn-off timer when event is still active
|
||||
- Don't turn off TV when switching between events
|
||||
|
||||
### 2. Configuration
|
||||
|
||||
#### Environment Variables (.env)
|
||||
|
||||
```bash
|
||||
CEC_ENABLED=true # Enable/disable CEC (default: true)
|
||||
CEC_DEVICE=TV # Target device (default: TV)
|
||||
CEC_TURN_OFF_DELAY=30 # Turn-off delay in seconds (default: 30)
|
||||
```
|
||||
|
||||
#### Configuration Loading (display_manager.py lines ~45-48)
|
||||
|
||||
```python
|
||||
CEC_ENABLED = os.getenv("CEC_ENABLED", "true").lower() in ("true", "1", "yes")
|
||||
CEC_DEVICE = os.getenv("CEC_DEVICE", "TV")
|
||||
CEC_TURN_OFF_DELAY = int(os.getenv("CEC_TURN_OFF_DELAY", "30"))
|
||||
```
|
||||
|
||||
### 3. Documentation
|
||||
|
||||
#### New Files
|
||||
|
||||
**HDMI_CEC_SETUP.md** - Comprehensive setup and troubleshooting guide
|
||||
- Installation instructions
|
||||
- Configuration options
|
||||
- Troubleshooting steps
|
||||
- Hardware requirements
|
||||
- TV brand compatibility
|
||||
- Advanced usage examples
|
||||
|
||||
**HDMI_CEC_IMPLEMENTATION.md** (this file) - Implementation details
|
||||
|
||||
#### Updated Files
|
||||
|
||||
**README.md**
|
||||
- Added HDMI-CEC to key features
|
||||
- Added cec-utils to installation
|
||||
- Added CEC configuration section
|
||||
- Added HDMI-CEC TV Control section with quick start
|
||||
|
||||
**QUICK_REFERENCE.md**
|
||||
- Added test-hdmi-cec.sh to testing commands
|
||||
- Added cec-utils to installation
|
||||
- Added CEC configuration to .env example
|
||||
- Added HDMI-CEC commands section
|
||||
- Added HDMI_CEC_SETUP.md to documentation list
|
||||
- Added CEC to key features
|
||||
|
||||
### 4. Testing Script
|
||||
|
||||
**scripts/test-hdmi-cec.sh** - Interactive test menu
|
||||
|
||||
Features:
|
||||
- Check for cec-client installation
|
||||
- Scan for CEC devices
|
||||
- Query TV power status
|
||||
- Manual TV on/off commands
|
||||
- Test Display Manager CEC integration
|
||||
- View CEC-related logs
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
./scripts/test-hdmi-cec.sh
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### CEC Command Execution
|
||||
|
||||
Commands are executed via shell using `cec-client`:
|
||||
|
||||
```python
|
||||
result = subprocess.run(
|
||||
f'echo "{command}" | cec-client -s -d 1',
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=5
|
||||
)
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `-s` - Single command mode (exit after execution)
|
||||
- `-d 1` - Debug level 1 (minimal output)
|
||||
|
||||
### Turn-Off Delay Mechanism
|
||||
|
||||
Uses Python's `threading.Timer` for delayed execution:
|
||||
|
||||
```python
|
||||
self.turn_off_timer = threading.Timer(
|
||||
self.turn_off_delay,
|
||||
self._turn_off_now
|
||||
)
|
||||
self.turn_off_timer.daemon = True
|
||||
self.turn_off_timer.start()
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Non-blocking operation
|
||||
- Can be cancelled if new event arrives
|
||||
- Prevents TV from turning off between closely-spaced events
|
||||
|
||||
### State Tracking
|
||||
|
||||
The controller maintains TV state to avoid redundant commands:
|
||||
|
||||
```python
|
||||
self.tv_state = None # None = unknown, True = on, False = off
|
||||
```
|
||||
|
||||
On initialization, attempts to detect current state:
|
||||
```python
|
||||
if 'power status: on' in output:
|
||||
self.tv_state = True
|
||||
elif 'power status: standby' in output:
|
||||
self.tv_state = False
|
||||
```
|
||||
|
||||
### Event Lifecycle with CEC
|
||||
|
||||
1. **Event Starts**
|
||||
- `process_events()` detects new event
|
||||
- Calls `cec.turn_on()` before starting display
|
||||
- Cancels any pending turn-off timer
|
||||
- Starts display process
|
||||
|
||||
2. **Event Running**
|
||||
- Process monitored in main loop
|
||||
- Turn-off timer cancelled on each check (keeps TV on)
|
||||
|
||||
3. **Event Ends**
|
||||
- `stop_current_display(turn_off_tv=True)` called
|
||||
- Schedules delayed turn-off: `cec.turn_off(delayed=True)`
|
||||
- Timer starts countdown
|
||||
|
||||
4. **New Event Before Timeout**
|
||||
- Turn-off timer cancelled: `cec.cancel_turn_off()`
|
||||
- TV stays on
|
||||
- New event starts immediately
|
||||
|
||||
5. **Timeout Expires (No New Events)**
|
||||
- Timer executes: `_turn_off_now()`
|
||||
- TV turns off
|
||||
- System goes to idle state
|
||||
|
||||
### Event Switching Behavior
|
||||
|
||||
**Switching Between Events:**
|
||||
```python
|
||||
# Different event - stop current and start new
|
||||
logging.info(f"Event changed from {self.current_process.event_id} to {event_id}")
|
||||
# Don't turn off TV when switching between events
|
||||
self.stop_current_display(turn_off_tv=False)
|
||||
```
|
||||
|
||||
**No Active Events:**
|
||||
```python
|
||||
if self.current_process:
|
||||
logging.info("No active events in time window - stopping current display")
|
||||
# Turn off TV with delay
|
||||
self.stop_current_display(turn_off_tv=True)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Missing cec-client
|
||||
|
||||
If `cec-client` is not installed:
|
||||
```python
|
||||
if not self._check_cec_available():
|
||||
logging.warning("cec-client not found - HDMI-CEC control disabled")
|
||||
logging.info("Install with: sudo apt-get install cec-utils")
|
||||
self.enabled = False
|
||||
return
|
||||
```
|
||||
|
||||
The system continues to work normally, just without TV control.
|
||||
|
||||
### CEC Command Failures
|
||||
|
||||
Commands check for success indicators in output:
|
||||
```python
|
||||
success = (
|
||||
result.returncode == 0 or
|
||||
'power status changed' in output.lower() or
|
||||
'power on' in output.lower() or
|
||||
'standby' in output.lower()
|
||||
)
|
||||
```
|
||||
|
||||
Failures are logged but don't crash the system:
|
||||
```python
|
||||
if success:
|
||||
logging.debug(f"CEC command '{command}' executed successfully")
|
||||
else:
|
||||
logging.warning(f"CEC command '{command}' may have failed")
|
||||
```
|
||||
|
||||
### Command Timeouts
|
||||
|
||||
All CEC commands have 5-second timeout:
|
||||
```python
|
||||
try:
|
||||
result = subprocess.run(..., timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.error(f"CEC command '{command}' timed out after 5s")
|
||||
return False
|
||||
```
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Conservative (Long Delay)
|
||||
```bash
|
||||
CEC_ENABLED=true
|
||||
CEC_DEVICE=TV
|
||||
CEC_TURN_OFF_DELAY=120 # 2 minutes
|
||||
```
|
||||
Good for: Frequent events, preventing screen flicker
|
||||
|
||||
### Standard (Default)
|
||||
```bash
|
||||
CEC_ENABLED=true
|
||||
CEC_DEVICE=TV
|
||||
CEC_TURN_OFF_DELAY=30 # 30 seconds
|
||||
```
|
||||
Good for: General use, balance between responsiveness and stability
|
||||
|
||||
### Aggressive (Short Delay)
|
||||
```bash
|
||||
CEC_ENABLED=true
|
||||
CEC_DEVICE=TV
|
||||
CEC_TURN_OFF_DELAY=5 # 5 seconds
|
||||
```
|
||||
Good for: Power saving, scheduled events with clear gaps
|
||||
|
||||
### Disabled
|
||||
```bash
|
||||
CEC_ENABLED=false
|
||||
```
|
||||
Good for: Testing, TVs without CEC support, manual control
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 1. Unit Testing (Python)
|
||||
|
||||
Test script creates HDMICECController instance and exercises all methods:
|
||||
|
||||
```bash
|
||||
./scripts/test-hdmi-cec.sh
|
||||
# Choose option 5: Test Display Manager CEC integration
|
||||
```
|
||||
|
||||
### 2. Manual CEC Testing
|
||||
|
||||
Direct cec-client commands:
|
||||
|
||||
```bash
|
||||
# Turn on
|
||||
echo "on 0" | cec-client -s -d 1
|
||||
|
||||
# Turn off
|
||||
echo "standby 0" | cec-client -s -d 1
|
||||
|
||||
# Check status
|
||||
echo "pow 0" | cec-client -s -d 1
|
||||
```
|
||||
|
||||
### 3. Integration Testing
|
||||
|
||||
1. Start Display Manager with CEC enabled
|
||||
2. Send event via MQTT
|
||||
3. Verify TV turns on
|
||||
4. Let event end
|
||||
5. Verify TV turns off after delay
|
||||
6. Check logs for CEC activity
|
||||
|
||||
### 4. Log Monitoring
|
||||
|
||||
```bash
|
||||
tail -f ~/infoscreen-dev/logs/display_manager.log | grep -i cec
|
||||
```
|
||||
|
||||
Expected log entries:
|
||||
- "HDMI-CEC controller initialized"
|
||||
- "TV detected as ON/OFF"
|
||||
- "Turning TV ON/OFF via HDMI-CEC"
|
||||
- "Scheduling TV turn-off in Xs"
|
||||
- "Cancelled TV turn-off timer"
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### CPU Usage
|
||||
- Minimal: < 0.1% during idle
|
||||
- CEC commands: ~1-2% spike for 1-2 seconds
|
||||
- No continuous polling
|
||||
|
||||
### Memory Usage
|
||||
- HDMICECController: ~1KB
|
||||
- Timer threads: ~8KB each (max 1 active)
|
||||
- Total impact: Negligible
|
||||
|
||||
### Latency
|
||||
- TV turn-on: 1-3 seconds (CEC protocol + TV response)
|
||||
- TV turn-off: Same + configured delay
|
||||
- No impact on display performance
|
||||
|
||||
### Network
|
||||
- None - CEC is local HDMI bus only
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Single TV per HDMI**
|
||||
- Each HDMI output controls one TV
|
||||
- Multi-monitor setups need per-output management
|
||||
|
||||
2. **TV CEC Support Required**
|
||||
- TV must have HDMI-CEC enabled
|
||||
- Different brands use different names (Anynet+, SimpLink, etc.)
|
||||
|
||||
3. **Limited Status Feedback**
|
||||
- Can query power status
|
||||
- Cannot reliably verify command execution
|
||||
- Some TVs respond slowly or inconsistently
|
||||
|
||||
4. **No Volume Control**
|
||||
- Current implementation only handles power
|
||||
- Volume control could be added in future
|
||||
|
||||
5. **Device Address Assumptions**
|
||||
- Assumes TV is device 0 (standard)
|
||||
- Can be configured via CEC_DEVICE if different
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Additions
|
||||
|
||||
1. **Volume Control**
|
||||
```python
|
||||
def set_volume(self, level: int):
|
||||
"""Set TV volume (0-100)"""
|
||||
# CEC volume commands
|
||||
```
|
||||
|
||||
2. **Input Switching**
|
||||
```python
|
||||
def switch_input(self, input_num: int):
|
||||
"""Switch TV to specific HDMI input"""
|
||||
# CEC input selection
|
||||
```
|
||||
|
||||
3. **Multi-Monitor Support**
|
||||
```python
|
||||
def __init__(self, devices: List[str]):
|
||||
"""Support multiple displays"""
|
||||
self.controllers = [
|
||||
HDMICECController(device=dev) for dev in devices
|
||||
]
|
||||
```
|
||||
|
||||
4. **Enhanced State Detection**
|
||||
```python
|
||||
def get_tv_info(self):
|
||||
"""Get detailed TV information"""
|
||||
# Query manufacturer, model, capabilities
|
||||
```
|
||||
|
||||
5. **Event-Specific Behaviors**
|
||||
```json
|
||||
{
|
||||
"presentation": {...},
|
||||
"cec_config": {
|
||||
"turn_on": true,
|
||||
"turn_off_delay": 60,
|
||||
"volume": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### System
|
||||
- `cec-utils` package (provides cec-client binary)
|
||||
- HDMI connection to TV with CEC support
|
||||
|
||||
### Python
|
||||
- No additional packages required
|
||||
- Uses standard library: subprocess, threading, logging
|
||||
|
||||
## Compatibility
|
||||
|
||||
### Tested Platforms
|
||||
- Raspberry Pi 4/5
|
||||
- Raspberry Pi OS Bookworm
|
||||
- Python 3.9+
|
||||
|
||||
### TV Brands Tested
|
||||
- Samsung (Anynet+)
|
||||
- LG (SimpLink)
|
||||
- Sony (Bravia Sync)
|
||||
- Generic HDMI-CEC TVs
|
||||
|
||||
### HDMI Requirements
|
||||
- HDMI 1.4 or newer (for reliable CEC)
|
||||
- Quality HDMI cable (cheap cables may have CEC issues)
|
||||
- Cable length < 5 meters recommended
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See [HDMI_CEC_SETUP.md](HDMI_CEC_SETUP.md) for comprehensive troubleshooting guide.
|
||||
|
||||
Quick checks:
|
||||
1. `cec-client` installed? `which cec-client`
|
||||
2. TV CEC enabled? Check TV settings
|
||||
3. Devices detected? `echo "scan" | cec-client -s -d 1`
|
||||
4. Logs showing CEC activity? `grep -i cec logs/display_manager.log`
|
||||
|
||||
## Conclusion
|
||||
|
||||
The HDMI-CEC integration provides seamless automatic TV control that enhances the user experience by:
|
||||
|
||||
- Eliminating manual TV on/off operations
|
||||
- Preventing unnecessary power consumption
|
||||
- Creating a truly automated digital signage solution
|
||||
- Working reliably with minimal configuration
|
||||
|
||||
The implementation is robust, well-tested, and production-ready for deployment in educational and research environments.
|
||||
@@ -1,5 +1,12 @@
|
||||
# HDMI-CEC Setup and Configuration
|
||||
|
||||
This is the canonical HDMI-CEC operator document.
|
||||
|
||||
Related reference material:
|
||||
|
||||
- [HDMI_CEC_DEV_MODE.md](HDMI_CEC_DEV_MODE.md): development-mode behavior.
|
||||
- [HDMI_CEC_FLOW_DIAGRAM.md](HDMI_CEC_FLOW_DIAGRAM.md): flow and sequence diagrams.
|
||||
|
||||
## Overview
|
||||
|
||||
The Infoscreen Client now includes automatic TV control via HDMI-CEC (Consumer Electronics Control). This allows the Raspberry Pi to turn the connected TV on/off automatically based on event scheduling.
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
# HDMI-CEC Integration - Complete Summary
|
||||
|
||||
## ✅ Implementation Complete
|
||||
|
||||
Successfully added HDMI-CEC TV control functionality to the Infoscreen Client. The TV now automatically turns on when events start and off when events end.
|
||||
|
||||
## 📝 What Was Done
|
||||
|
||||
### 1. Core Implementation
|
||||
- **File**: `src/display_manager.py`
|
||||
- **New Class**: `HDMICECController` (220 lines)
|
||||
- **Integration**: 5 integration points in `DisplayManager` class
|
||||
- **Features**:
|
||||
- Auto TV power on when events start
|
||||
- Auto TV power off (with delay) when events end
|
||||
- Smart event switching (TV stays on)
|
||||
- Configurable turn-off delay
|
||||
- State tracking to avoid redundant commands
|
||||
- Threaded delayed turn-off with cancellation
|
||||
|
||||
### 2. Configuration
|
||||
- **Environment Variables** in `.env`:
|
||||
- `CEC_ENABLED=true` - Enable/disable CEC control
|
||||
- `CEC_DEVICE=TV` - Target device identifier
|
||||
- `CEC_TURN_OFF_DELAY=30` - Delay in seconds before TV turns off
|
||||
|
||||
### 3. Documentation Created
|
||||
- ✅ `HDMI_CEC_SETUP.md` - Comprehensive setup and troubleshooting (400+ lines)
|
||||
- ✅ `HDMI_CEC_IMPLEMENTATION.md` - Technical implementation details (700+ lines)
|
||||
- ✅ `HDMI_CEC_FLOW_DIAGRAM.md` - Visual flow diagrams and architecture (500+ lines)
|
||||
- ✅ Updated `README.md` - Added CEC to features, installation, configuration
|
||||
- ✅ Updated `QUICK_REFERENCE.md` - Added CEC commands and testing
|
||||
- ✅ Updated `.env` - Added CEC configuration section
|
||||
|
||||
### 4. Testing Script
|
||||
- **File**: `scripts/test-hdmi-cec.sh`
|
||||
- **Features**:
|
||||
- Interactive menu for testing CEC commands
|
||||
- Scan for CEC devices
|
||||
- Manual TV on/off commands
|
||||
- Test Display Manager integration
|
||||
- View CEC logs
|
||||
- Python integration test
|
||||
|
||||
## 🎯 How It Works
|
||||
|
||||
### Event Lifecycle
|
||||
```
|
||||
1. Event Starts → Turn TV ON → Start Display
|
||||
2. Event Running → Keep TV ON (cancel turn-off timer)
|
||||
3. Event Ends → Schedule TV turn-off (30s delay by default)
|
||||
4. Timer Expires → Turn TV OFF
|
||||
5. New Event Before Timeout → Cancel turn-off → TV stays ON
|
||||
```
|
||||
|
||||
### Key Behaviors
|
||||
- **Starting events**: TV turns ON before display starts
|
||||
- **Switching events**: TV stays ON (seamless transition)
|
||||
- **No active events**: TV turns OFF after configurable delay
|
||||
- **Service shutdown**: TV turns OFF (with delay)
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
# Install CEC utilities
|
||||
sudo apt-get install cec-utils
|
||||
|
||||
# Test CEC connection
|
||||
echo "scan" | cec-client -s -d 1
|
||||
```
|
||||
|
||||
### Configuration
|
||||
Edit `.env` file:
|
||||
```bash
|
||||
CEC_ENABLED=true # Enable TV control
|
||||
CEC_DEVICE=TV # Device identifier
|
||||
CEC_TURN_OFF_DELAY=30 # Delay before turn-off (seconds)
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Interactive test menu
|
||||
./scripts/test-hdmi-cec.sh
|
||||
|
||||
# Manual commands
|
||||
echo "on 0" | cec-client -s -d 1 # Turn on
|
||||
echo "standby 0" | cec-client -s -d 1 # Turn off
|
||||
echo "pow 0" | cec-client -s -d 1 # Check status
|
||||
```
|
||||
|
||||
### Monitor Logs
|
||||
```bash
|
||||
tail -f ~/infoscreen-dev/logs/display_manager.log | grep -i cec
|
||||
```
|
||||
|
||||
## 📊 Technical Details
|
||||
|
||||
### CEC Commands Used
|
||||
- `on 0` - Turn TV on (device 0)
|
||||
- `standby 0` - Turn TV off (standby mode)
|
||||
- `pow 0` - Query TV power status
|
||||
- `scan` - Scan for CEC devices
|
||||
|
||||
### Implementation Features
|
||||
- **Non-blocking**: CEC commands don't block event processing
|
||||
- **Threaded timers**: Delayed turn-off uses Python threading.Timer
|
||||
- **State tracking**: Avoids redundant commands
|
||||
- **Graceful fallback**: Works without cec-client (disabled mode)
|
||||
- **Error handling**: Timeouts, failures logged but don't crash
|
||||
- **Configurable**: All behavior controlled via environment variables
|
||||
|
||||
### Performance
|
||||
- CPU: < 0.1% idle, 1-2% spike during CEC commands
|
||||
- Memory: ~10KB total (controller + timer thread)
|
||||
- Latency: 1-3 seconds for TV response
|
||||
- No network usage (HDMI-CEC is local bus)
|
||||
|
||||
## 🔧 Configuration Options
|
||||
|
||||
### Quick Turn-Off (Power Saving)
|
||||
```bash
|
||||
CEC_TURN_OFF_DELAY=5 # 5 seconds
|
||||
```
|
||||
Good for: Scheduled events with clear gaps
|
||||
|
||||
### Standard (Balanced) - Default
|
||||
```bash
|
||||
CEC_TURN_OFF_DELAY=30 # 30 seconds
|
||||
```
|
||||
Good for: General use, prevents flicker
|
||||
|
||||
### Conservative (Smooth)
|
||||
```bash
|
||||
CEC_TURN_OFF_DELAY=120 # 2 minutes
|
||||
```
|
||||
Good for: Frequent events, maximize smoothness
|
||||
|
||||
### Disabled
|
||||
```bash
|
||||
CEC_ENABLED=false
|
||||
```
|
||||
Good for: Testing, manual control, non-CEC TVs
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `HDMI_CEC_SETUP.md` | Complete setup guide, troubleshooting, TV compatibility |
|
||||
| `HDMI_CEC_IMPLEMENTATION.md` | Technical details, code walkthrough, API reference |
|
||||
| `HDMI_CEC_FLOW_DIAGRAM.md` | Visual diagrams, state machines, flow charts |
|
||||
| `README.md` | Quick start, feature overview |
|
||||
| `QUICK_REFERENCE.md` | Commands cheat sheet |
|
||||
|
||||
## ✨ Features
|
||||
|
||||
✅ Automatic TV power on when events start
|
||||
✅ Automatic TV power off when events end
|
||||
✅ Configurable turn-off delay (prevent flicker)
|
||||
✅ Smart event switching (TV stays on)
|
||||
✅ State tracking (avoid redundant commands)
|
||||
✅ Graceful fallback (works without CEC)
|
||||
✅ Comprehensive logging
|
||||
✅ Interactive test script
|
||||
✅ Production-ready
|
||||
✅ Well-documented
|
||||
|
||||
## 🎓 Example Scenarios
|
||||
|
||||
### Scenario 1: Single Morning Presentation
|
||||
```
|
||||
08:55 - System idle, TV off
|
||||
09:00 - Event starts → TV turns ON → Presentation displays
|
||||
09:30 - Event ends → Presentation stops → 30s countdown starts
|
||||
09:30:30 - TV turns OFF
|
||||
```
|
||||
|
||||
### Scenario 2: Back-to-Back Events
|
||||
```
|
||||
10:00 - Event A starts → TV ON → Display A
|
||||
10:30 - Event A ends → Turn-off scheduled (30s)
|
||||
10:35 - Event B starts (within 30s) → Turn-off cancelled → Display B
|
||||
11:00 - Event B ends → Turn-off scheduled
|
||||
11:00:30 - TV OFF
|
||||
```
|
||||
|
||||
### Scenario 3: All-Day Display
|
||||
```
|
||||
08:00 - Morning event → TV ON
|
||||
10:00 - Switch to midday event → TV stays ON
|
||||
14:00 - Switch to afternoon event → TV stays ON
|
||||
17:00 - Last event ends → 30s countdown
|
||||
17:00:30 - TV OFF
|
||||
```
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### TV Not Responding
|
||||
1. Check if CEC enabled on TV (settings menu)
|
||||
2. Verify TV detected: `echo "scan" | cec-client -s -d 1`
|
||||
3. Test manual command: `echo "on 0" | cec-client -s -d 1`
|
||||
4. Check logs: `grep -i cec logs/display_manager.log`
|
||||
|
||||
### cec-client Not Found
|
||||
```bash
|
||||
sudo apt-get install cec-utils
|
||||
which cec-client # Should show /usr/bin/cec-client
|
||||
```
|
||||
|
||||
### TV Turns Off Too Soon
|
||||
Increase delay in `.env`:
|
||||
```bash
|
||||
CEC_TURN_OFF_DELAY=60 # Wait 60 seconds
|
||||
```
|
||||
|
||||
### Disable CEC Temporarily
|
||||
```bash
|
||||
# In .env
|
||||
CEC_ENABLED=false
|
||||
```
|
||||
|
||||
## 🎯 Integration Points
|
||||
|
||||
The CEC controller integrates at these key points:
|
||||
|
||||
1. **DisplayManager.__init__**: Initialize CEC controller
|
||||
2. **_signal_handler**: Turn off TV on shutdown
|
||||
3. **stop_current_display**: Schedule turn-off when stopping
|
||||
4. **process_events**: Turn on TV when starting, cancel turn-off when active
|
||||
|
||||
## 📦 Files Modified/Created
|
||||
|
||||
### Modified
|
||||
- ✅ `src/display_manager.py` - Added HDMICECController class and integration
|
||||
- ✅ `.env` - Added CEC configuration section
|
||||
- ✅ `README.md` - Added CEC to features, installation, configuration
|
||||
- ✅ `QUICK_REFERENCE.md` - Added CEC commands and testing
|
||||
|
||||
### Created
|
||||
- ✅ `HDMI_CEC_SETUP.md` - Setup and troubleshooting guide
|
||||
- ✅ `HDMI_CEC_IMPLEMENTATION.md` - Technical documentation
|
||||
- ✅ `HDMI_CEC_FLOW_DIAGRAM.md` - Visual diagrams
|
||||
- ✅ `scripts/test-hdmi-cec.sh` - Interactive test script
|
||||
- ✅ `HDMI_CEC_SUMMARY.md` - This file
|
||||
|
||||
## ✅ Testing Checklist
|
||||
|
||||
- [x] Python syntax check passes
|
||||
- [x] Module loads without errors
|
||||
- [x] HDMICECController class defined
|
||||
- [x] Integration points added to DisplayManager
|
||||
- [x] Configuration variables added
|
||||
- [x] Test script created and made executable
|
||||
- [x] Documentation complete
|
||||
- [x] README updated
|
||||
- [x] QUICK_REFERENCE updated
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
### To Start Using
|
||||
|
||||
1. **Install CEC utils**:
|
||||
```bash
|
||||
sudo apt-get install cec-utils
|
||||
```
|
||||
|
||||
2. **Test CEC connection**:
|
||||
```bash
|
||||
./scripts/test-hdmi-cec.sh
|
||||
```
|
||||
|
||||
3. **Enable in .env**:
|
||||
```bash
|
||||
CEC_ENABLED=true
|
||||
CEC_DEVICE=TV
|
||||
CEC_TURN_OFF_DELAY=30
|
||||
```
|
||||
|
||||
4. **Restart Display Manager**:
|
||||
```bash
|
||||
./scripts/start-display-manager.sh
|
||||
```
|
||||
|
||||
5. **Monitor logs**:
|
||||
```bash
|
||||
tail -f logs/display_manager.log | grep -i cec
|
||||
```
|
||||
|
||||
### Expected Log Output
|
||||
|
||||
```
|
||||
[INFO] HDMI-CEC controller initialized (device: TV, turn_off_delay: 30s)
|
||||
[INFO] TV detected as ON
|
||||
[INFO] Starting display for event: presentation_slides.pdf
|
||||
[INFO] Turning TV ON via HDMI-CEC...
|
||||
[INFO] TV turned ON successfully
|
||||
```
|
||||
|
||||
## 📖 Documentation Quick Links
|
||||
|
||||
- **Setup Guide**: [HDMI_CEC_SETUP.md](HDMI_CEC_SETUP.md)
|
||||
- **Technical Details**: [HDMI_CEC_IMPLEMENTATION.md](HDMI_CEC_IMPLEMENTATION.md)
|
||||
- **Flow Diagrams**: [HDMI_CEC_FLOW_DIAGRAM.md](HDMI_CEC_FLOW_DIAGRAM.md)
|
||||
- **Main README**: [README.md](README.md)
|
||||
- **Quick Reference**: [QUICK_REFERENCE.md](QUICK_REFERENCE.md)
|
||||
|
||||
## 🎉 Success!
|
||||
|
||||
HDMI-CEC TV control is now fully integrated into the Infoscreen Client. The system will automatically manage TV power based on event scheduling, creating a truly automated digital signage solution.
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date**: November 12, 2025
|
||||
**Status**: ✅ Production Ready
|
||||
**Tested**: Python syntax, module loading
|
||||
**Next**: Install cec-utils and test with physical TV
|
||||
61
MQTT_PAYLOAD_MIGRATION_CHECKLIST.md
Normal file
61
MQTT_PAYLOAD_MIGRATION_CHECKLIST.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# MQTT Payload Migration Checklist (One Page)
|
||||
|
||||
Use this checklist to migrate from legacy flat dashboard payload to grouped v2 payload.
|
||||
|
||||
## A. Client Implementation
|
||||
|
||||
- [x] Create branch for migration work.
|
||||
- [x] Capture one baseline message from MQTT (legacy format).
|
||||
- [x] Implement one canonical payload builder function.
|
||||
- [x] Emit grouped blocks in this order: `message`, `content`, `runtime`, `metadata`.
|
||||
- [x] Add `metadata.schema_version = "2.0"`.
|
||||
- [x] Add `metadata.producer = "simclient"`.
|
||||
- [x] Add `metadata.published_at` in UTC ISO format.
|
||||
- [x] Map capture type to `metadata.capture.type` (`periodic`, `event_start`, `event_stop`).
|
||||
- [x] Map screenshot freshness to `metadata.capture.age_s`.
|
||||
- [x] Keep screenshot object unchanged in semantics (`filename`, `data`, `timestamp`, `size`).
|
||||
- [x] Keep trigger behavior unchanged (periodic and triggered sends still work).
|
||||
- [x] Add publish log fields: schema version, capture type, age.
|
||||
- [x] Validate all 3 paths end-to-end:
|
||||
- [x] periodic
|
||||
- [x] event_start
|
||||
- [x] event_stop
|
||||
|
||||
## B. Server Migration
|
||||
|
||||
- [x] Add grouped v2 parser (`message/content/runtime/metadata`).
|
||||
- [x] Add temporary legacy fallback parser.
|
||||
- [x] Normalize both parsers into one internal server model.
|
||||
- [x] Mark required fields:
|
||||
- [x] `message.client_id`
|
||||
- [x] `message.status`
|
||||
- [x] `metadata.schema_version`
|
||||
- [x] `metadata.capture.type`
|
||||
- [x] Keep optional fields tolerated (`runtime.process_health`, `content.screenshot`).
|
||||
- [x] Update dashboard consumers to use normalized model (not raw legacy keys).
|
||||
- [x] Add migration counters:
|
||||
- [x] v2 parse success
|
||||
- [x] legacy fallback usage
|
||||
- [x] parse failures
|
||||
- [x] Test compatibility matrix:
|
||||
- [x] new client -> new server
|
||||
- [x] legacy client -> new server
|
||||
- [x] Run short soak in dev.
|
||||
|
||||
## C. Cutover and Cleanup
|
||||
|
||||
- [ ] Set v2 as primary parser path on server.
|
||||
- [ ] Confirm fallback usage is near zero for agreed window.
|
||||
- [ ] Remove legacy parser/fallback.
|
||||
- [ ] Remove client-side temporary compatibility fields (if used).
|
||||
- [ ] Keep one canonical schema sample in repo.
|
||||
- [ ] Close migration ticket with final validation evidence.
|
||||
|
||||
## Quick Go/No-Go Gate
|
||||
|
||||
Go only if all are true:
|
||||
|
||||
- [ ] No parse failures in dev soak
|
||||
- [ ] All 3 capture types visible in dashboard
|
||||
- [ ] Screenshot payload integrity unchanged
|
||||
- [ ] Metadata group present and complete
|
||||
194
MQTT_PAYLOAD_MIGRATION_GUIDE.md
Normal file
194
MQTT_PAYLOAD_MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# MQTT Payload Migration Guide
|
||||
|
||||
## Purpose
|
||||
This guide describes a practical migration from the current dashboard screenshot payload to a grouped schema, with client-side implementation first and server-side migration second.
|
||||
|
||||
## Scope
|
||||
- Environment: development and alpha systems (no production installs)
|
||||
- Message topic: infoscreen/<client_id>/dashboard
|
||||
- Capture types to preserve: periodic, event_start, event_stop
|
||||
|
||||
## Target Schema (v2)
|
||||
The canonical message should be grouped into four logical blocks in this order:
|
||||
|
||||
1. message
|
||||
2. content
|
||||
3. runtime
|
||||
4. metadata
|
||||
|
||||
Example shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"client_id": "<uuid>",
|
||||
"status": "alive"
|
||||
},
|
||||
"content": {
|
||||
"screenshot": {
|
||||
"filename": "latest.jpg",
|
||||
"data": "<base64>",
|
||||
"timestamp": "2026-03-30T10:15:41.123456+00:00",
|
||||
"size": 183245
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"system_info": {
|
||||
"hostname": "pi-display-01",
|
||||
"ip": "192.168.1.42",
|
||||
"uptime": 123456.7
|
||||
},
|
||||
"process_health": {
|
||||
"event_id": "evt-123",
|
||||
"event_type": "presentation",
|
||||
"current_process": "impressive",
|
||||
"process_pid": 4123,
|
||||
"process_status": "running",
|
||||
"restart_count": 0
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"schema_version": "2.0",
|
||||
"producer": "simclient",
|
||||
"published_at": "2026-03-30T10:15:42.004321+00:00",
|
||||
"capture": {
|
||||
"type": "periodic",
|
||||
"captured_at": "2026-03-30T10:15:41.123456+00:00",
|
||||
"age_s": 0.9,
|
||||
"triggered": false,
|
||||
"send_immediately": false
|
||||
},
|
||||
"transport": {
|
||||
"qos": 0,
|
||||
"publisher": "simclient"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step-by-Step: Client-Side First
|
||||
|
||||
1. Create a migration branch.
|
||||
- Example: feature/payload-v2
|
||||
|
||||
2. Freeze a baseline sample from MQTT.
|
||||
- Capture one payload via mosquitto_sub and store it for comparison.
|
||||
|
||||
3. Implement one canonical payload builder.
|
||||
- Centralize JSON assembly in one function only.
|
||||
- Do not duplicate payload construction across code paths.
|
||||
|
||||
4. Add versioned metadata.
|
||||
- Set metadata.schema_version = "2.0".
|
||||
- Add metadata.producer = "simclient".
|
||||
- Add metadata.published_at in UTC ISO format.
|
||||
|
||||
5. Map existing data into grouped blocks.
|
||||
- client_id/status -> message
|
||||
- screenshot object -> content.screenshot
|
||||
- system_info/process_health -> runtime
|
||||
- capture mode and freshness -> metadata.capture
|
||||
|
||||
6. Preserve existing capture semantics.
|
||||
- Keep type values unchanged: periodic, event_start, event_stop.
|
||||
- Keep UTC ISO timestamps.
|
||||
- Keep screenshot encoding and size behavior unchanged.
|
||||
|
||||
7. Optional short-term compatibility mode (recommended for one sprint).
|
||||
- Either:
|
||||
- Keep current legacy fields in parallel, or
|
||||
- Add a legacy block with old field names.
|
||||
- Goal: prevent immediate server breakage while parser updates are merged.
|
||||
|
||||
8. Improve publish logs for verification.
|
||||
- Log schema_version, metadata.capture.type, metadata.capture.age_s.
|
||||
|
||||
9. Validate all three capture paths end-to-end.
|
||||
- periodic capture
|
||||
- event_start trigger capture
|
||||
- event_stop trigger capture
|
||||
|
||||
10. Lock the client contract.
|
||||
- Save one validated JSON sample per capture type.
|
||||
- Use those samples in server parser tests.
|
||||
|
||||
## Step-by-Step: Server-Side Migration
|
||||
|
||||
1. Add support for grouped v2 parsing.
|
||||
- Parse from message/content/runtime/metadata first.
|
||||
|
||||
2. Add fallback parser for legacy payload (temporary).
|
||||
- If grouped keys are absent, parse old top-level keys.
|
||||
|
||||
3. Normalize to one internal server model.
|
||||
- Convert both parser paths into one DTO/entity used by dashboard logic.
|
||||
|
||||
4. Validate required fields.
|
||||
- Required:
|
||||
- message.client_id
|
||||
- message.status
|
||||
- metadata.schema_version
|
||||
- metadata.capture.type
|
||||
- Optional:
|
||||
- runtime.process_health
|
||||
- content.screenshot (if no screenshot available)
|
||||
|
||||
5. Update dashboard consumers.
|
||||
- Read grouped fields from internal model (not raw old keys).
|
||||
|
||||
6. Add migration observability.
|
||||
- Counters:
|
||||
- v2 parse success
|
||||
- legacy fallback usage
|
||||
- parse failures
|
||||
- Warning log for unknown schema_version.
|
||||
|
||||
7. Run mixed-format integration tests.
|
||||
- New client -> new server
|
||||
- Legacy client -> new server (fallback path)
|
||||
|
||||
8. Cut over to v2 preferred.
|
||||
- Keep fallback for short soak period only.
|
||||
|
||||
9. Remove fallback and legacy assumptions.
|
||||
- After stability window, remove old parser path.
|
||||
|
||||
10. Final cleanup.
|
||||
- Keep one schema doc and test fixtures.
|
||||
- Remove temporary compatibility switches.
|
||||
|
||||
## Legacy to v2 Field Mapping
|
||||
|
||||
| Legacy field | v2 field |
|
||||
|---|---|
|
||||
| client_id | message.client_id |
|
||||
| status | message.status |
|
||||
| screenshot | content.screenshot |
|
||||
| screenshot_type | metadata.capture.type |
|
||||
| screenshot_age_s | metadata.capture.age_s |
|
||||
| timestamp | metadata.published_at |
|
||||
| system_info | runtime.system_info |
|
||||
| process_health | runtime.process_health |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. All capture types parse and display correctly.
|
||||
- periodic
|
||||
- event_start
|
||||
- event_stop
|
||||
|
||||
2. Screenshot payload integrity is unchanged.
|
||||
- filename, data, timestamp, size remain valid.
|
||||
|
||||
3. Metadata is centrally visible at message end.
|
||||
- schema_version, capture metadata, transport metadata all inside metadata.
|
||||
|
||||
4. No regression in dashboard update timing.
|
||||
- Triggered screenshots still publish quickly.
|
||||
|
||||
## Suggested Timeline (Dev Only)
|
||||
|
||||
1. Day 1: client v2 payload implementation + local tests
|
||||
2. Day 2: server v2 parser + fallback
|
||||
3. Day 3-5: soak in dev, monitor parse metrics
|
||||
4. Day 6+: remove fallback and finalize v2-only
|
||||
844
README.md
844
README.md
@@ -1,48 +1,29 @@
|
||||
# Infoscreen Client - Display Manager
|
||||
# Infoscreen Client
|
||||
|
||||
Digital signage system for Raspberry Pi that displays presentations, videos, and web content in kiosk mode. Centrally managed via MQTT with automatic client discovery, heartbeat monitoring, and screenshot-based dashboard monitoring.
|
||||
Digital signage client for Raspberry Pi that displays presentations, videos, and web content in kiosk mode. It is managed centrally via MQTT and includes HDMI-CEC TV control, screenshot-based dashboard monitoring, and process health reporting.
|
||||
|
||||
## 🎯 Key Features
|
||||
Dashboard screenshots can contain visible on-screen content. Keep that in mind when enabling or documenting remote monitoring.
|
||||
|
||||
- **Automatic Presentation Display** - Server renders PPTX to PDF; client displays PDFs with Impressive
|
||||
- **Auto-Advance Slideshows** - Configurable timing for automatic slide progression
|
||||
- **Loop Mode** - Presentations can loop infinitely or quit after last slide
|
||||
- **HDMI-CEC TV Control** - Automatic TV power on/off based on event scheduling
|
||||
- **MQTT Integration** - Real-time event management from central server
|
||||
- **Group Management** - Organize clients into groups for targeted content
|
||||
- **Heartbeat Monitoring** - Regular status updates and screenshot dashboard
|
||||
- **Client Process Monitoring** - Health-state bridge, crash/restart tracking, and monitoring log
|
||||
- **Screenshot Dashboard** - Automatic screen capture with Wayland/X11 support, client-side compression
|
||||
- **Multi-Content Support** - Presentations, videos, and web pages
|
||||
- **Kiosk Mode** - Full-screen display with automatic startup
|
||||
## Key Features
|
||||
|
||||
## 📋 System Requirements
|
||||
- Server-side PPTX to PDF rendering; client displays PDFs with Impressive.
|
||||
- Presentation auto-advance, loop mode, and progress indicators.
|
||||
- Video playback with `python-vlc` when available and external VLC fallback.
|
||||
- Web and WebUntis display in kiosk mode.
|
||||
- HDMI-CEC TV power control with local fallback and MQTT-coordinated power intent.
|
||||
- MQTT discovery, heartbeat, group assignment, and event delivery.
|
||||
- Screenshot dashboard with Wayland/X11 capture tool fallbacks.
|
||||
- Process health bridge between `display_manager.py` and `simclient.py`.
|
||||
|
||||
### Hardware
|
||||
- Raspberry Pi 4/5 (or compatible)
|
||||
- HDMI display
|
||||
- Network connectivity (WiFi or Ethernet)
|
||||
- SSD storage recommended
|
||||
## Quick Start
|
||||
|
||||
### Software
|
||||
- Raspberry Pi OS (Bookworm or newer)
|
||||
- Python 3.x
|
||||
- Impressive (PDF presenter with auto-advance)
|
||||
- Chromium browser (for web content)
|
||||
- VLC or MPV (for video playback)
|
||||
- Screenshot tools: `scrot` or ImageMagick (X11) OR `grim` or `gnome-screenshot` (Wayland)
|
||||
- CEC Utils (for HDMI-CEC TV control - optional)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Installation
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
cd ~/
|
||||
git clone <repository-url> infoscreen-dev
|
||||
cd infoscreen-dev
|
||||
# Install system dependencies
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
python3 python3-pip python3-venv \
|
||||
@@ -51,723 +32,228 @@ sudo apt-get install -y \
|
||||
cec-utils \
|
||||
scrot imagemagick
|
||||
|
||||
# For Wayland systems, install screenshot tools:
|
||||
# For Wayland systems:
|
||||
# sudo apt-get install grim gnome-screenshot
|
||||
|
||||
# Create Python virtual environment
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install Python dependencies
|
||||
pip install -r src/requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
### 2. Configure `.env`
|
||||
|
||||
Create `.env` file in project root (or copy from `.env.template`):
|
||||
Copy `.env.template` to `.env` and set at least:
|
||||
|
||||
```bash
|
||||
# Screenshot capture behavior
|
||||
SCREENSHOT_ALWAYS=0 # Set to 1 for testing (forces capture even without active display)
|
||||
ENV=production
|
||||
DEBUG_MODE=0
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Environment
|
||||
ENV=production # development | production (CEC disabled in development)
|
||||
DEBUG_MODE=0 # 1 to enable debug mode
|
||||
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
|
||||
|
||||
# MQTT Configuration
|
||||
MQTT_BROKER=192.168.1.100 # Your MQTT broker IP/hostname
|
||||
MQTT_BROKER=192.168.1.100
|
||||
MQTT_PORT=1883
|
||||
MQTT_USER=<broker-username>
|
||||
MQTT_PASSWORD_BROKER=<broker-password>
|
||||
MQTT_USERNAME=infoscreen-client-<client-uuid-prefix>
|
||||
MQTT_PASSWORD=<per-device-random-password>
|
||||
MQTT_TLS_ENABLED=0
|
||||
|
||||
# Timing (seconds)
|
||||
HEARTBEAT_INTERVAL=60 # How often client sends status updates
|
||||
SCREENSHOT_INTERVAL=180 # How often simclient transmits screenshots
|
||||
SCREENSHOT_CAPTURE_INTERVAL=180 # How often display_manager captures screenshots
|
||||
DISPLAY_CHECK_INTERVAL=15 # How often display_manager checks for new events
|
||||
HEARTBEAT_INTERVAL=60
|
||||
SCREENSHOT_INTERVAL=180
|
||||
SCREENSHOT_CAPTURE_INTERVAL=180
|
||||
DISPLAY_CHECK_INTERVAL=15
|
||||
|
||||
# File/API Server (used to download presentation files)
|
||||
# Defaults to MQTT_BROKER host with port 8000 and http scheme
|
||||
FILE_SERVER_HOST= # Optional; if empty, defaults to MQTT_BROKER
|
||||
FILE_SERVER_PORT=8000 # Default API port
|
||||
FILE_SERVER_SCHEME=http # http or https
|
||||
# FILE_SERVER_BASE_URL= # Optional full override, e.g., http://192.168.1.100:8000
|
||||
FILE_SERVER_HOST=
|
||||
FILE_SERVER_PORT=8000
|
||||
FILE_SERVER_SCHEME=http
|
||||
|
||||
# HDMI-CEC TV Control (optional)
|
||||
CEC_ENABLED=true # Enable automatic TV power control
|
||||
CEC_DEVICE=0 # Target device (0 recommended for TV)
|
||||
CEC_TURN_OFF_DELAY=30 # Seconds to wait before turning off TV
|
||||
CEC_POWER_ON_WAIT=5 # Seconds to wait after power ON (for TV boot)
|
||||
CEC_POWER_OFF_WAIT=5 # Seconds to wait after power OFF
|
||||
CEC_ENABLED=true
|
||||
CEC_DEVICE=0
|
||||
CEC_TURN_OFF_DELAY=30
|
||||
CEC_POWER_ON_WAIT=5
|
||||
CEC_POWER_OFF_WAIT=5
|
||||
|
||||
POWER_CONTROL_MODE=local
|
||||
|
||||
COMMAND_HELPER_PATH=/usr/local/bin/infoscreen-cmd-helper.sh
|
||||
COMMAND_EXEC_TIMEOUT_SEC=15
|
||||
COMMAND_DEDUPE_TTL_HOURS=24
|
||||
COMMAND_DEDUPE_MAX_ENTRIES=5000
|
||||
COMMAND_MOCK_REBOOT_IMMEDIATE_COMPLETE=0
|
||||
```
|
||||
|
||||
MQTT auth/TLS notes:
|
||||
|
||||
- `MQTT_USER` / `MQTT_PASSWORD_BROKER` are the broker credentials used at connection time.
|
||||
- `MQTT_USERNAME` / `MQTT_PASSWORD` are legacy per-device identity fields kept for fallback and identity purposes.
|
||||
- Store real broker credentials only in the local [/.env](.env), which is gitignored.
|
||||
- When TLS is enabled, also set `MQTT_TLS_CA_CERT`, and if client certificates are used, `MQTT_TLS_CERT` and `MQTT_TLS_KEY`.
|
||||
- Keep the local [/.env](.env) readable only by the service user and admins, for example mode `600`.
|
||||
|
||||
Mode summary:
|
||||
|
||||
- `POWER_CONTROL_MODE=local`: local event-time CEC only.
|
||||
- `POWER_CONTROL_MODE=hybrid`: prefer fresh MQTT intent, fallback to local timing.
|
||||
- `POWER_CONTROL_MODE=mqtt`: MQTT intent authoritative, with safe fallback behavior.
|
||||
|
||||
### 3. Start Services
|
||||
|
||||
```bash
|
||||
# Start MQTT client (handles events, heartbeat, discovery)
|
||||
cd ~/infoscreen-dev/src
|
||||
python3 simclient.py
|
||||
The preferred method on deployed devices is systemd:
|
||||
|
||||
# In another terminal: Start Display Manager
|
||||
cd ~/infoscreen-dev/src
|
||||
python3 display_manager.py
|
||||
```bash
|
||||
sudo systemctl start infoscreen-simclient infoscreen-display
|
||||
sudo systemctl status infoscreen-simclient infoscreen-display
|
||||
sudo journalctl -u infoscreen-simclient -u infoscreen-display -f
|
||||
```
|
||||
|
||||
Or use the startup script:
|
||||
For first-time setup, run `src/pi-setup.sh` to install and enable the units. See [src/README.md](src/README.md) for the systemd setup steps.
|
||||
|
||||
For local development without systemd:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
./scripts/start-simclient.sh
|
||||
|
||||
# Terminal 2
|
||||
./scripts/start-display-manager.sh
|
||||
```
|
||||
|
||||
## 📊 Presentation System
|
||||
|
||||
### How It Works
|
||||
|
||||
The system uses **Impressive** as the PDF presenter with native auto-advance and loop support:
|
||||
|
||||
1. **Server-side rendering**: PPTX files are converted to PDF by the server using Gotenberg
|
||||
2. **Client receives PDFs**: Events contain pre-rendered PDF files ready for display
|
||||
3. **Direct display**: PDF files are displayed directly with Impressive (no client-side conversion needed)
|
||||
4. **Auto-advance** uses Impressive's built-in `--auto` parameter
|
||||
5. **Loop mode** uses Impressive's `--wrap` parameter (infinite loop)
|
||||
6. **Auto-quit** uses Impressive's `--autoquit` parameter (exit after last slide)
|
||||
|
||||
### Event JSON Format
|
||||
|
||||
#### Looping Presentation (Typical for Events)
|
||||
```json
|
||||
{
|
||||
"id": "event_123",
|
||||
"start": "2025-10-01 14:00:00",
|
||||
"end": "2025-10-01 16:00:00",
|
||||
"presentation": {
|
||||
"files": [
|
||||
{
|
||||
"name": "slides.pptx",
|
||||
"url": "https://server/files/slides.pptx"
|
||||
}
|
||||
],
|
||||
"auto_advance": true,
|
||||
"slide_interval": 10,
|
||||
"loop": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Slides advance every 10 seconds, presentation loops infinitely until event ends.
|
||||
|
||||
#### Single Playthrough
|
||||
```json
|
||||
{
|
||||
"presentation": {
|
||||
"files": [{"name": "welcome.pptx"}],
|
||||
"auto_advance": true,
|
||||
"slide_interval": 5,
|
||||
"loop": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Slides advance every 5 seconds, exits after last slide.
|
||||
|
||||
### Presentation Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `auto_advance` | boolean | `false` | Enable automatic slide advancement |
|
||||
| `slide_interval` | integer | `10` | Seconds between slides |
|
||||
| `loop` | boolean | `false` | Loop presentation vs. quit after last slide |
|
||||
|
||||
### Scheduler-Specific Fields
|
||||
|
||||
The scheduler may send additional fields that are preserved in `current_event.json`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `page_progress` | boolean | Show overall progress bar in presentation (Impressive `--page-progress`). Can be provided at `presentation.page_progress` (preferred) or top-level. |
|
||||
| `auto_progress` | boolean | Show per-page auto-advance countdown (Impressive `--auto-progress`). Can be provided at `presentation.auto_progress` (preferred) or top-level. |
|
||||
| `occurrence_of_id` | integer | Original event ID for recurring events |
|
||||
| `recurrence_rule` | string | iCal recurrence rule (RRULE format) |
|
||||
| `recurrence_end` | string | End date for recurring events |
|
||||
|
||||
**Note:** All fields from the scheduler are automatically preserved when events are stored in `current_event.json`. The client does not filter or modify scheduler-specific metadata.
|
||||
|
||||
#### Progress Bar Display
|
||||
|
||||
When using Impressive PDF presenter:
|
||||
- `page_progress: true` - Shows a progress bar at the bottom indicating position in the presentation
|
||||
- `auto_progress: true` - Shows a countdown progress bar for each slide during auto-advance
|
||||
- Both options can be enabled simultaneously for maximum visual feedback
|
||||
|
||||
## 🎥 Video Events
|
||||
|
||||
```json
|
||||
{
|
||||
"video": {
|
||||
"url": "https://server/videos/intro.mp4",
|
||||
"loop": true,
|
||||
"autoplay": true,
|
||||
"volume": 0.8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The Display Manager prefers `python-vlc` (libvlc) when available. This gives programmatic control over playback (autoplay, loop, volume) and ensures the player is cleanly stopped and released when events end.
|
||||
- Supported video event fields:
|
||||
- `url` (string): HTTP/HTTPS or streaming URL. URLs using the placeholder host `server` are rewritten to the configured file server (see File/API Server configuration).
|
||||
- `autoplay` (boolean): start playback automatically when the event becomes active (default: true).
|
||||
- `loop` (boolean): loop playback indefinitely.
|
||||
- `volume` (float): 0.0–1.0 (mapped internally to VLC's 0–100 volume scale).
|
||||
- Effective playback volume is calculated as `event.video.volume * client_config.audio.video_volume_multiplier` and then mapped to VLC's 0–100 scale. Example: `volume: 0.8` with `audio.video_volume_multiplier: 0.5` results in 40% VLC volume.
|
||||
- If `python-vlc` is not installed, the Display Manager will fall back to launching the external `vlc` binary.
|
||||
- External VLC audio rendering behavior:
|
||||
- When `muted: true` (or effective volume resolves to 0), fallback starts VLC with `--no-audio`.
|
||||
- When not muted, fallback applies startup loudness with `--gain=<0.00-1.00>` derived from effective volume.
|
||||
- Runtime volume updates are best-effort in `python-vlc` mode; external VLC fallback is startup-parameter based.
|
||||
- HDMI-CEC remains the recommended mechanism for TV power control only. TV volume via CEC is not implemented because support is device-dependent and much less reliable than controlling VLC directly.
|
||||
- The client-wide multiplier is intended to be sent over the existing MQTT config topic `infoscreen/{client_id}/config` and is persisted locally in `src/config/client_settings.json` for the Display Manager.
|
||||
- Fullscreen behavior:
|
||||
- External VLC fallback uses `--fullscreen`.
|
||||
- `python-vlc` mode enforces fullscreen on startup and retries fullscreen toggling briefly because video outputs may attach asynchronously.
|
||||
- For a truly panel-free fullscreen (no taskbar), run the Display Manager inside a minimal kiosk X session or a dedicated user session without a desktop panel.
|
||||
- Monitoring PID behavior:
|
||||
- External VLC fallback reports the external `vlc` process PID.
|
||||
- `python-vlc` mode is in-process, so monitoring reports the `display_manager.py` runtime PID.
|
||||
|
||||
## 🌐 Web Events
|
||||
|
||||
```json
|
||||
{
|
||||
"web": {
|
||||
"url": "https://dashboard.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Opens webpage in Chromium kiosk mode (fullscreen, no UI).
|
||||
|
||||
## 🗂️ Project Structure
|
||||
|
||||
```
|
||||
infoscreen-dev/
|
||||
├── .env # Environment configuration
|
||||
├── README.md # This file
|
||||
├── IMPRESSIVE_INTEGRATION.md # Detailed presentation system docs
|
||||
├── src/
|
||||
│ ├── simclient.py # MQTT client (events, heartbeat, discovery)
|
||||
│ ├── display_manager.py # Display controller (manages applications)
|
||||
│ ├── requirements.txt # Python dependencies
|
||||
│ ├── current_event.json # Current active event (runtime)
|
||||
│ ├── config/ # Persistent client data
|
||||
│ │ ├── client_uuid.txt
|
||||
│ │ └── last_group_id.txt
|
||||
│ ├── presentation/ # Downloaded presentation files
|
||||
│ └── screenshots/ # Dashboard screenshots
|
||||
├── scripts/
|
||||
│ ├── start-dev.sh # Start development client
|
||||
│ ├── start-display-manager.sh # Start Display Manager
|
||||
│ ├── test-display-manager.sh # Interactive testing
|
||||
│ ├── test-impressive.sh # Test Impressive (auto-quit mode)
|
||||
│ ├── test-impressive-loop.sh # Test Impressive (loop mode)
|
||||
│ ├── test-mqtt.sh # Test MQTT connectivity
|
||||
│ ├── test-screenshot.sh # Test screenshot capture
|
||||
│ ├── test-utc-timestamps.sh # Test event timing
|
||||
│ └── present-pdf-auto-advance.sh # PDF presentation wrapper
|
||||
└── logs/ # Application logs
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Display Manager
|
||||
|
||||
```bash
|
||||
./scripts/test-display-manager.sh
|
||||
```
|
||||
|
||||
Interactive menu for testing:
|
||||
- Check Display Manager status
|
||||
- Create test events (presentation, video, webpage)
|
||||
- View active processes
|
||||
- Cycle through different event types
|
||||
|
||||
### Test Impressive Presentation
|
||||
|
||||
**Single playthrough (auto-quit):**
|
||||
```bash
|
||||
./scripts/test-impressive.sh
|
||||
```
|
||||
|
||||
**Loop mode (infinite):**
|
||||
```bash
|
||||
./scripts/test-impressive-loop.sh
|
||||
```
|
||||
|
||||
### Test MQTT Connectivity
|
||||
|
||||
```bash
|
||||
./scripts/test-mqtt.sh
|
||||
```
|
||||
|
||||
Verifies MQTT broker connectivity and topic access.
|
||||
|
||||
### Test Screenshot Capture
|
||||
|
||||
```bash
|
||||
./scripts/test-screenshot.sh
|
||||
```
|
||||
|
||||
Captures test screenshot for dashboard monitoring.
|
||||
|
||||
**Manual test:**
|
||||
```bash
|
||||
export SCREENSHOT_ALWAYS=1
|
||||
export SCREENSHOT_CAPTURE_INTERVAL=5
|
||||
python3 src/display_manager.py &
|
||||
sleep 15
|
||||
ls -lh src/screenshots/
|
||||
```
|
||||
|
||||
## 🔧 Configuration Details
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All configuration is done via `.env` file in the project root. Copy `.env.template` to `.env` and adjust values for your environment.
|
||||
|
||||
#### Environment
|
||||
- `ENV` - Environment mode: `development` or `production`
|
||||
- **Important:** CEC TV control is automatically disabled in `development` mode
|
||||
- `DEBUG_MODE` - Enable debug output: `1` (on) or `0` (off)
|
||||
- `LOG_LEVEL` - Logging verbosity: `DEBUG`, `INFO`, `WARNING`, or `ERROR`
|
||||
|
||||
#### MQTT Configuration
|
||||
- `MQTT_BROKER` - **Required.** MQTT broker IP address or hostname
|
||||
- `MQTT_PORT` - MQTT broker port (default: `1883`)
|
||||
- `MQTT_USERNAME` - Optional. MQTT authentication username (if broker requires it)
|
||||
- `MQTT_PASSWORD` - Optional. MQTT authentication password (if broker requires it)
|
||||
|
||||
#### Timing Configuration (seconds)
|
||||
- `HEARTBEAT_INTERVAL` - How often client sends status updates to server (default: `60`)
|
||||
- `SCREENSHOT_INTERVAL` - How often simclient transmits screenshots via MQTT (default: `180`)
|
||||
- `SCREENSHOT_CAPTURE_INTERVAL` - How often display_manager captures screenshots (default: `180`)
|
||||
- `DISPLAY_CHECK_INTERVAL` - How often display_manager checks for new events (default: `15`)
|
||||
|
||||
#### Screenshot Configuration
|
||||
- `SCREENSHOT_ALWAYS` - Force screenshot capture even when no display is active
|
||||
- `0` - In production: capture only when a display process is active; in development: periodic idle captures are allowed so dashboard stays fresh
|
||||
- `1` - Always capture screenshots (useful for testing)
|
||||
|
||||
#### File/API Server Configuration
|
||||
These settings control how the client downloads presentation files and other content.
|
||||
## Runtime Model
|
||||
|
||||
- `FILE_SERVER_HOST` - Optional. File server hostname/IP. Defaults to `MQTT_BROKER` if empty
|
||||
- `FILE_SERVER_PORT` - File server port (default: `8000`)
|
||||
- `FILE_SERVER_SCHEME` - Protocol: `http` or `https` (default: `http`)
|
||||
- `FILE_SERVER_BASE_URL` - Optional. Full base URL override (e.g., `http://192.168.1.100:8000`)
|
||||
- When set, this takes precedence over HOST/PORT/SCHEME settings
|
||||
The client runs as two cooperating processes:
|
||||
|
||||
#### HDMI-CEC TV Control (Optional)
|
||||
Automatic TV power management based on event scheduling.
|
||||
- `src/simclient.py`: MQTT communication, discovery, heartbeats, event ingestion, dashboard publishing, power intent intake.
|
||||
- `src/display_manager.py`: display orchestration, HDMI-CEC, screenshots, local runtime health state.
|
||||
|
||||
- `CEC_ENABLED` - Enable automatic TV control: `true` or `false`
|
||||
- **Note:** Automatically disabled when `ENV=development` to avoid TV cycling during testing
|
||||
- `CEC_DEVICE` - Target CEC device address (recommended: `0` for TV)
|
||||
- `CEC_TURN_OFF_DELAY` - Seconds to wait before turning off TV after last event ends (default: `30`)
|
||||
- `CEC_POWER_ON_WAIT` - Seconds to wait after power ON command for TV to boot (default: `5`)
|
||||
- `CEC_POWER_OFF_WAIT` - Seconds to wait after power OFF command (default: `5`, increase for slower TVs)
|
||||
Important runtime files:
|
||||
|
||||
### File Server URL Resolution
|
||||
- `src/current_event.json`: active event from scheduler.
|
||||
- `src/current_process_health.json`: process health bridge for dashboard and monitoring.
|
||||
- `src/power_intent_state.json`: latest validated MQTT power intent.
|
||||
- `src/power_state.json`: last applied power action telemetry.
|
||||
- `src/screenshots/`: shared screenshot directory.
|
||||
|
||||
The MQTT client ([src/simclient.py](src/simclient.py)) downloads presentation files and videos from the configured file server.
|
||||
## Content Types
|
||||
|
||||
**URL Rewriting:**
|
||||
- Event URLs using placeholder host `server` (e.g., `http://server:8000/...`) are automatically rewritten to the configured file server
|
||||
- By default, file server = `MQTT_BROKER` host with port `8000` and `http` scheme
|
||||
- Use `FILE_SERVER_BASE_URL` for complete override, or set individual HOST/PORT/SCHEME variables
|
||||
### Presentations
|
||||
|
||||
**Best practices:**
|
||||
- Keep inline comments in `.env` after a space and `#` to avoid parsing issues
|
||||
- Match the scheme (`http`/`https`) to your actual server configuration
|
||||
- For HTTPS or non-standard ports, explicitly set `FILE_SERVER_SCHEME` and `FILE_SERVER_PORT`
|
||||
Presentations are rendered server-side to PDF and displayed with Impressive. Auto-advance, loop, page progress, and auto-progress are supported.
|
||||
|
||||
### MQTT Topics
|
||||
See [IMPRESSIVE_INTEGRATION.md](IMPRESSIVE_INTEGRATION.md) for full behavior, event examples, and troubleshooting.
|
||||
|
||||
#### Client → Server
|
||||
- `infoscreen/discovery` - Initial client announcement
|
||||
- `infoscreen/{client_id}/heartbeat` - Regular status updates
|
||||
- `infoscreen/{client_id}/dashboard` - Screenshot images (base64)
|
||||
- `infoscreen/{client_id}/health` - Process health state (`event_id`, process, pid, status)
|
||||
- `infoscreen/{client_id}/logs/error` - Forwarded client error logs
|
||||
- `infoscreen/{client_id}/logs/warn` - Forwarded client warning logs
|
||||
### Videos
|
||||
|
||||
#### Server → Client
|
||||
- `infoscreen/{client_id}/discovery_ack` - Server response with client ID
|
||||
- `infoscreen/{client_id}/group_id` - Group assignment
|
||||
- `infoscreen/events/{group_id}` - Event commands for group
|
||||
Video events support:
|
||||
|
||||
### Client Identification
|
||||
- `url`
|
||||
- `autoplay`
|
||||
- `loop`
|
||||
- `volume`
|
||||
|
||||
**Hardware Token:** SHA256 hash of:
|
||||
- CPU serial number
|
||||
- MAC addresses (all network interfaces)
|
||||
The Display Manager prefers `python-vlc`; if unavailable it falls back to the external VLC binary.
|
||||
|
||||
**Persistent UUID:** Stored in `src/config/client_uuid.txt`
|
||||
### Web Pages
|
||||
|
||||
**Group Membership:** Stored in `src/config/last_group_id.txt`
|
||||
Web and WebUntis events are displayed in Chromium kiosk mode.
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
## TV Power Intent
|
||||
|
||||
### Display Manager doesn't start presentations
|
||||
Phase 1 TV power coordination uses the group topic:
|
||||
|
||||
**Check Impressive installation:**
|
||||
```bash
|
||||
which impressive
|
||||
# If not found: sudo apt-get install impressive
|
||||
```
|
||||
- `infoscreen/groups/{group_id}/power/intent`
|
||||
|
||||
**Check logs:**
|
||||
```bash
|
||||
tail -f logs/display_manager.log
|
||||
```
|
||||
Key references:
|
||||
|
||||
**Check disk space:**
|
||||
```bash
|
||||
df -h
|
||||
```
|
||||
- Frozen contract: [TV_POWER_INTENT_SERVER_CONTRACT_V1.md](TV_POWER_INTENT_SERVER_CONTRACT_V1.md)
|
||||
- Rollout and canary testing: [TV_POWER_RUNBOOK.md](TV_POWER_RUNBOOK.md)
|
||||
- Client implementation handoff: [TV_POWER_HANDOFF_CLIENT.md](TV_POWER_HANDOFF_CLIENT.md)
|
||||
|
||||
**Note:** PPTX conversion happens server-side via Gotenberg. The client only receives and displays pre-rendered PDF files.
|
||||
## Testing
|
||||
|
||||
### Slides don't auto-advance
|
||||
Use the helper scripts in `scripts/` for focused tests:
|
||||
|
||||
**Verify event JSON:**
|
||||
- `auto_advance: true` is set
|
||||
- `slide_interval` is specified (default: 10)
|
||||
- `./scripts/test-display-manager.sh`: event and process testing.
|
||||
- `./scripts/test-impressive.sh`: single-play presentation.
|
||||
- `./scripts/test-impressive-loop.sh`: looping presentation.
|
||||
- `./scripts/test-mqtt.sh`: MQTT broker connectivity.
|
||||
- `./scripts/test-reboot-command.sh`: end-to-end reboot/shutdown command lifecycle canary (`accepted -> execution_started -> completed/failed`).
|
||||
- `./scripts/test-screenshot.sh`: screenshot capture.
|
||||
- `./scripts/test-hdmi-cec.sh`: HDMI-CEC diagnostics and runtime state inspection.
|
||||
- `./scripts/test-power-intent.sh`: MQTT power intent publishing, rejection tests, and telemetry checks.
|
||||
|
||||
**Test Impressive directly:**
|
||||
```bash
|
||||
./scripts/test-impressive.sh
|
||||
```
|
||||
## Troubleshooting
|
||||
|
||||
### Presentation doesn't loop
|
||||
Use the specialist docs instead of treating this file as the full troubleshooting manual:
|
||||
|
||||
**Verify event JSON:**
|
||||
- `loop: true` is set
|
||||
- Presentation and Impressive issues: [IMPRESSIVE_INTEGRATION.md](IMPRESSIVE_INTEGRATION.md)
|
||||
- HDMI-CEC setup and TV control: [HDMI_CEC_SETUP.md](HDMI_CEC_SETUP.md)
|
||||
- Screenshot race condition and metadata sync: [SCREENSHOT_MQTT_FIX.md](SCREENSHOT_MQTT_FIX.md)
|
||||
- Monitoring and dashboard behavior: [CLIENT_MONITORING_SETUP.md](CLIENT_MONITORING_SETUP.md)
|
||||
- Developer-oriented MQTT/event details: [src/README.md](src/README.md)
|
||||
|
||||
**Test loop mode:**
|
||||
```bash
|
||||
./scripts/test-impressive-loop.sh
|
||||
```
|
||||
Quick checks:
|
||||
|
||||
### File downloads fail
|
||||
- Follow logs: `tail -f logs/display_manager.log src/simclient.log`
|
||||
- Inspect screenshots: `ls -lh src/screenshots/`
|
||||
- Inspect power state: `cat src/power_intent_state.json` and `cat src/power_state.json`
|
||||
- Restart services (systemd): `sudo systemctl restart infoscreen-simclient infoscreen-display`
|
||||
- Restart services (dev): `./scripts/restart-all.sh`
|
||||
|
||||
Symptoms:
|
||||
- `Failed to resolve 'server'` or `NameResolutionError` when downloading files
|
||||
- `Invalid URL 'http # http or https://...'` in `logs/simclient.log`
|
||||
## Deployment
|
||||
|
||||
What to check:
|
||||
- Look for lines like `Lade Datei herunter von:` in `logs/simclient.log` to see the effective URL used
|
||||
- Ensure the URL host is the MQTT broker IP (or your configured file server), not `server`
|
||||
- Verify `.env` values don’t include inline comments as part of the value (e.g., keep `FILE_SERVER_SCHEME=http` on its own line)
|
||||
For production you typically run both `simclient.py` and `display_manager.py` via systemd or Docker.
|
||||
|
||||
Fixes:
|
||||
- If your API is on the same host as the broker: leave `FILE_SERVER_HOST` empty (defaults to `MQTT_BROKER`), keep `FILE_SERVER_PORT=8000`, and set `FILE_SERVER_SCHEME=http` or `https`
|
||||
- To override fully, set `FILE_SERVER_BASE_URL` (e.g., `http://192.168.1.100:8000`); this takes precedence over host/port/scheme
|
||||
- After changing `.env`, restart the simclient process
|
||||
- Container setup: [src/CONTAINER_TRANSITION.md](src/CONTAINER_TRANSITION.md)
|
||||
- Production compose file: [src/docker-compose.production.yml](src/docker-compose.production.yml)
|
||||
- Display manager architecture: [src/DISPLAY_MANAGER.md](src/DISPLAY_MANAGER.md)
|
||||
|
||||
Expected healthy log sequence:
|
||||
- `Lade Datei herunter von: http://<broker-ip>:8000/...`
|
||||
- Followed by `"GET /... HTTP/1.1" 200` and `Datei erfolgreich heruntergeladen:`
|
||||
If running directly on the host, ensure:
|
||||
|
||||
### VLC hardware decode / renderer issues
|
||||
- the display session is available (`DISPLAY` / `XAUTHORITY` for X11),
|
||||
- the screenshot tools for your session type are installed,
|
||||
- `ENV=production` is set when you want HDMI-CEC active.
|
||||
|
||||
If you see messages like:
|
||||
## Documentation Map
|
||||
|
||||
```
|
||||
[h264_v4l2m2m @ ...] Could not find a valid device
|
||||
[h264_v4l2m2m @ ...] can't configure decoder
|
||||
[... ] avcodec decoder error: cannot start codec (h264_v4l2m2m)
|
||||
```
|
||||
### Operator / Deployment
|
||||
|
||||
that indicates libVLC / ffmpeg attempted to use the platform V4L2 M2M hardware decoder but the kernel/device isn't available. Options to resolve:
|
||||
- [QUICK_REFERENCE.md](QUICK_REFERENCE.md)
|
||||
- [HDMI_CEC_SETUP.md](HDMI_CEC_SETUP.md)
|
||||
- [TV_POWER_RUNBOOK.md](TV_POWER_RUNBOOK.md)
|
||||
- [CLIENT_MONITORING_SETUP.md](CLIENT_MONITORING_SETUP.md)
|
||||
- [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
- Enable the V4L2 M2M codec driver on the system (platform-specific; on Raspberry Pi ensure correct kernel/firmware and codec modules are loaded). Check `v4l2-ctl --list-devices` and `ls /dev/video*` after installing `v4l-utils`.
|
||||
- Disable hardware decoding so libVLC/ffmpeg uses software decoding (reliable but higher CPU). You can test this by launching the `vlc` binary with:
|
||||
### Feature-Specific
|
||||
|
||||
```bash
|
||||
vlc --avcodec-hw=none 'http://<your-video-url>'
|
||||
```
|
||||
- [IMPRESSIVE_INTEGRATION.md](IMPRESSIVE_INTEGRATION.md)
|
||||
- [SCREENSHOT_MQTT_FIX.md](SCREENSHOT_MQTT_FIX.md)
|
||||
- [SCHEDULER_FIELDS_SUPPORT.md](SCHEDULER_FIELDS_SUPPORT.md)
|
||||
- [SERVER_VOLUME_CONTROL_SETUP.md](SERVER_VOLUME_CONTROL_SETUP.md)
|
||||
|
||||
Or modify `src/display_manager.py` to create the libVLC instance with software-decoding forced:
|
||||
### Development / Internal
|
||||
|
||||
```python
|
||||
instance = vlc.Instance('--avcodec-hw=none', '--no-video-title-show', '--no-video-deco')
|
||||
```
|
||||
- [src/README.md](src/README.md)
|
||||
- [src/DISPLAY_MANAGER.md](src/DISPLAY_MANAGER.md)
|
||||
- [src/IMPLEMENTATION_SUMMARY.md](src/IMPLEMENTATION_SUMMARY.md)
|
||||
- [TV_POWER_COORDINATION_TASKLIST.md](TV_POWER_COORDINATION_TASKLIST.md)
|
||||
- [TV_POWER_HANDOFF_SERVER.md](TV_POWER_HANDOFF_SERVER.md)
|
||||
- [SERVER_TEAM_ACTIONS.md](SERVER_TEAM_ACTIONS.md)
|
||||
|
||||
This is the fastest workaround if hardware decode is not required or not available on the device.
|
||||
## Contributing
|
||||
|
||||
### MQTT connection issues
|
||||
Before changing runtime behavior:
|
||||
|
||||
**Test broker connectivity:**
|
||||
```bash
|
||||
./scripts/test-mqtt.sh
|
||||
```
|
||||
- test with the relevant helper scripts,
|
||||
- verify logs stay clean,
|
||||
- update the specialist doc for the feature you changed.
|
||||
|
||||
### MQTT reconnect and heartbeat behavior
|
||||
When editing AI assistant guidance files:
|
||||
|
||||
- On reconnect, the client re-subscribes all topics in `on_connect` and re-sends discovery to re-register.
|
||||
- Heartbeats are sent only when connected. During brief reconnect windows, Paho may return rc=4 (`NO_CONN`).
|
||||
- A single rc=4 warning after broker restarts or short network stalls is expected; the next heartbeat usually succeeds.
|
||||
- Investigate only if rc=4 repeats across multiple intervals without subsequent successful heartbeat logs.
|
||||
- keep `.github/copilot-instructions.md` policy-focused,
|
||||
- follow its "Instruction File Design Rules" section,
|
||||
- avoid turning it into a shadow README.
|
||||
|
||||
### Monitoring and UTC timestamps
|
||||
Recent project history is tracked in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
Client-side monitoring is implemented with a health-state bridge between `display_manager.py` and `simclient.py`.
|
||||
|
||||
- Health bridge file: `src/current_process_health.json`
|
||||
- Local monitoring log: `logs/monitoring.log`
|
||||
- Process states: `running`, `crashed`, `stopped`
|
||||
- Restart tracking: bounded restart attempts per active event
|
||||
|
||||
UTC timestamp policy:
|
||||
|
||||
- `display_manager.log`, `simclient.log`, and `monitoring.log` are written in UTC (`...Z`)
|
||||
- MQTT payload timestamps (heartbeat/dashboard/health/log messages) are UTC ISO timestamps
|
||||
- Screenshot metadata timestamps are UTC ISO timestamps
|
||||
|
||||
This prevents daylight-saving and timezone drift issues across clients.
|
||||
|
||||
### VLC/PulseAudio warnings in remote sessions
|
||||
|
||||
Warnings such as `pulse audio output error: overflow, flushing` can appear when testing through remote desktop/audio forwarding (for example, NoMachine) or virtual/dummy display setups.
|
||||
|
||||
- If playback and audio are stable on a real HDMI display, this is usually non-fatal.
|
||||
- If warnings appear only in remote sessions, treat them as environment-related rather than a core video playback bug.
|
||||
|
||||
### Screenshots not uploading
|
||||
|
||||
**Check which session type you're running:**
|
||||
```bash
|
||||
echo $WAYLAND_DISPLAY # Set if Wayland
|
||||
echo $DISPLAY # Set if X11
|
||||
echo $XAUTHORITY # Should point to ~/.Xauthority for X11 captures
|
||||
```
|
||||
|
||||
If `DISPLAY` is empty for non-interactive starts (systemd/nohup/ssh), the display manager now falls back to `:0` and tries `~/.Xauthority` automatically.
|
||||
|
||||
**Install appropriate screenshot tool:**
|
||||
```bash
|
||||
# For X11:
|
||||
sudo apt-get install scrot imagemagick
|
||||
|
||||
# For Wayland:
|
||||
sudo apt-get install grim gnome-screenshot
|
||||
```
|
||||
|
||||
**Test screenshot capture:**
|
||||
```bash
|
||||
export SCREENSHOT_ALWAYS=1 # Force capture even without active event
|
||||
./scripts/test-screenshot.sh
|
||||
ls -lh src/screenshots/
|
||||
```
|
||||
|
||||
**Check logs for session detection:**
|
||||
```bash
|
||||
tail -f logs/display_manager.log | grep -i screenshot
|
||||
# Should show: "Screenshot session=wayland" or "Screenshot session=x11"
|
||||
```
|
||||
|
||||
**If you see stale dashboard images after restarts:**
|
||||
```bash
|
||||
cat src/screenshots/meta.json
|
||||
stat src/screenshots/latest.jpg
|
||||
```
|
||||
|
||||
- If `send_immediately` is stuck `true` for old metadata, restart both processes so simclient consumes and clears it.
|
||||
- If `latest.jpg` timestamp does not move while new `screenshot_*.jpg` files appear, update to latest code (fix for periodic `latest.jpg` update path) and restart display_manager.
|
||||
|
||||
**Verify simclient is reading screenshots:**
|
||||
```bash
|
||||
tail -f logs/simclient.log | grep -i screenshot
|
||||
# Should show: "Dashboard heartbeat sent with screenshot: latest.jpg"
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **IMPRESSIVE_INTEGRATION.md** - Detailed presentation system documentation
|
||||
- **HDMI_CEC_SETUP.md** - HDMI-CEC setup and troubleshooting
|
||||
- **src/DISPLAY_MANAGER.md** - Display Manager architecture
|
||||
- **src/IMPLEMENTATION_SUMMARY.md** - Implementation overview
|
||||
- **src/README.md** - MQTT client documentation
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- Hardware-based client identification (non-spoofable)
|
||||
- Configurable MQTT authentication
|
||||
- Local-only file storage
|
||||
- No sensitive data in logs
|
||||
|
||||
## 🚢 Production Deployment
|
||||
|
||||
### Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/infoscreen-display.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Infoscreen Display Manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=olafn
|
||||
WorkingDirectory=/home/olafn/infoscreen-dev/src
|
||||
Environment="DISPLAY=:0"
|
||||
Environment="XAUTHORITY=/home/olafn/.Xauthority"
|
||||
ExecStart=/home/olafn/infoscreen-dev/venv/bin/python3 /home/olafn/infoscreen-dev/src/display_manager.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable infoscreen-display
|
||||
sudo systemctl start infoscreen-display
|
||||
sudo systemctl status infoscreen-display
|
||||
```
|
||||
|
||||
### Auto-start on Boot
|
||||
|
||||
Both services (simclient.py and display_manager.py) should start automatically:
|
||||
|
||||
1. **simclient.py** - MQTT communication, event management
|
||||
2. **display_manager.py** - Display application controller
|
||||
|
||||
Create similar systemd service for simclient.py.
|
||||
|
||||
### Docker Deployment (Alternative)
|
||||
|
||||
```bash
|
||||
docker-compose -f src/docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
See `src/CONTAINER_TRANSITION.md` for details.
|
||||
|
||||
## 📝 Development
|
||||
|
||||
### Development Mode
|
||||
|
||||
Set in `.env`:
|
||||
```bash
|
||||
ENV=development
|
||||
DEBUG_MODE=1
|
||||
LOG_LEVEL=DEBUG
|
||||
HEARTBEAT_INTERVAL=10
|
||||
```
|
||||
|
||||
### Start Development Client
|
||||
|
||||
```bash
|
||||
./scripts/start-dev.sh
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# Display Manager
|
||||
tail -f logs/display_manager.log
|
||||
|
||||
# MQTT Client
|
||||
tail -f logs/simclient.log
|
||||
|
||||
# Both
|
||||
tail -f logs/*.log
|
||||
```
|
||||
|
||||
## 📺 HDMI-CEC TV Control
|
||||
|
||||
The system includes automatic TV power control via HDMI-CEC. The TV turns on when events start and turns off (with delay) when no events are active.
|
||||
|
||||
### Development mode behavior
|
||||
|
||||
- When `ENV=development`, HDMI-CEC is automatically disabled by the Display Manager to avoid constantly switching the TV during development.
|
||||
- The test script `scripts/test-hdmi-cec.sh` also respects this: menu option 5 (Display Manager CEC integration) will detect development mode and skip the integration test. Manual options (1–4) still work for direct cec-client testing.
|
||||
|
||||
To test CEC end-to-end, temporarily set `ENV=production` in `.env` and restart the Display Manager, or use the manual commands in the test script.
|
||||
|
||||
### Quick Setup
|
||||
|
||||
```bash
|
||||
# Install CEC utilities
|
||||
sudo apt-get install cec-utils
|
||||
|
||||
# Test CEC connection
|
||||
echo "scan" | cec-client -s -d 1
|
||||
|
||||
# Configure in .env
|
||||
CEC_ENABLED=true
|
||||
CEC_DEVICE=0 # Use 0 for best performance
|
||||
CEC_TURN_OFF_DELAY=30
|
||||
CEC_POWER_ON_WAIT=5 # Adjust if TV is slow to boot
|
||||
CEC_POWER_OFF_WAIT=2
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Auto Power On**: TV turns on when event starts
|
||||
- **Auto Power Off**: TV turns off after configurable delay when events end
|
||||
- **Smart Switching**: TV stays on when switching between events
|
||||
- **Configurable Delay**: Prevent rapid on/off cycles
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
echo "on 0" | cec-client -s -d 1 # Turn on
|
||||
echo "standby 0" | cec-client -s -d 1 # Turn off
|
||||
echo "pow 0" | cec-client -s -d 1 # Check status
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Test changes with `./scripts/test-display-manager.sh`
|
||||
2. Verify MQTT communication with `./scripts/test-mqtt.sh`
|
||||
3. Update documentation
|
||||
4. Submit pull request
|
||||
|
||||
## 📄 License
|
||||
## License
|
||||
|
||||
[Add your license here]
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check logs in `logs/` directory
|
||||
2. Review troubleshooting section
|
||||
3. Test individual components with test scripts
|
||||
4. Check MQTT broker connectivity
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** March 2026
|
||||
**Status:** ✅ Production Ready
|
||||
**Tested On:** Raspberry Pi 5, Raspberry Pi OS (Bookworm)
|
||||
|
||||
## Recent Changes
|
||||
|
||||
### November 2025
|
||||
|
||||
- Screenshot pipeline implemented with a two-process model (`display_manager.py` capture, `simclient.py` transmission).
|
||||
- Wayland/X11 screenshot tool fallback chains added.
|
||||
- Dashboard payload format extended with screenshot and system metadata.
|
||||
- Scheduler event type support extended (`presentation`, `webuntis`, `webpage`, `website`).
|
||||
- Website autoscroll support added (CDP injection + extension fallback).
|
||||
|
||||
### March 2026
|
||||
|
||||
- Event-trigger screenshots (`event_start`, `event_stop`) hardened against periodic overwrite races.
|
||||
- `latest.jpg` and `meta.json` synchronization improved for reliable dashboard updates.
|
||||
- Stale/invalid pending trigger metadata now self-heals instead of blocking periodic updates.
|
||||
- Display environment fallbacks (`DISPLAY=:0`, `XAUTHORITY`) improved for non-interactive starts.
|
||||
- Development mode allows periodic idle captures to keep dashboard previews fresh when no event is active.
|
||||
|
||||
127
SERVER_TEAM_ACTIONS.md
Normal file
127
SERVER_TEAM_ACTIONS.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Server Team Action Items — Infoscreen Client
|
||||
|
||||
This document lists everything the server/infrastructure/frontend team must implement to complete the client integration. The client-side code is production-ready for all items listed here.
|
||||
|
||||
---
|
||||
|
||||
## 1. MQTT Broker Hardening (prerequisite for everything else)
|
||||
|
||||
- Disable anonymous access on the broker.
|
||||
- Create one broker account **per client device**:
|
||||
- Username convention: `infoscreen-client-<uuid-prefix>` (e.g. `infoscreen-client-9b8d1856`)
|
||||
- Provision the password to the device `.env` as `MQTT_PASSWORD_BROKER=`
|
||||
- Create a **server/publisher account** (e.g. `infoscreen-server`) for all server-side publishes.
|
||||
- Enforce ACLs:
|
||||
|
||||
| Topic | Publisher |
|
||||
|---|---|
|
||||
| `infoscreen/{uuid}/commands` | server only |
|
||||
| `infoscreen/{uuid}/command` (alias) | server only |
|
||||
| `infoscreen/{uuid}/group_id` | server only |
|
||||
| `infoscreen/events/{group_id}` | server only |
|
||||
| `infoscreen/groups/+/power/intent` | server only |
|
||||
| `infoscreen/{uuid}/commands/ack` | client only |
|
||||
| `infoscreen/{uuid}/command/ack` | client only |
|
||||
| `infoscreen/{uuid}/heartbeat` | client only |
|
||||
| `infoscreen/{uuid}/health` | client only |
|
||||
| `infoscreen/{uuid}/logs/#` | client only |
|
||||
| `infoscreen/{uuid}/service_failed` | client only |
|
||||
|
||||
---
|
||||
|
||||
## 2. Reboot / Shutdown Command — Ack Lifecycle
|
||||
|
||||
Client publishes ack status updates to two topics per command (canonical + transitional alias):
|
||||
- `infoscreen/{uuid}/commands/ack`
|
||||
- `infoscreen/{uuid}/command/ack`
|
||||
|
||||
**Ack payload schema (v1, frozen):**
|
||||
```json
|
||||
{
|
||||
"command_id": "07aab032-53c2-45ef-a5a3-6aa58e9d9fae",
|
||||
"status": "accepted | execution_started | completed | failed",
|
||||
"error_code": null,
|
||||
"error_message": null
|
||||
}
|
||||
```
|
||||
|
||||
**Status lifecycle:**
|
||||
|
||||
| Status | When | Notes |
|
||||
|---|---|---|
|
||||
| `accepted` | Command received and validated | Immediate |
|
||||
| `execution_started` | Helper invoked | Immediate after accepted |
|
||||
| `completed` | Execution confirmed | For `reboot_host`: arrives after reconnect (10–90 s after `execution_started`) |
|
||||
| `failed` | Helper returned error | `error_code` and `error_message` will be set |
|
||||
|
||||
**Server must:**
|
||||
- Track `command_id` through the full lifecycle and update status in DB/UI.
|
||||
- Surface `failed` + `error_code` to the operator UI.
|
||||
- Expect `reboot_host` `completed` to arrive after a reconnect delay — do not treat the gap as a timeout.
|
||||
- Use `expires_at` from the original command to determine when to abandon waiting.
|
||||
|
||||
---
|
||||
|
||||
## 3. Health Dashboard — Broker Connection Fields (Gap 2)
|
||||
|
||||
Every `infoscreen/{uuid}/health` payload now includes a `broker_connection` block:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-04-05T08:00:00.000000+00:00",
|
||||
"expected_state": { "event_id": 42 },
|
||||
"actual_state": {
|
||||
"process": "display_manager",
|
||||
"pid": 1234,
|
||||
"status": "running"
|
||||
},
|
||||
"broker_connection": {
|
||||
"broker_reachable": true,
|
||||
"reconnect_count": 2,
|
||||
"last_disconnect_at": "2026-04-04T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server must:**
|
||||
- Display `reconnect_count` and `last_disconnect_at` per device in the health dashboard.
|
||||
- Implement alerting heuristic:
|
||||
- **All** clients go silent simultaneously → likely broker outage, not device crash.
|
||||
- **Single** client goes silent → device crash, network failure, or process hang.
|
||||
|
||||
---
|
||||
|
||||
## 4. Service-Failed MQTT Notification (Gap 3)
|
||||
|
||||
When systemd gives up restarting a service after repeated crashes (`StartLimitBurst` exceeded), the client automatically publishes a **retained** message:
|
||||
|
||||
**Topic:** `infoscreen/{uuid}/service_failed`
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"event": "service_failed",
|
||||
"unit": "infoscreen-simclient.service",
|
||||
"client_uuid": "9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||
"failed_at": "2026-04-05T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Server must:**
|
||||
- Subscribe to `infoscreen/+/service_failed` on startup (retained — message survives broker restart).
|
||||
- Alert the operator immediately when this topic receives a payload.
|
||||
- **Clear the retained message** once the device is acknowledged or recovered:
|
||||
```
|
||||
mosquitto_pub -t "infoscreen/{uuid}/service_failed" -n --retain
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. No Server Action Required
|
||||
|
||||
These items are fully implemented client-side and require no server changes:
|
||||
|
||||
- systemd watchdog (`WatchdogSec=60`) — hangs detected and process restarted automatically.
|
||||
- Command deduplication — `command_id` deduplicated with 24-hour TTL.
|
||||
- Ack retry backoff — client retries ack publish on broker disconnect until `expires_at`.
|
||||
- Mock helper / test mode (`COMMAND_MOCK_REBOOT_IMMEDIATE_COMPLETE`) — development only.
|
||||
88
TODO.md
88
TODO.md
@@ -25,10 +25,98 @@ This file tracks higher-level todos and design notes for the infoscreen client.
|
||||
- `set_volume()` issues appropriate CEC commands and returns success/failure.
|
||||
- Document any platform limitations (some TVs don't support absolute volume via CEC).
|
||||
|
||||
## Systemd crash recovery (server team recommendation)
|
||||
|
||||
Reliable restart-on-crash for both processes must be handled by **systemd**, not by in-process watchdogs or ad-hoc shell scripts.
|
||||
|
||||
### What needs to be done
|
||||
|
||||
- `display_manager`: already has `scripts/infoscreen-display.service` with `Restart=on-failure` / `RestartSec=10`.
|
||||
- Review `RestartSec` — may want a short backoff (e.g. 5–15 s) and `StartLimitIntervalSec` + `StartLimitBurst` to prevent thrash loops.
|
||||
- `simclient`: **no service unit exists yet**.
|
||||
- Create `scripts/infoscreen-simclient.service` modelled on the display service.
|
||||
- Use `Restart=on-failure` and `RestartSec=10`.
|
||||
- Wire `EnvironmentFile=/home/olafn/infoscreen-dev/.env` so the unit picks up `.env` variables automatically.
|
||||
- Set `After=network-online.target` so MQTT connection is not attempted before the network is ready.
|
||||
- Both units should be installed and enabled via `src/pi-setup.sh` (`systemctl enable --now`).
|
||||
- After enabling, verify crash recovery with `kill -9 <pid>` and confirm systemd restarts the process within `RestartSec`.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Both `simclient` and `display_manager` restart automatically within 15 s of any non-intentional exit.
|
||||
- `systemctl status` shows `active (running)` after a crash-induced restart.
|
||||
- `journalctl -u infoscreen-simclient` captures all process output (stdout + stderr).
|
||||
- `pi-setup.sh` idempotently installs and enables both units.
|
||||
|
||||
### Notes
|
||||
|
||||
- Use `Restart=on-failure` — restarts on crashes and signals but not on clean `systemctl stop`, preserving operator control during deployments.
|
||||
- The reboot/shutdown command flow publishes `execution_started` and then exits intentionally; systemd will restart simclient, and the recovery logic in the heartbeat loop will emit `completed` on reconnect. This is the intended lifecycle.
|
||||
|
||||
## Process health observability gaps
|
||||
|
||||
Two scenarios are currently undetected or ambiguous from the server/frontend perspective.
|
||||
|
||||
### Gap 1: Hung / deadlocked process ✅ implemented
|
||||
|
||||
**Solution implemented:** Zero-dependency `_sd_notify()` helper writes directly to `NOTIFY_SOCKET` (raw Unix socket, no extra package). `READY=1` is sent when the heartbeat loop starts; `WATCHDOG=1` is sent every 5 s in the main loop iteration. The service unit uses `Type=notify` + `WatchdogSec=60` — if the main loop freezes for 60 s, systemd kills and restarts the process automatically.
|
||||
|
||||
**To apply on device:**
|
||||
```bash
|
||||
sudo cp ~/infoscreen-dev/scripts/infoscreen-simclient.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart infoscreen-simclient
|
||||
```
|
||||
|
||||
### Gap 2: MQTT broker unreachable vs. simclient dead ✅ implemented (client side)
|
||||
|
||||
**Solution implemented:** `connection_state` dict expanded with `reconnect_count` and `connect_count`. `publish_health_message()` now accepts `connection_state` and appends a `broker_connection` block to every health payload:
|
||||
|
||||
```json
|
||||
"broker_connection": {
|
||||
"broker_reachable": true,
|
||||
"reconnect_count": 2,
|
||||
"last_disconnect_at": "2026-04-04T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`broker_reachable` = `true` when MQTT is connected at publish time.
|
||||
`reconnect_count` increments on every reconnection (first connect does not count).
|
||||
`last_disconnect_at` is the UTC timestamp of the most recent disconnect.
|
||||
|
||||
**Server-side action still needed:**
|
||||
- Display `reconnect_count` and `last_disconnect_at` in the frontend health dashboard.
|
||||
- Alert heuristic: if **all** clients go silent simultaneously → likely broker issue; if only one → likely device issue.
|
||||
|
||||
### Gap 3: systemd gives up (StartLimitBurst exceeded) ✅ implemented
|
||||
|
||||
**Solution implemented:** `scripts/infoscreen-notify-failure@.service` (template unit) + `scripts/infoscreen-notify-failure.sh`. Both main units have `OnFailure=infoscreen-notify-failure@%n.service`. When systemd marks a service `failed`, the notifier runs once, reads broker credentials from `.env`, reads `client_uuid.txt`, and publishes a retained JSON payload to `infoscreen/{uuid}/service_failed` via `mosquitto_pub`.
|
||||
|
||||
**To apply on device:**
|
||||
```bash
|
||||
sudo cp ~/infoscreen-dev/scripts/infoscreen-notify-failure@.service /etc/systemd/system/
|
||||
sudo cp ~/infoscreen-dev/scripts/infoscreen-simclient.service /etc/systemd/system/
|
||||
sudo cp ~/infoscreen-dev/scripts/infoscreen-display.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart infoscreen-simclient infoscreen-display
|
||||
```
|
||||
|
||||
**Topic:** `infoscreen/{client_uuid}/service_failed` (retained)
|
||||
**Payload:** `{"event":"service_failed","unit":"infoscreen-simclient.service","client_uuid":"...","failed_at":"2026-..."}`
|
||||
|
||||
## Next-high-level items
|
||||
|
||||
- Add environment-controlled libVLC hw-accel toggle (`VLC_HW_ACCEL=1|0`) to `display_manager.py` so software decode can be forced when necessary.
|
||||
- Add automated tests for video start/stop lifecycle (mock python-vlc) to ensure resources are released on event end.
|
||||
- Add allowlist validation for `website` / `webuntis` event URLs
|
||||
- Goal: restrict browser-based events to approved hosts and schemes even if an authenticated publisher sends an unsafe URL.
|
||||
- Ideas / approaches:
|
||||
- Add env-configurable allowlists for general website hosts and WebUntis hosts.
|
||||
- Allow only `https` by default and reject `file:`, `data:`, `javascript:`, loopback, and private-address URLs unless explicitly allowed.
|
||||
- Enforce the same validation on both server-side payload generation and client-side execution in `display_manager.py`.
|
||||
- Acceptance criteria:
|
||||
- Unsafe or unapproved URLs are rejected before Chromium is launched.
|
||||
- WebUntis and approved website events still work with explicit allowlist configuration.
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
206
TV_POWER_COORDINATION_TASKLIST.md
Normal file
206
TV_POWER_COORDINATION_TASKLIST.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# TV Power Coordination Task List (Server + Client)
|
||||
|
||||
## Goal
|
||||
Prevent unintended TV power-off during adjacent events while enabling coordinated, server-driven power intent via MQTT with robust client-side fallback.
|
||||
|
||||
## Scope
|
||||
- Server publishes explicit TV power intent and event-window context.
|
||||
- Client executes HDMI-CEC power actions with timer-safe behavior.
|
||||
- Client falls back to local schedule/end-time logic if server intent is missing or stale.
|
||||
- Existing event playback behavior remains backward compatible.
|
||||
|
||||
## Ownership Proposal
|
||||
- Server team: Scheduler integration, power-intent publisher, reliability semantics.
|
||||
- Client team: MQTT handler, state machine, CEC execution, fallback and observability.
|
||||
|
||||
---
|
||||
|
||||
## 1. MQTT Contract (Shared Spec)
|
||||
|
||||
### 1.1 Topics
|
||||
- Command/intent topic (retained):
|
||||
- infoscreen/{client_id}/power/intent
|
||||
- Optional group-wide command topic (retained):
|
||||
- infoscreen/groups/{group_id}/power/intent
|
||||
- Client state/ack topic:
|
||||
- infoscreen/{client_id}/power/state
|
||||
|
||||
### 1.2 QoS and retain
|
||||
- intent topics: QoS 1, retained=true
|
||||
- state topic: QoS 0 or 1 (recommend QoS 0 initially), retained=false
|
||||
|
||||
### 1.3 Intent payload schema (v1)
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "uuid-or-monotonic-id",
|
||||
"issued_at": "2026-03-31T12:00:00Z",
|
||||
"expires_at": "2026-03-31T12:10:00Z",
|
||||
"target": {
|
||||
"client_id": "optional-if-group-topic",
|
||||
"group_id": "optional"
|
||||
},
|
||||
"power": {
|
||||
"desired_state": "on",
|
||||
"reason": "event_window_active",
|
||||
"grace_seconds": 30
|
||||
},
|
||||
"event_window": {
|
||||
"start": "2026-03-31T12:00:00Z",
|
||||
"end": "2026-03-31T13:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 State payload schema (client -> server)
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "last-applied-intent-id",
|
||||
"client_id": "...",
|
||||
"reported_at": "2026-03-31T12:00:01Z",
|
||||
"power": {
|
||||
"applied_state": "on",
|
||||
"source": "mqtt_intent|local_fallback",
|
||||
"result": "ok|skipped|error",
|
||||
"detail": "free text"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 Idempotency and ordering rules
|
||||
- Client applies only newest valid intent by issued_at then intent_id tie-break.
|
||||
- Duplicate intent_id must be ignored after first successful apply.
|
||||
- Expired intents must not trigger new actions.
|
||||
- Retained intent must be immediately usable after client reconnect.
|
||||
|
||||
### 1.6 Safety rules
|
||||
- desired_state=on cancels any pending delayed-off timer before action.
|
||||
- desired_state=off may schedule delayed-off, never immediate off during an active event window.
|
||||
- If payload is malformed, client logs and ignores it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Server Team Task List
|
||||
|
||||
### 2.1 Contract + scheduler mapping
|
||||
- Finalize field names and UTC timestamp format with client team.
|
||||
- Define when scheduler emits on/off intents for adjacent/overlapping events.
|
||||
- Ensure contiguous events produce uninterrupted desired_state=on coverage.
|
||||
|
||||
### 2.2 Publisher implementation
|
||||
- Add publisher for infoscreen/{client_id}/power/intent.
|
||||
- Support retained messages and QoS 1.
|
||||
- Include expires_at for stale-intent protection.
|
||||
- Emit new intent_id for every semantic state transition.
|
||||
|
||||
### 2.3 Reconnect and replay behavior
|
||||
- On scheduler restart, republish current effective intent as retained.
|
||||
- On event edits/cancellations, publish replacement retained intent.
|
||||
|
||||
### 2.4 Conflict policy
|
||||
- Define precedence when both group and per-client intents exist.
|
||||
- Recommended: per-client overrides group intent.
|
||||
|
||||
### 2.5 Monitoring and diagnostics
|
||||
- Record publish attempts, broker ack results, and active retained payload.
|
||||
- Add operational dashboard panels for intent age and last transition.
|
||||
|
||||
### 2.6 Server acceptance criteria
|
||||
- Adjacent event windows do not produce off intent between events.
|
||||
- Reconnect test: fresh client receives retained intent and powers correctly.
|
||||
- Expired intent is never acted on by a conforming client.
|
||||
|
||||
---
|
||||
|
||||
## 3. Client Team Task List
|
||||
|
||||
### 3.1 MQTT subscription + parsing
|
||||
- Subscribe to infoscreen/{client_id}/power/intent.
|
||||
- Optionally subscribe to infoscreen/groups/{group_id}/power/intent.
|
||||
- Parse schema_version=1.0 payload with strict validation.
|
||||
|
||||
### 3.2 Power state controller integration
|
||||
- Add power-intent handler in display manager path that owns HDMI-CEC decisions.
|
||||
- On desired_state=on:
|
||||
- cancel delayed-off timer
|
||||
- call CEC on only if needed
|
||||
- On desired_state=off:
|
||||
- schedule delayed off using configured grace_seconds (or local default)
|
||||
- re-check active event before executing off
|
||||
|
||||
### 3.3 Fallback behavior (critical)
|
||||
- If MQTT unreachable, intent missing, invalid, or expired:
|
||||
- fall back to existing local event-time logic
|
||||
- use event end as off trigger with existing delayed-off safety
|
||||
- If local logic sees active event, enforce cancel of pending off timer.
|
||||
|
||||
### 3.4 Adjacent-event race hardening
|
||||
- Guarantee pending off timer is canceled on any newly active event.
|
||||
- Ensure event switch path never requests off while next event is active.
|
||||
- Add explicit logging for timer create/cancel/fire with reason and event_id.
|
||||
|
||||
### 3.5 State publishing
|
||||
- Publish apply results to infoscreen/{client_id}/power/state.
|
||||
- Include source=mqtt_intent or local_fallback.
|
||||
- Include last intent_id and result details for troubleshooting.
|
||||
|
||||
### 3.6 Config flags
|
||||
- Add feature toggle:
|
||||
- POWER_CONTROL_MODE=local|mqtt|hybrid (recommend default: hybrid)
|
||||
- hybrid behavior:
|
||||
- prefer valid mqtt intent
|
||||
- automatically fall back to local logic
|
||||
|
||||
### 3.7 Client acceptance criteria
|
||||
- Adjacent events: no unintended off between two active windows.
|
||||
- Broker outage during event: TV remains on via local fallback.
|
||||
- Broker recovery: retained intent reconciles state without oscillation.
|
||||
- Duplicate/old intents do not cause repeated CEC toggles.
|
||||
|
||||
---
|
||||
|
||||
## 4. Integration Test Matrix (Joint)
|
||||
|
||||
## 4.1 Happy paths
|
||||
- Single event start -> on intent -> TV on.
|
||||
- Event end -> off intent -> delayed off -> TV off.
|
||||
- Adjacent events (end==start or small gap) -> uninterrupted TV on.
|
||||
|
||||
## 4.2 Failure paths
|
||||
- Broker down before event start.
|
||||
- Broker down during active event.
|
||||
- Malformed retained intent at reconnect.
|
||||
- Delayed off armed, then new event starts before timer fires.
|
||||
|
||||
## 4.3 Consistency checks
|
||||
- Client state topic reflects actual applied source and result.
|
||||
- Logs include intent_id correlation across server and client.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rollout Plan
|
||||
|
||||
### Phase 1: Contract and feature flags
|
||||
- Freeze schema and topic naming.
|
||||
- Ship client support behind POWER_CONTROL_MODE=hybrid.
|
||||
|
||||
### Phase 2: Server publisher rollout
|
||||
- Enable publishing for test group only.
|
||||
- Verify retained and reconnect behavior.
|
||||
|
||||
### Phase 3: Production enablement
|
||||
- Enable hybrid mode fleet-wide.
|
||||
- Observe for 1 week: off-between-adjacent-events incidents must be zero.
|
||||
|
||||
### Phase 4: Optional tightening
|
||||
- If metrics are stable, evaluate mqtt-first policy while retaining local safety fallback.
|
||||
|
||||
---
|
||||
|
||||
## 6. Definition of Done
|
||||
- Shared MQTT contract approved by both teams.
|
||||
- Server and client implementations merged with tests.
|
||||
- Adjacent-event regression test added and passing.
|
||||
- Operational runbook updated (topics, payloads, fallback behavior, troubleshooting).
|
||||
- Production monitoring confirms no unintended mid-schedule TV power-off.
|
||||
95
TV_POWER_HANDOFF_CLIENT.md
Normal file
95
TV_POWER_HANDOFF_CLIENT.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Client Handoff: TV Power Coordination
|
||||
|
||||
## Purpose
|
||||
Implement robust client-side TV power control that applies server MQTT intents when valid and falls back to local event timing when server/broker data is missing or stale.
|
||||
|
||||
## Source of Truth
|
||||
- Shared full plan: TV_POWER_COORDINATION_TASKLIST.md
|
||||
|
||||
## Scope (Client Team)
|
||||
- Intent subscription/validation
|
||||
- CEC state transitions and timer cancellation safety
|
||||
- Hybrid fallback using local event windows
|
||||
- Power state acknowledgment publishing
|
||||
|
||||
## MQTT Contract (Client Responsibilities)
|
||||
|
||||
### Subscribe
|
||||
- infoscreen/{client_id}/power/intent
|
||||
- Optional: infoscreen/groups/{group_id}/power/intent
|
||||
|
||||
### Publish state
|
||||
- infoscreen/{client_id}/power/state
|
||||
|
||||
### State Payload (v1)
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "last-applied-intent-id",
|
||||
"client_id": "...",
|
||||
"reported_at": "2026-03-31T12:00:01Z",
|
||||
"power": {
|
||||
"applied_state": "on",
|
||||
"source": "mqtt_intent|local_fallback",
|
||||
"result": "ok|skipped|error",
|
||||
"detail": "free text"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Required Runtime Rules
|
||||
|
||||
### Intent Validation and Ordering
|
||||
- Accept only schema_version=1.0 (or explicitly version-gated supported set).
|
||||
- Ignore malformed payloads.
|
||||
- Ignore expired intents (expires_at in past).
|
||||
- Apply only newest valid intent by issued_at, intent_id tie-break.
|
||||
- Deduplicate already-applied intent_id.
|
||||
|
||||
### Power Action Safety
|
||||
- desired_state=on:
|
||||
- cancel pending delayed-off timer immediately
|
||||
- turn on via CEC only if needed
|
||||
- desired_state=off:
|
||||
- schedule delayed off (grace_seconds or local default)
|
||||
- re-check active event before executing actual off
|
||||
|
||||
### Fallback (Critical)
|
||||
- If MQTT unavailable, intent missing, invalid, or stale:
|
||||
- use existing local event start/end logic
|
||||
- use event end as off trigger plus delayed-off safety
|
||||
- Any active event must cancel pending off timers.
|
||||
|
||||
## Configuration
|
||||
- Add POWER_CONTROL_MODE with values:
|
||||
- local
|
||||
- mqtt
|
||||
- hybrid (recommended default)
|
||||
|
||||
### Hybrid Mode
|
||||
- Prefer valid MQTT intent.
|
||||
- Automatically fall back to local schedule logic when intent channel is not trustworthy.
|
||||
|
||||
## Implementation Tasks
|
||||
1. Add intent topic handlers and schema validation.
|
||||
2. Integrate intent application into display power control path.
|
||||
3. Add timer race hardening for adjacent event transitions.
|
||||
4. Add fallback decision branch for stale/missing intents.
|
||||
5. Add power state publisher with intent_id/source/result.
|
||||
6. Add logs for timer arm/cancel/fire with reason and event_id.
|
||||
7. Add tests for adjacent events, broker outage, reconnect, duplicate intent.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. No unintended TV off between adjacent events.
|
||||
2. Broker outage during active event does not power off TV prematurely.
|
||||
3. Reconnect with retained intent reconciles state without oscillation.
|
||||
4. Duplicate/old intents do not trigger repeated CEC toggles.
|
||||
5. State messages clearly show mqtt_intent vs local_fallback source.
|
||||
|
||||
## Target Integration Points
|
||||
- Main runtime orchestration: src/display_manager.py
|
||||
- MQTT plumbing and topic handlers: src/simclient.py
|
||||
|
||||
## Operational Notes
|
||||
- Keep fallback logic enabled even after MQTT rollout.
|
||||
- Ensure all new timestamps are UTC ISO format.
|
||||
83
TV_POWER_HANDOFF_SERVER.md
Normal file
83
TV_POWER_HANDOFF_SERVER.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Server Handoff: TV Power Coordination
|
||||
|
||||
## Purpose
|
||||
Implement server-side MQTT power intent publishing so clients can keep TVs on across adjacent events and power off safely after schedules end.
|
||||
|
||||
## Source of Truth
|
||||
- Shared full plan: TV_POWER_COORDINATION_TASKLIST.md
|
||||
|
||||
## Scope (Server Team)
|
||||
- Scheduler-to-intent mapping
|
||||
- MQTT publishing semantics (retain, QoS, expiry)
|
||||
- Conflict handling (group vs client)
|
||||
- Observability for intent lifecycle
|
||||
|
||||
## MQTT Contract (Server Responsibilities)
|
||||
|
||||
### Topics
|
||||
- Primary (per-client): infoscreen/{client_id}/power/intent
|
||||
- Optional (group-level): infoscreen/groups/{group_id}/power/intent
|
||||
|
||||
### Delivery Semantics
|
||||
- QoS: 1
|
||||
- retained: true
|
||||
- Always publish UTC timestamps (ISO 8601 with Z)
|
||||
|
||||
### Intent Payload (v1)
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "uuid-or-monotonic-id",
|
||||
"issued_at": "2026-03-31T12:00:00Z",
|
||||
"expires_at": "2026-03-31T12:10:00Z",
|
||||
"target": {
|
||||
"client_id": "optional-if-group-topic",
|
||||
"group_id": "optional"
|
||||
},
|
||||
"power": {
|
||||
"desired_state": "on",
|
||||
"reason": "event_window_active",
|
||||
"grace_seconds": 30
|
||||
},
|
||||
"event_window": {
|
||||
"start": "2026-03-31T12:00:00Z",
|
||||
"end": "2026-03-31T13:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Required Behavior
|
||||
|
||||
### Adjacent/Overlapping Events
|
||||
- Never publish an intermediate off intent when windows are contiguous/overlapping.
|
||||
- Maintain continuous desired_state=on coverage across adjacent windows.
|
||||
|
||||
### Reconnect/Restart
|
||||
- On scheduler restart, republish effective retained intent.
|
||||
- On event edits/cancellations, replace retained intent with a fresh intent_id.
|
||||
|
||||
### Conflict Policy
|
||||
- If both group and client intent exist: per-client overrides group.
|
||||
|
||||
### Expiry Safety
|
||||
- expires_at must be set for every intent.
|
||||
- Server should avoid publishing already-expired intents.
|
||||
|
||||
## Implementation Tasks
|
||||
1. Add scheduler mapping layer that computes effective desired_state per client timeline.
|
||||
2. Add intent publisher with retained QoS1 delivery.
|
||||
3. Generate unique intent_id for each semantic transition.
|
||||
4. Emit issued_at/expires_at and event_window consistently in UTC.
|
||||
5. Add group-vs-client precedence logic.
|
||||
6. Add logs/metrics for publish success, retained payload age, and transition count.
|
||||
7. Add integration tests for adjacent events and reconnect replay.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Adjacent events do not create OFF gap intents.
|
||||
2. Fresh client receives retained intent after reconnect and gets correct desired state.
|
||||
3. Intent payloads are schema-valid, UTC-formatted, and include expiry.
|
||||
4. Publish logs and metrics allow intent timeline reconstruction.
|
||||
|
||||
## Operational Notes
|
||||
- Keep intent publishing idempotent and deterministic.
|
||||
- Preserve backward compatibility while clients run in hybrid mode.
|
||||
163
TV_POWER_INTENT_SERVER_CONTRACT_V1.md
Normal file
163
TV_POWER_INTENT_SERVER_CONTRACT_V1.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# TV Power Intent — Server Contract v1 (Phase 1)
|
||||
|
||||
> This document is the stable reference for client-side implementation.
|
||||
> The server implementation is validated and frozen at this contract.
|
||||
> Last validated: 2026-04-01
|
||||
|
||||
---
|
||||
|
||||
## Topic
|
||||
|
||||
```
|
||||
infoscreen/groups/{group_id}/power/intent
|
||||
```
|
||||
|
||||
- **Scope**: group-level only (Phase 1). No per-client topic in Phase 1.
|
||||
- **QoS**: 1
|
||||
- **Retained**: true — broker holds last payload; client receives it immediately on (re)connect.
|
||||
|
||||
---
|
||||
|
||||
## Publish semantics
|
||||
|
||||
| Trigger | Behaviour |
|
||||
|---|---|
|
||||
| Semantic transition (state/reason changes) | New `intent_id`, immediate publish |
|
||||
| No change (heartbeat) | Same `intent_id`, refreshed `issued_at` and `expires_at`, published every poll interval |
|
||||
| Scheduler startup | Immediate publish before first poll wait |
|
||||
| MQTT reconnect | Immediate retained republish of last known intent |
|
||||
|
||||
Poll interval default: **15 seconds** (dev) / **30 seconds** (prod).
|
||||
|
||||
---
|
||||
|
||||
## Payload schema
|
||||
|
||||
All fields are always present. No optional fields for Phase 1 required fields.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "<uuid4>",
|
||||
"group_id": <integer>,
|
||||
"desired_state": "on" | "off",
|
||||
"reason": "active_event" | "no_active_event",
|
||||
"issued_at": "<ISO 8601 UTC with Z>",
|
||||
"expires_at": "<ISO 8601 UTC with Z>",
|
||||
"poll_interval_sec": <integer>,
|
||||
"active_event_ids": [<integer>, ...],
|
||||
"event_window_start": "<ISO 8601 UTC with Z>" | null,
|
||||
"event_window_end": "<ISO 8601 UTC with Z>" | null
|
||||
}
|
||||
```
|
||||
|
||||
### Field reference
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `schema_version` | string | Always `"1.0"` in Phase 1 |
|
||||
| `intent_id` | string (uuid4) | Stable across heartbeats; new value on semantic transition |
|
||||
| `group_id` | integer | Matches the MQTT topic group_id |
|
||||
| `desired_state` | `"on"` or `"off"` | The commanded TV power state |
|
||||
| `reason` | string | Human-readable reason for current state |
|
||||
| `issued_at` | UTC Z string | When this payload was computed |
|
||||
| `expires_at` | UTC Z string | After this time, payload is stale; re-subscribe or treat as `off` |
|
||||
| `poll_interval_sec` | integer | Server poll interval; expiry = max(3 × poll, 90s) |
|
||||
| `active_event_ids` | integer array | IDs of currently active events; empty when `off` |
|
||||
| `event_window_start` | UTC Z string or null | Start of merged active coverage window; null when `off` |
|
||||
| `event_window_end` | UTC Z string or null | End of merged active coverage window; null when `off` |
|
||||
|
||||
---
|
||||
|
||||
## Expiry rule
|
||||
|
||||
```
|
||||
expires_at = issued_at + max(3 × poll_interval_sec, 90s)
|
||||
```
|
||||
|
||||
Default at poll=15s → expiry window = **90 seconds**.
|
||||
|
||||
**Client rule**: if `now > expires_at` treat as stale and fall back to `off` until a fresh payload arrives.
|
||||
|
||||
---
|
||||
|
||||
## Example payloads
|
||||
|
||||
### ON (active event)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "4a7fe3bc-3654-48e3-b5b9-9fad1f7fead3",
|
||||
"group_id": 2,
|
||||
"desired_state": "on",
|
||||
"reason": "active_event",
|
||||
"issued_at": "2026-04-01T06:00:03.496Z",
|
||||
"expires_at": "2026-04-01T06:01:33.496Z",
|
||||
"poll_interval_sec": 15,
|
||||
"active_event_ids": [148],
|
||||
"event_window_start": "2026-04-01T06:00:00Z",
|
||||
"event_window_end": "2026-04-01T07:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### OFF (no active event)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "833c53e3-d728-4604-9861-6ff7be1f227e",
|
||||
"group_id": 2,
|
||||
"desired_state": "off",
|
||||
"reason": "no_active_event",
|
||||
"issued_at": "2026-04-01T07:00:03.702Z",
|
||||
"expires_at": "2026-04-01T07:01:33.702Z",
|
||||
"poll_interval_sec": 15,
|
||||
"active_event_ids": [],
|
||||
"event_window_start": null,
|
||||
"event_window_end": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validated server behaviours (client can rely on these)
|
||||
|
||||
| Scenario | Guaranteed server behaviour |
|
||||
|---|---|
|
||||
| Event starts | `desired_state: on` emitted within one poll interval |
|
||||
| Event ends | `desired_state: off` emitted within one poll interval |
|
||||
| Adjacent events (end1 == start2) | No intermediate `off` emitted at boundary |
|
||||
| Overlapping events | `desired_state: on` held continuously |
|
||||
| Scheduler restart during active event | Immediate `on` republish on reconnect; broker retained holds `on` during outage |
|
||||
| No events in group | `desired_state: off` with empty `active_event_ids` |
|
||||
| Heartbeat (no change) | Same `intent_id`, refreshed timestamps every poll |
|
||||
|
||||
---
|
||||
|
||||
## Client responsibilities (Phase 1)
|
||||
|
||||
1. **Subscribe** to `infoscreen/groups/{own_group_id}/power/intent` at QoS 1 on connect.
|
||||
2. **Re-subscribe on reconnect** — broker retained message will deliver last known intent immediately.
|
||||
3. **Parse `desired_state`** and apply TV power action (`on` → power on / `off` → power off).
|
||||
4. **Deduplicate** using `intent_id` — if same `intent_id` received again, skip re-applying power command.
|
||||
5. **Check expiry** — if `now > expires_at`, treat as stale and fall back to `off` until renewed.
|
||||
6. **Ignore unknown fields** — for forward compatibility with Phase 2 additions.
|
||||
7. **Do not use per-client topic** in Phase 1; only group topic is active.
|
||||
|
||||
---
|
||||
|
||||
## Timestamps
|
||||
|
||||
- All timestamps use **ISO 8601 UTC with Z suffix**: `"2026-04-01T06:00:03.496Z"`
|
||||
- Client must parse as UTC.
|
||||
- Do not assume local time.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 (deferred — not yet active)
|
||||
|
||||
- Per-client intent topic: `infoscreen/{client_uuid}/power/intent`
|
||||
- Per-client override takes precedence over group intent
|
||||
- Client state acknowledgement: `infoscreen/{client_uuid}/power/state`
|
||||
- Listener persistence of client state to DB
|
||||
213
TV_POWER_RUNBOOK.md
Normal file
213
TV_POWER_RUNBOOK.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# TV Power Runbook
|
||||
|
||||
Operational runbook for Phase 1 TV power coordination using MQTT power intent plus local HDMI-CEC fallback.
|
||||
|
||||
## Scope
|
||||
|
||||
This runbook covers:
|
||||
|
||||
- `POWER_CONTROL_MODE` rollout
|
||||
- canary validation
|
||||
- expected log signatures
|
||||
- rollback
|
||||
- common failure checks
|
||||
|
||||
Contract reference:
|
||||
|
||||
- [TV_POWER_INTENT_SERVER_CONTRACT_V1.md](TV_POWER_INTENT_SERVER_CONTRACT_V1.md)
|
||||
|
||||
## Topics and Runtime Files
|
||||
|
||||
Phase 1 topic:
|
||||
|
||||
- `infoscreen/groups/{group_id}/power/intent`
|
||||
|
||||
Telemetry topic:
|
||||
|
||||
- `infoscreen/{client_id}/power/state`
|
||||
|
||||
Runtime files:
|
||||
|
||||
- `src/power_intent_state.json`
|
||||
- `src/power_state.json`
|
||||
- `src/current_process_health.json`
|
||||
|
||||
## Power Control Modes
|
||||
|
||||
- `local`: ignore MQTT intent and use local event-time CEC logic.
|
||||
- `hybrid`: prefer fresh MQTT intent and fall back to local timing when missing, stale, or invalid.
|
||||
- `mqtt`: MQTT intent is authoritative; stale or missing intent triggers safe delayed-off behavior.
|
||||
|
||||
Recommended rollout path:
|
||||
|
||||
1. Start with `local`.
|
||||
2. Canary with `hybrid`.
|
||||
3. Roll out `hybrid` fleet-wide after stable observation.
|
||||
4. Use `mqtt` only if you explicitly want strict server authority.
|
||||
|
||||
## Gate 1: Local Mode
|
||||
|
||||
Set in `.env`:
|
||||
|
||||
```bash
|
||||
POWER_CONTROL_MODE=local
|
||||
```
|
||||
|
||||
Expected startup log signature:
|
||||
|
||||
```text
|
||||
[INFO] Power control mode: local
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- No MQTT power intent application.
|
||||
- Existing CEC behavior remains unchanged.
|
||||
|
||||
## Gate 2: Hybrid Canary
|
||||
|
||||
On one client or one canary group:
|
||||
|
||||
```bash
|
||||
POWER_CONTROL_MODE=hybrid
|
||||
./scripts/restart-all.sh
|
||||
```
|
||||
|
||||
Expected startup logs:
|
||||
|
||||
```text
|
||||
[INFO] Power state service thread started
|
||||
[INFO] Subscribed to power intent topic: infoscreen/groups/<id>/power/intent
|
||||
[INFO] Power control mode: hybrid
|
||||
```
|
||||
|
||||
### Valid ON Intent
|
||||
|
||||
Expected sequence:
|
||||
|
||||
```text
|
||||
[INFO] Power intent accepted: id=<uuid> desired_state=on reason=active_event ...
|
||||
[INFO] Applying MQTT power intent ON id=<uuid> reason=active_event
|
||||
[INFO] TV turned ON successfully
|
||||
[INFO] Power state published: state=on source=mqtt_intent result=ok
|
||||
```
|
||||
|
||||
### Valid OFF Intent
|
||||
|
||||
Expected sequence:
|
||||
|
||||
```text
|
||||
[INFO] Power intent accepted: id=<uuid> desired_state=off reason=no_active_event ...
|
||||
[INFO] Applying MQTT power intent OFF id=<uuid> reason=no_active_event
|
||||
[INFO] Power state published: state=off source=mqtt_intent result=ok
|
||||
```
|
||||
|
||||
### Expired Intent
|
||||
|
||||
Expected rejection:
|
||||
|
||||
```text
|
||||
[WARNING] Rejected power intent: intent expired
|
||||
```
|
||||
|
||||
### Malformed Intent
|
||||
|
||||
Expected rejection:
|
||||
|
||||
```text
|
||||
[WARNING] Rejected power intent: missing required field: intent_id
|
||||
```
|
||||
|
||||
### Retained Clear
|
||||
|
||||
When you clear the retained topic, the broker delivers an empty payload.
|
||||
|
||||
Expected log:
|
||||
|
||||
```text
|
||||
[INFO] Power intent retained message cleared (empty payload)
|
||||
```
|
||||
|
||||
This is normal and should not be treated as a parse error.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
./scripts/test-power-intent.sh
|
||||
./scripts/test-hdmi-cec.sh
|
||||
```
|
||||
|
||||
Useful test-power-intent paths:
|
||||
|
||||
- Option 1: publish valid ON intent.
|
||||
- Option 2: publish valid OFF intent.
|
||||
- Option 3: publish stale intent.
|
||||
- Option 4: publish malformed intent.
|
||||
- Option 5: clear retained topic with an empty retained payload.
|
||||
- Option 6: inspect runtime JSON files.
|
||||
- Option 8: subscribe to the power-state topic.
|
||||
|
||||
Useful manual checks:
|
||||
|
||||
```bash
|
||||
tail -f logs/display_manager.log src/simclient.log
|
||||
cat src/power_intent_state.json
|
||||
cat src/power_state.json
|
||||
cat src/current_process_health.json
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
To leave canary mode:
|
||||
|
||||
```bash
|
||||
POWER_CONTROL_MODE=local
|
||||
./scripts/restart-all.sh
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- MQTT power intent handling becomes inactive.
|
||||
- Local CEC fallback remains in place.
|
||||
|
||||
## Fleet Rollout Gate
|
||||
|
||||
Roll out `hybrid` more widely only after:
|
||||
|
||||
- zero unintended TV-off events between adjacent events,
|
||||
- valid ON/OFF actions apply cleanly,
|
||||
- duplicate refreshes are logged as `result=skipped`,
|
||||
- stale and malformed intents are rejected without side effects,
|
||||
- retained clear events no longer produce noisy warnings.
|
||||
|
||||
Suggested observation window:
|
||||
|
||||
- at least 7 days on a canary client or canary group.
|
||||
|
||||
## Common Symptoms
|
||||
|
||||
| Symptom | Check | Likely Action |
|
||||
|---|---|---|
|
||||
| Intent never arrives | `src/power_intent_state.json` missing or invalid | Check broker connectivity and group assignment |
|
||||
| `intent expired` appears repeatedly | client clock and server publish cadence | verify NTP and server refresh interval |
|
||||
| TV turns off between adjacent events | `src/power_state.json` shows `local_fallback` or stale intent at transition | inspect server timing and boundary coverage |
|
||||
| Repeated power state publishes with `skipped` | duplicate intent refreshes only | normal dedupe behavior |
|
||||
| Clear retained intent logs warning | old code path still running | restart services and verify latest code |
|
||||
|
||||
## Dashboard Observability
|
||||
|
||||
`src/current_process_health.json` includes a `power_control` block similar to:
|
||||
|
||||
```json
|
||||
"power_control": {
|
||||
"mode": "hybrid",
|
||||
"source": "mqtt_intent",
|
||||
"last_intent_id": "4a7fe3bc-...",
|
||||
"last_action": "on",
|
||||
"last_power_at": "2026-04-01T06:00:05Z"
|
||||
}
|
||||
```
|
||||
|
||||
This is the fastest local check for what the display manager last did and why.
|
||||
149
implementation-plans/reboot-command-payload-schemas.json
Normal file
149
implementation-plans/reboot-command-payload-schemas.json
Normal file
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://infoscreen.local/schemas/reboot-command-payload-schemas.json",
|
||||
"title": "Infoscreen Reboot Command Payload Schemas",
|
||||
"description": "Frozen v1 schemas for per-client command and command acknowledgement payloads.",
|
||||
"$defs": {
|
||||
"commandPayloadV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"command_id",
|
||||
"client_uuid",
|
||||
"action",
|
||||
"issued_at",
|
||||
"expires_at",
|
||||
"requested_by",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"const": "1.0"
|
||||
},
|
||||
"command_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"client_uuid": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reboot_host",
|
||||
"shutdown_host"
|
||||
]
|
||||
},
|
||||
"issued_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"requested_by": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 1
|
||||
},
|
||||
"reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 2000
|
||||
}
|
||||
}
|
||||
},
|
||||
"commandAckPayloadV1": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"command_id",
|
||||
"status",
|
||||
"error_code",
|
||||
"error_message"
|
||||
],
|
||||
"properties": {
|
||||
"command_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"execution_started",
|
||||
"completed",
|
||||
"failed"
|
||||
]
|
||||
},
|
||||
"error_code": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 128
|
||||
},
|
||||
"error_message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 4000
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"error_code": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"error_message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"commandPayloadV1": {
|
||||
"schema_version": "1.0",
|
||||
"command_id": "5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||
"client_uuid": "9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||
"action": "reboot_host",
|
||||
"issued_at": "2026-04-03T12:48:10Z",
|
||||
"expires_at": "2026-04-03T12:52:10Z",
|
||||
"requested_by": 1,
|
||||
"reason": "operator_request"
|
||||
}
|
||||
},
|
||||
{
|
||||
"commandAckPayloadV1": {
|
||||
"command_id": "5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||
"status": "execution_started",
|
||||
"error_code": null,
|
||||
"error_message": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
59
implementation-plans/reboot-command-payload-schemas.md
Normal file
59
implementation-plans/reboot-command-payload-schemas.md
Normal file
@@ -0,0 +1,59 @@
|
||||
## Reboot Command Payload Schema Snippets
|
||||
|
||||
This file provides copy-ready validation snippets for client and integration test helpers.
|
||||
|
||||
### Canonical Topics (v1)
|
||||
1. Command topic: infoscreen/{client_uuid}/commands
|
||||
2. Ack topic: infoscreen/{client_uuid}/commands/ack
|
||||
|
||||
### Transitional Compatibility Topics
|
||||
1. Command topic alias: infoscreen/{client_uuid}/command
|
||||
2. Ack topic alias: infoscreen/{client_uuid}/command/ack
|
||||
|
||||
### Canonical Action Values
|
||||
1. reboot_host
|
||||
2. shutdown_host
|
||||
|
||||
### Ack Status Values
|
||||
1. accepted
|
||||
2. execution_started
|
||||
3. completed
|
||||
4. failed
|
||||
|
||||
### JSON Schema Source
|
||||
Use this file for machine validation:
|
||||
1. implementation-plans/reboot-command-payload-schemas.json
|
||||
|
||||
### Minimal Command Schema Snippet
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "command_id", "client_uuid", "action", "issued_at", "expires_at", "requested_by", "reason"],
|
||||
"properties": {
|
||||
"schema_version": { "const": "1.0" },
|
||||
"command_id": { "type": "string", "format": "uuid" },
|
||||
"client_uuid": { "type": "string", "format": "uuid" },
|
||||
"action": { "enum": ["reboot_host", "shutdown_host"] },
|
||||
"issued_at": { "type": "string", "format": "date-time" },
|
||||
"expires_at": { "type": "string", "format": "date-time" },
|
||||
"requested_by": { "type": ["integer", "null"] },
|
||||
"reason": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Ack Schema Snippet
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["command_id", "status", "error_code", "error_message"],
|
||||
"properties": {
|
||||
"command_id": { "type": "string", "format": "uuid" },
|
||||
"status": { "enum": ["accepted", "execution_started", "completed", "failed"] },
|
||||
"error_code": { "type": ["string", "null"] },
|
||||
"error_message": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
## Client Team Implementation Spec (Raspberry Pi 5)
|
||||
|
||||
### Mission
|
||||
Implement client-side command handling for reliable restart and shutdown with strict validation, idempotency, acknowledgements, and reboot recovery continuity.
|
||||
|
||||
### Ownership Boundaries
|
||||
1. Client team owns command intake, execution, acknowledgement emission, and post-reboot continuity.
|
||||
2. Platform team owns command issuance, lifecycle aggregation, and server-side escalation logic.
|
||||
3. Client implementation must not assume managed PoE availability.
|
||||
|
||||
### Required Client Behaviors
|
||||
|
||||
### Frozen MQTT Topics and Schemas (v1)
|
||||
1. Canonical command topic: infoscreen/{client_uuid}/commands.
|
||||
2. Canonical ack topic: infoscreen/{client_uuid}/commands/ack.
|
||||
3. Transitional compatibility topics during migration:
|
||||
- infoscreen/{client_uuid}/command
|
||||
- infoscreen/{client_uuid}/command/ack
|
||||
4. QoS policy: command QoS 1, ack QoS 1 recommended.
|
||||
5. Retain policy: commands and acks are non-retained.
|
||||
6. Client migration behavior: subscribe to both command topics and publish to both ack topics during migration.
|
||||
|
||||
Frozen command payload schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"command_id": "5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||
"client_uuid": "9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||
"action": "reboot_host",
|
||||
"issued_at": "2026-04-03T12:48:10Z",
|
||||
"expires_at": "2026-04-03T12:52:10Z",
|
||||
"requested_by": 1,
|
||||
"reason": "operator_request"
|
||||
}
|
||||
```
|
||||
|
||||
Frozen ack payload schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||
"status": "execution_started",
|
||||
"error_code": null,
|
||||
"error_message": null
|
||||
}
|
||||
```
|
||||
|
||||
Allowed ack status values:
|
||||
1. accepted
|
||||
2. execution_started
|
||||
3. completed
|
||||
4. failed
|
||||
|
||||
Frozen command action values for v1:
|
||||
1. reboot_host
|
||||
2. shutdown_host
|
||||
|
||||
Reserved but not emitted by server in v1:
|
||||
1. restart_service
|
||||
|
||||
### Client Decision Defaults (v1)
|
||||
1. Privileged helper invocation: sudoers + local helper script (`sudo /usr/local/bin/infoscreen-cmd-helper.sh`).
|
||||
2. Dedupe retention: keep processed command IDs for 24 hours and cap store size to 5000 newest entries.
|
||||
3. Ack retry schedule while broker unavailable: 0.5s, 1s, 2s, 4s, then 5s cap until expires_at.
|
||||
4. Boot-loop handling: server remains authority for safety lockout; client enforces idempotency by command_id and reports local execution outcomes.
|
||||
|
||||
### MQTT Auth Hardening (Current Priority)
|
||||
1. Client must support authenticated MQTT connections for both command and event intake.
|
||||
2. Client must remain compatible with broker ACLs that restrict publish/subscribe rights per topic.
|
||||
3. Client should support TLS broker connections from environment configuration when certificates are provided.
|
||||
4. URL/domain allowlisting for web and webuntis events is explicitly deferred and tracked separately in TODO.md.
|
||||
5. Client credentials are loaded from the local [/.env](.env), not from tracked docs or templates.
|
||||
|
||||
Server-side prerequisites for this client work:
|
||||
1. Broker credentials must be provisioned for clients.
|
||||
2. Broker ACLs must allow each client to subscribe only to its own command topics and assigned event topics.
|
||||
3. Broker ACLs must allow each client to publish only its own ack, heartbeat, health, dashboard, and telemetry topics.
|
||||
4. Server-side publishers must move to authenticated broker access before production rollout.
|
||||
|
||||
Validation snippets for helper scripts:
|
||||
1. Human-readable snippets: implementation-plans/reboot-command-payload-schemas.md
|
||||
2. Machine-validated JSON Schema: implementation-plans/reboot-command-payload-schemas.json
|
||||
|
||||
### 1. Command Intake
|
||||
1. Subscribe to canonical and transitional command topics with QoS 1.
|
||||
2. Parse required fields exactly: schema_version, command_id, client_uuid, action, issued_at, expires_at, requested_by, reason.
|
||||
3. Reject invalid payloads with failed acknowledgement including error_code and diagnostic message.
|
||||
4. Reject stale commands when current time exceeds expires_at.
|
||||
5. Reject already-processed command_id values without re-execution.
|
||||
|
||||
### 2. Idempotency And Persistence
|
||||
1. Persist processed command_id and execution result on local storage.
|
||||
2. Persistence must survive service restart and full OS reboot.
|
||||
3. On restart, reload dedupe cache before processing newly delivered commands.
|
||||
|
||||
### 3. Acknowledgement Contract Behavior
|
||||
1. Emit accepted immediately after successful validation and dedupe pass.
|
||||
2. Emit execution_started immediately before invoking the command action.
|
||||
3. Emit completed only when local success condition is confirmed.
|
||||
4. Emit failed with structured error_code on validation or execution failure.
|
||||
5. If MQTT is temporarily unavailable, retry ack publish with bounded backoff until command expiry.
|
||||
6. Ack payload fields are strict: command_id, status, error_code, error_message (no additional fields).
|
||||
7. For status failed, error_code and error_message must be non-null, non-empty strings.
|
||||
|
||||
### 4. Execution Security Model
|
||||
1. Execute via systemd-managed privileged helper.
|
||||
2. Allow only whitelisted operations:
|
||||
- reboot_host
|
||||
- shutdown_host
|
||||
3. Do not execute restart_service in v1.
|
||||
4. Disallow arbitrary shell commands and untrusted arguments.
|
||||
5. Enforce per-command execution timeout and terminate hung child processes.
|
||||
|
||||
### 5. Reboot Recovery Continuity
|
||||
1. For reboot_host action:
|
||||
- send execution_started
|
||||
- trigger reboot promptly
|
||||
2. During startup:
|
||||
- emit heartbeat early
|
||||
- emit process-health once service is ready
|
||||
3. Keep last command execution state available after reboot for reconciliation.
|
||||
|
||||
### 6. Time And Timeout Semantics
|
||||
1. Use monotonic timers for local elapsed-time checks.
|
||||
2. Use UTC wall-clock only for protocol timestamps and expiry comparisons.
|
||||
3. Target reconnect baseline on Pi 5 USB-SATA SSD: 90 seconds.
|
||||
4. Accept cold-boot and USB enumeration ceiling up to 150 seconds.
|
||||
|
||||
### 7. Capability Reporting
|
||||
1. Report recovery capability class:
|
||||
- software_only
|
||||
- managed_poe_available
|
||||
- manual_only
|
||||
2. Report watchdog enabled status.
|
||||
3. Report boot-source metadata for diagnostics.
|
||||
|
||||
### 8. Error Codes Minimum Set
|
||||
1. invalid_schema
|
||||
2. missing_field
|
||||
3. stale_command
|
||||
4. duplicate_command
|
||||
5. permission_denied_local
|
||||
6. execution_timeout
|
||||
7. execution_failed
|
||||
8. broker_unavailable
|
||||
9. internal_error
|
||||
|
||||
### Acceptance Tests (Client Team)
|
||||
1. Invalid schema payload is rejected and failed ack emitted.
|
||||
2. Expired command is rejected and not executed.
|
||||
3. Duplicate command_id is not executed twice.
|
||||
4. reboot_host emits execution_started and reconnects with heartbeat in expected window.
|
||||
5. shutdown_host action is accepted and invokes local privileged helper without accepting non-whitelisted actions.
|
||||
6. MQTT outage during ack path retries correctly without duplicate execution.
|
||||
7. Client idempotency cooperates with server-side lockout semantics (no local reboot-rate policy).
|
||||
8. Client connects successfully to an authenticated broker and still receives commands and event topics permitted by ACLs.
|
||||
|
||||
### Delivery Artifacts
|
||||
1. Client protocol conformance checklist.
|
||||
2. Test evidence for all acceptance tests.
|
||||
3. Runtime logs showing full lifecycle for one shutdown and one reboot scenario.
|
||||
4. Known limitations list per image version.
|
||||
|
||||
### Definition Of Done
|
||||
1. All acceptance tests pass on Pi 5 USB-SATA SSD test devices.
|
||||
2. No duplicate execution observed under reconnect and retained-delivery edge cases.
|
||||
3. Acknowledgement sequence is complete and machine-parseable for server correlation.
|
||||
4. Reboot recovery continuity works without managed PoE dependencies.
|
||||
175
implementation-plans/reboot-implementation-handoff-share.md
Normal file
175
implementation-plans/reboot-implementation-handoff-share.md
Normal file
@@ -0,0 +1,175 @@
|
||||
## Remote Reboot Reliability Handoff (Share Document)
|
||||
|
||||
### Purpose
|
||||
This document defines the agreed implementation scope for reliable remote reboot and shutdown of Raspberry Pi 5 clients, with monitoring-first visibility and safe escalation paths.
|
||||
|
||||
### Scope
|
||||
1. In scope: restart and shutdown command reliability.
|
||||
2. In scope: full lifecycle monitoring and audit visibility.
|
||||
3. In scope: capability-tier recovery model with optional managed PoE escalation.
|
||||
4. Out of scope: broader maintenance module in client-management for this cycle.
|
||||
5. Out of scope: mandatory dependency on customer-managed power switching.
|
||||
|
||||
### Agreed Operating Model
|
||||
1. Command delivery is asynchronous and lifecycle-tracked, not fire-and-forget.
|
||||
2. Commands use idempotent command_id semantics with stale-command rejection by expires_at.
|
||||
3. Monitoring is authoritative for operational state and escalation decisions.
|
||||
4. Recovery must function even when no managed power switching is available.
|
||||
|
||||
### Frozen Contract v1 (Effective Immediately)
|
||||
1. Canonical command topic: infoscreen/{client_uuid}/commands.
|
||||
2. Canonical ack topic: infoscreen/{client_uuid}/commands/ack.
|
||||
3. Transitional compatibility topics accepted during migration:
|
||||
- infoscreen/{client_uuid}/command
|
||||
- infoscreen/{client_uuid}/command/ack
|
||||
4. QoS policy: command QoS 1, ack QoS 1 recommended.
|
||||
5. Retain policy: commands and acks are non-retained.
|
||||
|
||||
Command payload schema (frozen):
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"command_id": "5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||
"client_uuid": "9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||
"action": "reboot_host",
|
||||
"issued_at": "2026-04-03T12:48:10Z",
|
||||
"expires_at": "2026-04-03T12:52:10Z",
|
||||
"requested_by": 1,
|
||||
"reason": "operator_request"
|
||||
}
|
||||
```
|
||||
|
||||
Ack payload schema (frozen):
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||
"status": "execution_started",
|
||||
"error_code": null,
|
||||
"error_message": null
|
||||
}
|
||||
```
|
||||
|
||||
Allowed ack status values:
|
||||
1. accepted
|
||||
2. execution_started
|
||||
3. completed
|
||||
4. failed
|
||||
|
||||
Frozen command action values:
|
||||
1. reboot_host
|
||||
2. shutdown_host
|
||||
|
||||
API endpoint mapping:
|
||||
1. POST /api/clients/{uuid}/restart -> action reboot_host
|
||||
2. POST /api/clients/{uuid}/shutdown -> action shutdown_host
|
||||
|
||||
Validation snippets:
|
||||
1. Human-readable snippets: implementation-plans/reboot-command-payload-schemas.md
|
||||
2. Machine-validated JSON Schema: implementation-plans/reboot-command-payload-schemas.json
|
||||
|
||||
### Command Lifecycle States
|
||||
1. queued
|
||||
2. publish_in_progress
|
||||
3. published
|
||||
4. ack_received
|
||||
5. execution_started
|
||||
6. awaiting_reconnect
|
||||
7. recovered
|
||||
8. completed
|
||||
9. failed
|
||||
10. expired
|
||||
11. timed_out
|
||||
12. canceled
|
||||
13. blocked_safety
|
||||
14. manual_intervention_required
|
||||
|
||||
### Timeout Defaults (Pi 5, USB-SATA SSD baseline)
|
||||
1. queued to publish_in_progress: immediate, timeout 5 seconds.
|
||||
2. publish_in_progress to published: timeout 8 seconds.
|
||||
3. published to ack_received: timeout 20 seconds.
|
||||
4. ack_received to execution_started: 15 seconds for service restart, 25 seconds for host reboot.
|
||||
5. execution_started to awaiting_reconnect: timeout 10 seconds.
|
||||
6. awaiting_reconnect to recovered: baseline 90 seconds after validation, cold-boot ceiling 150 seconds.
|
||||
7. recovered to completed: 15 to 20 seconds based on fleet stability.
|
||||
8. command expires_at default: 240 seconds, bounded 180 to 360 seconds.
|
||||
|
||||
### Recovery Tiers
|
||||
1. Tier 0 baseline, always required: watchdog, systemd auto-restart, lifecycle tracking, manual intervention fallback.
|
||||
2. Tier 1 optional: managed PoE per-port power-cycle escalation where customer infrastructure supports it.
|
||||
3. Tier 2 no remote power control: direct manual intervention workflow.
|
||||
|
||||
### Governance And Safety
|
||||
1. Role access: admin and superadmin.
|
||||
2. Bulk actions require reason capture.
|
||||
3. Safety lockout: maximum 3 reboot commands per client in 15 minutes.
|
||||
4. Escalation cooldown: 60 seconds before automatic move to manual_intervention_required.
|
||||
|
||||
### MQTT Auth Hardening (Phase 1, Required Before Broad Rollout)
|
||||
1. Intranet-only deployment is not sufficient protection for privileged MQTT actions by itself.
|
||||
2. Phase 1 hardening scope is broker authentication, authorization, and network restriction; payload URL allowlisting is deferred to a later client/server feature.
|
||||
3. MQTT broker must disable anonymous publish/subscribe access in production.
|
||||
4. MQTT broker must require authenticated identities for server-side publishers and client devices.
|
||||
5. MQTT broker must enforce ACLs so that:
|
||||
- only server-side services can publish to `infoscreen/{client_uuid}/commands`
|
||||
- only server-side services can publish scheduler event topics
|
||||
- each client can subscribe only to its own command topics and assigned event topics
|
||||
- each client can publish only its own ack, heartbeat, health, dashboard, and telemetry topics
|
||||
6. Broker port exposure must be restricted to the management network and approved hosts only.
|
||||
7. TLS support is strongly recommended in this phase and should be enabled when operationally feasible.
|
||||
|
||||
### Server Team Actions For Auth Hardening
|
||||
1. Provision broker credentials for command/event publishers and for client devices.
|
||||
2. Configure Mosquitto or equivalent broker ACLs for per-topic publish and subscribe restrictions.
|
||||
3. Disable anonymous access on production brokers.
|
||||
4. Restrict broker network exposure with firewall rules, VLAN policy, or equivalent network controls.
|
||||
5. Update server/frontend deployment to publish MQTT with authenticated credentials.
|
||||
6. Validate that server-side event publishing and reboot/shutdown command publishing still work under the new ACL policy.
|
||||
7. Coordinate credential distribution and rotation with the client deployment process.
|
||||
|
||||
### Credential Management Guidance
|
||||
1. Real MQTT passwords must not be stored in tracked documentation or committed templates.
|
||||
2. Each client device should receive a unique broker username and password, stored only in its local [/.env](.env).
|
||||
3. Server-side publisher credentials should be stored in the server team's secret-management path, not in source control.
|
||||
4. Recommended naming convention for client broker users: `infoscreen-client-<client-uuid-prefix>`.
|
||||
5. Client passwords should be random, at least 20 characters, and rotated through deployment tooling or broker administration procedures.
|
||||
6. The server/infrastructure team owns broker-side user creation, ACL assignment, rotation, and revocation.
|
||||
7. The client team owns loading credentials from local env files and validating connection behavior against the secured broker.
|
||||
|
||||
### Client Team Actions For Auth Hardening
|
||||
1. Add MQTT username/password support in the client connection setup.
|
||||
2. Add client-side TLS configuration support from environment when certificates are provided.
|
||||
3. Update local test helpers to support authenticated MQTT publishing and subscription.
|
||||
4. Validate command and event intake against the authenticated broker configuration before canary rollout.
|
||||
|
||||
### Ready For Server/Frontend Team (Auth Phase)
|
||||
1. Client implementation is ready to connect with MQTT auth from local `.env` (`MQTT_USERNAME`, `MQTT_PASSWORD`, optional TLS settings).
|
||||
2. Client command/event intake and client ack/telemetry publishing run over the authenticated MQTT session.
|
||||
3. Server/frontend team must now complete broker-side enforcement and publisher migration.
|
||||
|
||||
Server/frontend done criteria:
|
||||
1. Anonymous broker access is disabled in production.
|
||||
2. Server-side publishers use authenticated broker credentials.
|
||||
3. ACLs are active and validated for command, event, and client telemetry topics.
|
||||
4. At least one canary client proves end-to-end flow under ACLs:
|
||||
- server publishes command/event with authenticated publisher
|
||||
- client receives payload
|
||||
- client sends ack/telemetry successfully
|
||||
5. Revocation test passes: disabling one client credential blocks only that client without impacting others.
|
||||
|
||||
Operational note:
|
||||
1. Client-side auth support is necessary but not sufficient by itself; broker ACL/auth enforcement is the security control that must be enabled by the server/infrastructure team.
|
||||
|
||||
### Rollout Plan
|
||||
1. Contract freeze and sign-off.
|
||||
2. Platform and client implementation against frozen schemas.
|
||||
3. One-group canary.
|
||||
4. Rollback if failed plus timed_out exceeds 5 percent.
|
||||
5. Expand only after 7 days below intervention threshold.
|
||||
|
||||
### Success Criteria
|
||||
1. Deterministic command lifecycle visibility from enqueue to completion.
|
||||
2. No duplicate execution under reconnect or delayed-delivery conditions.
|
||||
3. Stable Pi 5 SSD reconnect behavior within defined baseline.
|
||||
4. Clear and actionable manual intervention states when automatic recovery is exhausted.
|
||||
54
implementation-plans/reboot-kickoff-summary.md
Normal file
54
implementation-plans/reboot-kickoff-summary.md
Normal file
@@ -0,0 +1,54 @@
|
||||
## Reboot Reliability Kickoff Summary
|
||||
|
||||
### Objective
|
||||
Ship a reliable, observable restart and shutdown workflow for Raspberry Pi 5 clients, with safe escalation and clear operator outcomes.
|
||||
|
||||
### What Is Included
|
||||
1. Asynchronous command lifecycle with idempotent command_id handling.
|
||||
2. Monitoring-first state visibility from queued to terminal outcomes.
|
||||
3. Client acknowledgements for accepted, execution_started, completed, and failed.
|
||||
4. Pi 5 USB-SATA SSD timeout baseline and tuning rules.
|
||||
5. Capability-tier recovery with optional managed PoE escalation.
|
||||
|
||||
### What Is Not Included
|
||||
1. Full maintenance module in client-management.
|
||||
2. Required managed power-switch integration.
|
||||
3. Production Wake-on-LAN rollout.
|
||||
|
||||
### Team Split
|
||||
1. Platform team: API command lifecycle, safety controls, listener ack ingestion.
|
||||
2. Web team: lifecycle-aware UX and command status display.
|
||||
3. Client team: strict validation, dedupe, ack sequence, secure execution helper, reboot continuity.
|
||||
|
||||
### Ownership Matrix
|
||||
| Team | Primary Plan File | Main Deliverables |
|
||||
| --- | --- | --- |
|
||||
| Platform team | implementation-plans/reboot-implementation-handoff-share.md | Command lifecycle backend, policy enforcement, listener ack mapping, safety lockout and escalation |
|
||||
| Web team | implementation-plans/reboot-implementation-handoff-share.md | Lifecycle UI states, bulk safety UX, capability visibility, command status polling |
|
||||
| Client team | implementation-plans/reboot-implementation-handoff-client-team.md | Command validation, dedupe persistence, ack sequence, secure execution helper, reboot continuity |
|
||||
| Project coordination | implementation-plans/reboot-kickoff-summary.md | Phase sequencing, canary gates, rollback thresholds, cross-team sign-off tracking |
|
||||
|
||||
### Baseline Operational Defaults
|
||||
1. Safety lockout: 3 reboot commands per client in rolling 15 minutes.
|
||||
2. Escalation cooldown: 60 seconds.
|
||||
3. Reconnect target on Pi 5 SSD: 90 seconds baseline, 150 seconds cold-boot ceiling.
|
||||
4. Rollback canary trigger: failed plus timed_out above 5 percent.
|
||||
|
||||
### Frozen Contract Snapshot
|
||||
1. Canonical command topic: infoscreen/{client_uuid}/commands.
|
||||
2. Canonical ack topic: infoscreen/{client_uuid}/commands/ack.
|
||||
3. Transitional compatibility topics during migration:
|
||||
- infoscreen/{client_uuid}/command
|
||||
- infoscreen/{client_uuid}/command/ack
|
||||
4. Command schema version: 1.0.
|
||||
5. Allowed command actions: reboot_host, shutdown_host.
|
||||
6. Allowed ack status values: accepted, execution_started, completed, failed.
|
||||
7. Validation snippets:
|
||||
- implementation-plans/reboot-command-payload-schemas.md
|
||||
- implementation-plans/reboot-command-payload-schemas.json
|
||||
|
||||
### Immediate Next Steps
|
||||
1. Continue implementation in parallel by team against frozen contract.
|
||||
2. Client team validates dedupe and expiry handling on canonical topics.
|
||||
3. Platform team verifies ack-state transitions for accepted, execution_started, completed, failed.
|
||||
4. Execute one-group canary and validate timing plus failure drills.
|
||||
26
mqqt-message baseline.json
Normal file
26
mqqt-message baseline.json
Normal file
File diff suppressed because one or more lines are too long
27
scripts/infoscreen-cmd-helper.sh
Executable file
27
scripts/infoscreen-cmd-helper.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Privileged command helper for remote reboot/shutdown actions.
|
||||
# Intended installation path: /usr/local/bin/infoscreen-cmd-helper.sh
|
||||
# Suggested sudoers entry:
|
||||
# infoscreen ALL=(ALL) NOPASSWD: /usr/local/bin/infoscreen-cmd-helper.sh
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: infoscreen-cmd-helper.sh <reboot_host|shutdown_host>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
action="$1"
|
||||
|
||||
case "$action" in
|
||||
reboot_host)
|
||||
exec systemctl reboot
|
||||
;;
|
||||
shutdown_host)
|
||||
exec systemctl poweroff
|
||||
;;
|
||||
*)
|
||||
echo "unsupported action: $action" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -3,6 +3,8 @@ Description=Infoscreen Display Manager
|
||||
Documentation=https://github.com/RobbStarkAustria/infoscreen_client_2025
|
||||
After=network.target graphical.target
|
||||
Wants=network-online.target
|
||||
# Publish an MQTT alert if systemd gives up restarting (StartLimitBurst exceeded).
|
||||
OnFailure=infoscreen-notify-failure@%n.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
55
scripts/infoscreen-notify-failure.sh
Executable file
55
scripts/infoscreen-notify-failure.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publishes a service-failed MQTT notification when called by systemd OnFailure=.
|
||||
# Usage: infoscreen-notify-failure.sh <failing-unit-name>
|
||||
#
|
||||
# Designed to be called from infoscreen-notify-failure@.service.
|
||||
# Reads broker credentials from .env; reads client UUID from config.
|
||||
# Safe to run even if MQTT is unreachable (exits cleanly, errors logged to journal).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FAILING_UNIT="${1:-unknown}"
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="$PROJECT_DIR/.env"
|
||||
UUID_FILE="$PROJECT_DIR/src/config/client_uuid.txt"
|
||||
|
||||
# Load .env (skip comments and blank lines)
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source <(grep -v '^\s*#' "$ENV_FILE" | grep -v '^\s*$')
|
||||
set +a
|
||||
fi
|
||||
|
||||
MQTT_BROKER="${MQTT_BROKER:-localhost}"
|
||||
MQTT_PORT="${MQTT_PORT:-1883}"
|
||||
MQTT_USER="${MQTT_USER:-}"
|
||||
MQTT_PASSWORD_BROKER="${MQTT_PASSWORD_BROKER:-}"
|
||||
|
||||
CLIENT_UUID="unknown"
|
||||
if [[ -f "$UUID_FILE" ]]; then
|
||||
CLIENT_UUID="$(cat "$UUID_FILE" | tr -d '[:space:]')"
|
||||
fi
|
||||
|
||||
TOPIC="infoscreen/${CLIENT_UUID}/service_failed"
|
||||
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
PAYLOAD=$(printf '{"event":"service_failed","unit":"%s","client_uuid":"%s","failed_at":"%s"}' \
|
||||
"$FAILING_UNIT" "$CLIENT_UUID" "$TIMESTAMP")
|
||||
|
||||
# Build mosquitto_pub auth args
|
||||
AUTH_ARGS=()
|
||||
if [[ -n "$MQTT_USER" ]]; then AUTH_ARGS+=(-u "$MQTT_USER"); fi
|
||||
if [[ -n "$MQTT_PASSWORD_BROKER" ]]; then AUTH_ARGS+=(-P "$MQTT_PASSWORD_BROKER"); fi
|
||||
|
||||
echo "Publishing service-failed notification: unit=$FAILING_UNIT client=$CLIENT_UUID"
|
||||
|
||||
mosquitto_pub \
|
||||
-h "$MQTT_BROKER" \
|
||||
-p "$MQTT_PORT" \
|
||||
"${AUTH_ARGS[@]}" \
|
||||
-t "$TOPIC" \
|
||||
-m "$PAYLOAD" \
|
||||
-q 1 \
|
||||
--retain \
|
||||
2>&1 || echo "WARNING: mosquitto_pub failed (broker unreachable?); notification not delivered"
|
||||
19
scripts/infoscreen-notify-failure@.service
Normal file
19
scripts/infoscreen-notify-failure@.service
Normal file
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Infoscreen service-failed MQTT notifier (%i)
|
||||
# One-shot: run once and exit. %i is the failing unit name passed by OnFailure=.
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=olafn
|
||||
Group=olafn
|
||||
WorkingDirectory=/home/olafn/infoscreen-dev
|
||||
EnvironmentFile=/home/olafn/infoscreen-dev/.env
|
||||
ExecStart=/home/olafn/infoscreen-dev/scripts/infoscreen-notify-failure.sh %i
|
||||
|
||||
# Do not restart the notifier itself.
|
||||
Restart=no
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=infoscreen-notify-failure
|
||||
52
scripts/infoscreen-simclient.service
Normal file
52
scripts/infoscreen-simclient.service
Normal file
@@ -0,0 +1,52 @@
|
||||
[Unit]
|
||||
Description=Infoscreen Simclient (MQTT communication)
|
||||
Documentation=https://github.com/RobbStarkAustria/infoscreen_client_2025
|
||||
# Simclient needs network before starting — MQTT will fail otherwise.
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# Publish an MQTT alert if systemd gives up restarting (StartLimitBurst exceeded).
|
||||
OnFailure=infoscreen-notify-failure@%n.service
|
||||
# StartLimit* must live in [Unit] for compatibility with older systemd (< 230).
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
# notify: simclient sends READY=1 via sd_notify once fully initialised.
|
||||
# WatchdogSec: if WATCHDOG=1 is not sent within this window, systemd kills
|
||||
# and restarts the process — detects hung/deadlocked main loops.
|
||||
Type=notify
|
||||
WatchdogSec=60
|
||||
User=olafn
|
||||
Group=olafn
|
||||
WorkingDirectory=/home/olafn/infoscreen-dev
|
||||
|
||||
# Load all client configuration from the local .env file.
|
||||
# Keep .env mode 600; systemd reads it as root before dropping privileges.
|
||||
EnvironmentFile=/home/olafn/infoscreen-dev/.env
|
||||
|
||||
# Start simclient
|
||||
ExecStart=/home/olafn/infoscreen-dev/scripts/start-simclient.sh
|
||||
|
||||
# Restart on failure (non-zero exit or signal).
|
||||
# This covers crash recovery AND the reboot-command lifecycle:
|
||||
# 1. Server sends reboot_host command
|
||||
# 2. Simclient publishes accepted + execution_started, then exits
|
||||
# 3. Systemd restarts simclient within RestartSec seconds
|
||||
# 4. On reconnect, heartbeat loop detects pending_recovery_command and
|
||||
# publishes completed — closing the lifecycle cleanly.
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=infoscreen-simclient
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
# Simclient runs in multi-user mode — no graphical session required.
|
||||
WantedBy=multi-user.target
|
||||
24
scripts/install-command-helper.sh
Executable file
24
scripts/install-command-helper.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Installs the privileged command helper and sudoers drop-in.
|
||||
# Usage: ./scripts/install-command-helper.sh [linux-user]
|
||||
|
||||
target_user="${1:-$USER}"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
helper_src="$script_dir/infoscreen-cmd-helper.sh"
|
||||
helper_dst="/usr/local/bin/infoscreen-cmd-helper.sh"
|
||||
sudoers_file="/etc/sudoers.d/infoscreen-command-helper"
|
||||
|
||||
if [[ ! -f "$helper_src" ]]; then
|
||||
echo "helper source not found: $helper_src" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo install -m 0755 "$helper_src" "$helper_dst"
|
||||
printf '%s\n' "$target_user ALL=(ALL) NOPASSWD: $helper_dst" | sudo tee "$sudoers_file" >/dev/null
|
||||
sudo chmod 0440 "$sudoers_file"
|
||||
sudo visudo -cf "$sudoers_file" >/dev/null
|
||||
|
||||
echo "Installed helper: $helper_dst"
|
||||
echo "Installed sudoers: $sudoers_file (user: $target_user)"
|
||||
34
scripts/mock-command-helper.sh
Executable file
34
scripts/mock-command-helper.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Non-destructive helper for command lifecycle canary tests.
|
||||
# Use by starting simclient with:
|
||||
# COMMAND_HELPER_PATH=/home/olafn/infoscreen-dev/scripts/mock-command-helper.sh
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: mock-command-helper.sh <reboot_host|shutdown_host>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
action="$1"
|
||||
|
||||
case "$action" in
|
||||
reboot_host|shutdown_host)
|
||||
;;
|
||||
*)
|
||||
echo "unsupported action: $action" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${MOCK_COMMAND_HELPER_FORCE_FAIL:-0}" == "1" ]]; then
|
||||
echo "forced failure for canary test (action=$action)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${MOCK_COMMAND_HELPER_SLEEP_SEC:-0}" != "0" ]]; then
|
||||
sleep "${MOCK_COMMAND_HELPER_SLEEP_SEC}"
|
||||
fi
|
||||
|
||||
echo "mock helper executed action=$action"
|
||||
exit 0
|
||||
35
scripts/start-simclient.sh
Executable file
35
scripts/start-simclient.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Start Simclient - MQTT communication and event intake for infoscreen
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
VENV_PATH="$PROJECT_ROOT/venv"
|
||||
SIMCLIENT="$PROJECT_ROOT/src/simclient.py"
|
||||
|
||||
echo "📡 Starting Simclient..."
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ ! -d "$VENV_PATH" ]; then
|
||||
echo "❌ Virtual environment not found at: $VENV_PATH"
|
||||
echo "Please create it with: python3 -m venv venv"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
source "$VENV_PATH/bin/activate"
|
||||
|
||||
# Check if simclient.py exists
|
||||
if [ ! -f "$SIMCLIENT" ]; then
|
||||
echo "❌ Simclient not found at: $SIMCLIENT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ENV="${ENV:-development}"
|
||||
echo "Environment: $ENV"
|
||||
|
||||
echo "Starting simclient..."
|
||||
echo "---"
|
||||
exec python3 "$SIMCLIENT"
|
||||
@@ -65,6 +65,8 @@ while true; do
|
||||
echo " 4) Scan for devices"
|
||||
echo " 5) Test Display Manager CEC integration"
|
||||
echo " 6) View CEC logs from Display Manager"
|
||||
echo " 7) Show power intent/state runtime files"
|
||||
echo " 8) Clear power intent/state runtime files"
|
||||
echo " q) Quit"
|
||||
echo ""
|
||||
read -p "Enter choice: " choice
|
||||
@@ -249,6 +251,35 @@ PYTEST
|
||||
echo ""
|
||||
read -p "Press Enter to continue..."
|
||||
;;
|
||||
7)
|
||||
echo -e "${YELLOW}Showing power intent/state runtime files...${NC}"
|
||||
echo ""
|
||||
INTENT_FILE="$PROJECT_ROOT/src/power_intent_state.json"
|
||||
STATE_FILE="$PROJECT_ROOT/src/power_state.json"
|
||||
|
||||
if [ -f "$INTENT_FILE" ]; then
|
||||
echo "power_intent_state.json:"
|
||||
echo "-------------------------"
|
||||
cat "$INTENT_FILE"
|
||||
else
|
||||
echo "power_intent_state.json not found"
|
||||
fi
|
||||
echo ""
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
echo "power_state.json:"
|
||||
echo "-----------------"
|
||||
cat "$STATE_FILE"
|
||||
else
|
||||
echo "power_state.json not found"
|
||||
fi
|
||||
echo ""
|
||||
read -p "Press Enter to continue..."
|
||||
;;
|
||||
8)
|
||||
echo -e "${YELLOW}Clearing power intent/state runtime files...${NC}"
|
||||
rm -f "$PROJECT_ROOT/src/power_intent_state.json" "$PROJECT_ROOT/src/power_state.json"
|
||||
echo -e "${GREEN}Removed runtime power files (if present).${NC}"
|
||||
;;
|
||||
q|Q)
|
||||
echo "Exiting..."
|
||||
exit 0
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
#!/bin/bash
|
||||
source "$(dirname "$0")/../.env"
|
||||
|
||||
MQTT_AUTH_ARGS=()
|
||||
MQTT_TLS_ARGS=()
|
||||
|
||||
if [[ -n "${MQTT_USERNAME:-}" ]]; then
|
||||
MQTT_AUTH_ARGS+=( -u "$MQTT_USERNAME" )
|
||||
fi
|
||||
if [[ -n "${MQTT_PASSWORD:-}" ]]; then
|
||||
MQTT_AUTH_ARGS+=( -P "$MQTT_PASSWORD" )
|
||||
fi
|
||||
if [[ "${MQTT_TLS_ENABLED:-0}" == "1" || "${MQTT_TLS_ENABLED:-0}" == "true" || "${MQTT_TLS_ENABLED:-0}" == "yes" ]]; then
|
||||
[[ -n "${MQTT_TLS_CA_CERT:-}" ]] && MQTT_TLS_ARGS+=( --cafile "$MQTT_TLS_CA_CERT" )
|
||||
[[ -n "${MQTT_TLS_CERT:-}" ]] && MQTT_TLS_ARGS+=( --cert "$MQTT_TLS_CERT" )
|
||||
[[ -n "${MQTT_TLS_KEY:-}" ]] && MQTT_TLS_ARGS+=( --key "$MQTT_TLS_KEY" )
|
||||
if [[ "${MQTT_TLS_INSECURE:-0}" == "1" || "${MQTT_TLS_INSECURE:-0}" == "true" || "${MQTT_TLS_INSECURE:-0}" == "yes" ]]; then
|
||||
MQTT_TLS_ARGS+=( --insecure )
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Testing MQTT connection to $MQTT_BROKER:$MQTT_PORT"
|
||||
echo "Publishing test message..."
|
||||
mosquitto_pub -h "$MQTT_BROKER" -p "$MQTT_PORT" -t "infoscreen/test" -m "Hello from Pi development setup"
|
||||
mosquitto_pub -h "$MQTT_BROKER" -p "$MQTT_PORT" "${MQTT_AUTH_ARGS[@]}" "${MQTT_TLS_ARGS[@]}" -t "infoscreen/test" -m "Hello from Pi development setup"
|
||||
|
||||
echo "Subscribing to test topic (press Ctrl+C to stop)..."
|
||||
mosquitto_sub -h "$MQTT_BROKER" -p "$MQTT_PORT" -t "infoscreen/test"
|
||||
mosquitto_sub -h "$MQTT_BROKER" -p "$MQTT_PORT" "${MQTT_AUTH_ARGS[@]}" "${MQTT_TLS_ARGS[@]}" -t "infoscreen/test"
|
||||
|
||||
271
scripts/test-power-intent.sh
Executable file
271
scripts/test-power-intent.sh
Executable file
@@ -0,0 +1,271 @@
|
||||
#!/bin/bash
|
||||
# Test TV power intent MQTT message flow (Phase 1 contract v1)
|
||||
# Requires: mosquitto_pub / mosquitto_sub
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ── Load .env ────────────────────────────────────────────────────────────────
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
# Strip inline comments and surrounding whitespace before export.
|
||||
while IFS='=' read -r key value; do
|
||||
key="${key//[$'\t\r\n']}"
|
||||
key="$(echo "$key" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
|
||||
# Skip comments/empty lines/invalid keys
|
||||
[[ -z "$key" ]] && continue
|
||||
[[ "$key" =~ ^# ]] && continue
|
||||
[[ "$key" =~ ^[A-Z_][A-Z0-9_]*$ ]] || continue
|
||||
|
||||
value="${value%%#*}" # strip inline comments
|
||||
value="${value//[$'\t\r\n']}"
|
||||
value="$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
|
||||
export "$key=$value"
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
|
||||
BROKER="${MQTT_BROKER:-localhost}"
|
||||
PORT="${MQTT_PORT:-1883}"
|
||||
MQTT_USERNAME="${MQTT_USERNAME:-}"
|
||||
MQTT_PASSWORD="${MQTT_PASSWORD:-}"
|
||||
MQTT_TLS_ENABLED="${MQTT_TLS_ENABLED:-0}"
|
||||
MQTT_TLS_CA_CERT="${MQTT_TLS_CA_CERT:-}"
|
||||
MQTT_TLS_CERT="${MQTT_TLS_CERT:-}"
|
||||
MQTT_TLS_KEY="${MQTT_TLS_KEY:-}"
|
||||
MQTT_TLS_INSECURE="${MQTT_TLS_INSECURE:-0}"
|
||||
|
||||
MQTT_AUTH_ARGS=()
|
||||
MQTT_TLS_ARGS=()
|
||||
|
||||
if [[ -n "$MQTT_USERNAME" ]]; then
|
||||
MQTT_AUTH_ARGS+=( -u "$MQTT_USERNAME" )
|
||||
fi
|
||||
if [[ -n "$MQTT_PASSWORD" ]]; then
|
||||
MQTT_AUTH_ARGS+=( -P "$MQTT_PASSWORD" )
|
||||
fi
|
||||
if [[ "$MQTT_TLS_ENABLED" == "1" || "$MQTT_TLS_ENABLED" == "true" || "$MQTT_TLS_ENABLED" == "yes" ]]; then
|
||||
[[ -n "$MQTT_TLS_CA_CERT" ]] && MQTT_TLS_ARGS+=( --cafile "$MQTT_TLS_CA_CERT" )
|
||||
[[ -n "$MQTT_TLS_CERT" ]] && MQTT_TLS_ARGS+=( --cert "$MQTT_TLS_CERT" )
|
||||
[[ -n "$MQTT_TLS_KEY" ]] && MQTT_TLS_ARGS+=( --key "$MQTT_TLS_KEY" )
|
||||
if [[ "$MQTT_TLS_INSECURE" == "1" || "$MQTT_TLS_INSECURE" == "true" || "$MQTT_TLS_INSECURE" == "yes" ]]; then
|
||||
MQTT_TLS_ARGS+=( --insecure )
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Read runtime IDs ─────────────────────────────────────────────────────────
|
||||
GROUP_ID_FILE="$PROJECT_ROOT/src/config/last_group_id.txt"
|
||||
CLIENT_UUID_FILE="$PROJECT_ROOT/src/config/client_uuid.txt"
|
||||
GROUP_ID=""
|
||||
CLIENT_UUID=""
|
||||
[ -f "$GROUP_ID_FILE" ] && GROUP_ID="$(cat "$GROUP_ID_FILE" 2>/dev/null | tr -d '[:space:]')"
|
||||
[ -f "$CLIENT_UUID_FILE" ] && CLIENT_UUID="$(cat "$CLIENT_UUID_FILE" 2>/dev/null | tr -d '[:space:]')"
|
||||
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo -e "${BLUE}TV Power Intent Test (Phase 1 Contract)${NC}"
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo " Broker : $BROKER:$PORT"
|
||||
echo " Group : ${GROUP_ID:-<not assigned yet>}"
|
||||
echo " Client : ${CLIENT_UUID:-<unknown>}"
|
||||
echo ""
|
||||
|
||||
# ── Check tools ──────────────────────────────────────────────────────────────
|
||||
if ! command -v mosquitto_pub &>/dev/null; then
|
||||
echo -e "${RED}mosquitto_pub not found. Install with: sudo apt-get install mosquitto-clients${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
now_iso() { date -u +"%Y-%m-%dT%H:%M:%S.000Z"; }
|
||||
|
||||
# expires_at = now + <seconds>
|
||||
expires_iso() {
|
||||
local secs="${1:-90}"
|
||||
date -u -d "@$(( $(date +%s) + secs ))" +"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
}
|
||||
|
||||
group_topic() {
|
||||
echo "infoscreen/groups/${GROUP_ID}/power/intent"
|
||||
}
|
||||
|
||||
publish_intent() {
|
||||
local state="$1"
|
||||
local reason="$2"
|
||||
local issued="${3:-$(now_iso)}"
|
||||
local expires="${4:-$(expires_iso 90)}"
|
||||
local intent_id
|
||||
intent_id="$(python3 -c 'import uuid; print(uuid.uuid4())')"
|
||||
local topic
|
||||
topic="$(group_topic)"
|
||||
|
||||
if [ -z "$GROUP_ID" ]; then
|
||||
echo -e "${RED}No group_id found. Subscribe a client and assign a group first.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "$intent_id",
|
||||
"group_id": $GROUP_ID,
|
||||
"desired_state": "$state",
|
||||
"reason": "$reason",
|
||||
"issued_at": "$issued",
|
||||
"expires_at": "$expires",
|
||||
"poll_interval_sec": 15,
|
||||
"active_event_ids": [$([ "$state" = "on" ] && echo "1" || echo "")],
|
||||
"event_window_start": $([ "$state" = "on" ] && echo "\"$(now_iso)\"" || echo "null"),
|
||||
"event_window_end": $([ "$state" = "on" ] && echo "\"$(expires_iso 3600)\"" || echo "null")
|
||||
}
|
||||
EOF
|
||||
)
|
||||
echo -e "${YELLOW}Publishing to: $topic${NC}"
|
||||
echo "$payload" | python3 -m json.tool 2>/dev/null || echo "$payload"
|
||||
echo ""
|
||||
mosquitto_pub -h "$BROKER" -p "$PORT" "${MQTT_AUTH_ARGS[@]}" "${MQTT_TLS_ARGS[@]}" -t "$topic" -q 1 --retain -m "$payload"
|
||||
echo -e "${GREEN}Published (retained, QoS 1)${NC}"
|
||||
echo "intent_id: $intent_id"
|
||||
}
|
||||
|
||||
clear_intent() {
|
||||
local topic
|
||||
topic="$(group_topic)"
|
||||
if [ -z "$GROUP_ID" ]; then
|
||||
echo -e "${RED}No group_id found.${NC}"
|
||||
return 1
|
||||
fi
|
||||
mosquitto_pub -h "$BROKER" -p "$PORT" "${MQTT_AUTH_ARGS[@]}" "${MQTT_TLS_ARGS[@]}" -t "$topic" -q 1 --retain --null-message
|
||||
echo -e "${GREEN}Retained intent cleared from broker${NC}"
|
||||
}
|
||||
|
||||
show_state_files() {
|
||||
echo ""
|
||||
local intent_file="$PROJECT_ROOT/src/power_intent_state.json"
|
||||
local state_file="$PROJECT_ROOT/src/power_state.json"
|
||||
local health_file="$PROJECT_ROOT/src/current_process_health.json"
|
||||
|
||||
for f in "$intent_file" "$state_file" "$health_file"; do
|
||||
local label
|
||||
label="$(basename "$f")"
|
||||
if [ -f "$f" ]; then
|
||||
echo -e "${BLUE}── $label ──────────────────${NC}"
|
||||
python3 -m json.tool "$f" 2>/dev/null || cat "$f"
|
||||
else
|
||||
echo -e "${YELLOW}$label not found${NC}"
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
}
|
||||
|
||||
watch_logs() {
|
||||
echo -e "${YELLOW}Following power-related log entries (Ctrl-C to stop)...${NC}"
|
||||
local dm_log="$PROJECT_ROOT/logs/display_manager.log"
|
||||
local sc_log="$PROJECT_ROOT/src/simclient.log"
|
||||
|
||||
if [ -f "$dm_log" ] && [ -f "$sc_log" ]; then
|
||||
tail -f "$dm_log" "$sc_log" | grep --line-buffered -i \
|
||||
-E "(power|intent|cec|turn|desired_state|mqtt_intent|local_fallback|POWER)"
|
||||
elif [ -f "$dm_log" ]; then
|
||||
tail -f "$dm_log" | grep --line-buffered -i \
|
||||
-E "(power|intent|cec|turn|desired_state|mqtt_intent|local_fallback|POWER)"
|
||||
else
|
||||
echo -e "${RED}No log files found. Have both processes run at least once?${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
subscribe_power_state() {
|
||||
if [ -z "$CLIENT_UUID" ]; then
|
||||
echo -e "${RED}No client_uuid found.${NC}"
|
||||
return 1
|
||||
fi
|
||||
local topic="infoscreen/${CLIENT_UUID}/power/state"
|
||||
echo -e "${YELLOW}Subscribing to: $topic${NC}"
|
||||
echo "(Ctrl-C to stop)"
|
||||
echo ""
|
||||
mosquitto_sub -h "$BROKER" -p "$PORT" "${MQTT_AUTH_ARGS[@]}" "${MQTT_TLS_ARGS[@]}" -t "$topic" | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
print(json.dumps(json.loads(line), indent=2))
|
||||
except Exception:
|
||||
print(line)
|
||||
"
|
||||
}
|
||||
|
||||
# ── Menu ─────────────────────────────────────────────────────────────────────
|
||||
while true; do
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo "Choose a test:"
|
||||
echo " 1) Publish ON intent (valid 90s, group ${GROUP_ID:-?})"
|
||||
echo " 2) Publish OFF intent (valid 90s, group ${GROUP_ID:-?})"
|
||||
echo " 3) Publish stale intent (already expired) — expect rejection"
|
||||
echo " 4) Publish malformed intent (missing fields) — expect rejection"
|
||||
echo " 5) Clear retained intent from broker (sends empty retained payload)"
|
||||
echo " 6) Show power_intent_state / power_state / health JSON files"
|
||||
echo " 7) Follow power-related log entries (display_manager + simclient)"
|
||||
echo " 8) Subscribe to infoscreen/{client}/power/state topic"
|
||||
echo " q) Quit"
|
||||
echo ""
|
||||
read -rp "Enter choice: " choice
|
||||
echo ""
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
publish_intent "on" "active_event"
|
||||
;;
|
||||
2)
|
||||
publish_intent "off" "no_active_event"
|
||||
;;
|
||||
3)
|
||||
# issued and expired both in the past
|
||||
STALE_ISSUED=$(date -u -d '5 minutes ago' +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
STALE_EXPIRES=$(date -u -d '2 minutes ago' +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
publish_intent "on" "active_event" "$STALE_ISSUED" "$STALE_EXPIRES"
|
||||
echo -e "${YELLOW}⚠ This intent is expired - client must reject it and show 'intent expired' in log${NC}"
|
||||
;;
|
||||
4)
|
||||
if [ -z "$GROUP_ID" ]; then
|
||||
echo -e "${RED}No group_id.${NC}"
|
||||
else
|
||||
TOPIC="$(group_topic)"
|
||||
mosquitto_pub -h "$BROKER" -p "$PORT" "${MQTT_AUTH_ARGS[@]}" "${MQTT_TLS_ARGS[@]}" -t "$TOPIC" -q 1 --retain \
|
||||
-m '{"schema_version":"1.0","desired_state":"on"}'
|
||||
echo -e "${YELLOW}⚠ Malformed intent published - client must reject with 'missing required field' in log${NC}"
|
||||
fi
|
||||
;;
|
||||
5)
|
||||
clear_intent
|
||||
;;
|
||||
6)
|
||||
show_state_files
|
||||
read -rp "Press Enter to continue..."
|
||||
;;
|
||||
7)
|
||||
watch_logs
|
||||
;;
|
||||
8)
|
||||
subscribe_power_state
|
||||
;;
|
||||
q|Q)
|
||||
echo "Exiting."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice${NC}"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
done
|
||||
244
scripts/test-reboot-command.sh
Executable file
244
scripts/test-reboot-command.sh
Executable file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env bash
|
||||
# Safe end-to-end command lifecycle canary for reboot/shutdown contract v1.
|
||||
# Verifies ack flow: accepted -> execution_started -> completed/failed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
while IFS='=' read -r key value; do
|
||||
key="${key//[$'\t\r\n']}"
|
||||
key="$(echo "$key" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
[[ -z "$key" ]] && continue
|
||||
[[ "$key" =~ ^# ]] && continue
|
||||
[[ "$key" =~ ^[A-Z_][A-Z0-9_]*$ ]] || continue
|
||||
value="${value%%#*}"
|
||||
value="${value//[$'\t\r\n']}"
|
||||
value="$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
export "$key=$value"
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
|
||||
BROKER="${MQTT_BROKER:-localhost}"
|
||||
PORT="${MQTT_PORT:-1883}"
|
||||
MQTT_USERNAME="${MQTT_USERNAME:-}"
|
||||
MQTT_PASSWORD="${MQTT_PASSWORD:-}"
|
||||
MQTT_TLS_ENABLED="${MQTT_TLS_ENABLED:-0}"
|
||||
MQTT_TLS_CA_CERT="${MQTT_TLS_CA_CERT:-}"
|
||||
MQTT_TLS_CERT="${MQTT_TLS_CERT:-}"
|
||||
MQTT_TLS_KEY="${MQTT_TLS_KEY:-}"
|
||||
MQTT_TLS_INSECURE="${MQTT_TLS_INSECURE:-0}"
|
||||
CLIENT_UUID_FILE="$PROJECT_ROOT/src/config/client_uuid.txt"
|
||||
LAST_COMMAND_STATE_FILE="$PROJECT_ROOT/src/config/last_command_state.json"
|
||||
|
||||
if [[ ! -f "$CLIENT_UUID_FILE" ]]; then
|
||||
echo -e "${RED}client UUID file missing: $CLIENT_UUID_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v mosquitto_pub >/dev/null 2>&1 || ! command -v mosquitto_sub >/dev/null 2>&1; then
|
||||
echo -e "${RED}mosquitto_pub/sub not found. Install mosquitto-clients.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_UUID="$(tr -d '[:space:]' < "$CLIENT_UUID_FILE")"
|
||||
COMMAND_TOPIC="infoscreen/${CLIENT_UUID}/commands"
|
||||
COMMAND_TOPIC_ALIAS="infoscreen/${CLIENT_UUID}/command"
|
||||
ACK_TOPIC="infoscreen/${CLIENT_UUID}/commands/ack"
|
||||
ACK_TOPIC_ALIAS="infoscreen/${CLIENT_UUID}/command/ack"
|
||||
|
||||
MQTT_AUTH_ARGS=()
|
||||
MQTT_TLS_ARGS=()
|
||||
|
||||
if [[ -n "$MQTT_USERNAME" ]]; then
|
||||
MQTT_AUTH_ARGS+=( -u "$MQTT_USERNAME" )
|
||||
fi
|
||||
if [[ -n "$MQTT_PASSWORD" ]]; then
|
||||
MQTT_AUTH_ARGS+=( -P "$MQTT_PASSWORD" )
|
||||
fi
|
||||
if [[ "$MQTT_TLS_ENABLED" == "1" || "$MQTT_TLS_ENABLED" == "true" || "$MQTT_TLS_ENABLED" == "yes" ]]; then
|
||||
[[ -n "$MQTT_TLS_CA_CERT" ]] && MQTT_TLS_ARGS+=( --cafile "$MQTT_TLS_CA_CERT" )
|
||||
[[ -n "$MQTT_TLS_CERT" ]] && MQTT_TLS_ARGS+=( --cert "$MQTT_TLS_CERT" )
|
||||
[[ -n "$MQTT_TLS_KEY" ]] && MQTT_TLS_ARGS+=( --key "$MQTT_TLS_KEY" )
|
||||
if [[ "$MQTT_TLS_INSECURE" == "1" || "$MQTT_TLS_INSECURE" == "true" || "$MQTT_TLS_INSECURE" == "yes" ]]; then
|
||||
MQTT_TLS_ARGS+=( --insecure )
|
||||
fi
|
||||
fi
|
||||
|
||||
ACTION="${1:-reboot_host}"
|
||||
MODE="${2:-success}" # success | failed
|
||||
TOPIC_MODE="${3:-canonical}" # canonical | alias
|
||||
WAIT_SEC="${4:-25}"
|
||||
|
||||
if [[ "$ACTION" != "reboot_host" && "$ACTION" != "shutdown_host" ]]; then
|
||||
echo -e "${RED}invalid action '$ACTION' (expected reboot_host|shutdown_host)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$MODE" != "success" && "$MODE" != "failed" ]]; then
|
||||
echo -e "${RED}invalid mode '$MODE' (expected success|failed)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$TOPIC_MODE" != "canonical" && "$TOPIC_MODE" != "alias" ]]; then
|
||||
echo -e "${RED}invalid topic mode '$TOPIC_MODE' (expected canonical|alias)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$WAIT_SEC" =~ ^[0-9]+$ ]] || [[ "$WAIT_SEC" -lt 1 ]]; then
|
||||
echo -e "${RED}invalid wait seconds '$WAIT_SEC' (expected positive integer)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$TOPIC_MODE" == "alias" ]]; then
|
||||
COMMAND_TOPIC="$COMMAND_TOPIC_ALIAS"
|
||||
fi
|
||||
|
||||
COMMAND_ID="$(python3 - <<'PY'
|
||||
import uuid
|
||||
print(uuid.uuid4())
|
||||
PY
|
||||
)"
|
||||
ISSUED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
EXPIRES_EPOCH="$(( $(date +%s) + 240 ))"
|
||||
EXPIRES_AT="$(date -u -d "@$EXPIRES_EPOCH" +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
PAYLOAD="$(cat <<EOF
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"command_id": "$COMMAND_ID",
|
||||
"client_uuid": "$CLIENT_UUID",
|
||||
"action": "$ACTION",
|
||||
"issued_at": "$ISSUED_AT",
|
||||
"expires_at": "$EXPIRES_AT",
|
||||
"requested_by": 1,
|
||||
"reason": "canary_test"
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
|
||||
TMP_ACK_LOG="$(mktemp)"
|
||||
cleanup() {
|
||||
[[ -n "${SUB_PID_1:-}" ]] && kill "$SUB_PID_1" >/dev/null 2>&1 || true
|
||||
[[ -n "${SUB_PID_2:-}" ]] && kill "$SUB_PID_2" >/dev/null 2>&1 || true
|
||||
rm -f "$TMP_ACK_LOG"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo -e "${BLUE}Command Lifecycle Canary${NC}"
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo " Broker : $BROKER:$PORT"
|
||||
echo " Client UUID : $CLIENT_UUID"
|
||||
echo " Command ID : $COMMAND_ID"
|
||||
echo " Action : $ACTION"
|
||||
echo " Mode : $MODE"
|
||||
echo " Cmd Topic : $COMMAND_TOPIC"
|
||||
echo " Ack Topics : $ACK_TOPIC , $ACK_TOPIC_ALIAS"
|
||||
echo ""
|
||||
echo -e "${YELLOW}IMPORTANT${NC}: to avoid real reboot/shutdown, run simclient with"
|
||||
echo " COMMAND_HELPER_PATH=$PROJECT_ROOT/scripts/mock-command-helper.sh"
|
||||
echo ""
|
||||
|
||||
# Subscribe first to avoid missing retained/non-retained race windows.
|
||||
mosquitto_sub -h "$BROKER" -p "$PORT" "${MQTT_AUTH_ARGS[@]}" "${MQTT_TLS_ARGS[@]}" -q 1 -v -t "$ACK_TOPIC" >> "$TMP_ACK_LOG" &
|
||||
SUB_PID_1=$!
|
||||
mosquitto_sub -h "$BROKER" -p "$PORT" "${MQTT_AUTH_ARGS[@]}" "${MQTT_TLS_ARGS[@]}" -q 1 -v -t "$ACK_TOPIC_ALIAS" >> "$TMP_ACK_LOG" &
|
||||
SUB_PID_2=$!
|
||||
sleep 0.5
|
||||
|
||||
if [[ "$MODE" == "failed" ]]; then
|
||||
echo -e "${YELLOW}If simclient was started with MOCK_COMMAND_HELPER_FORCE_FAIL=1, expected terminal status is failed.${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Publishing command payload...${NC}"
|
||||
mosquitto_pub -h "$BROKER" -p "$PORT" "${MQTT_AUTH_ARGS[@]}" "${MQTT_TLS_ARGS[@]}" -q 1 -t "$COMMAND_TOPIC" -m "$PAYLOAD"
|
||||
|
||||
EXPECTED_TERMINAL="completed"
|
||||
if [[ "$MODE" == "failed" ]]; then
|
||||
EXPECTED_TERMINAL="failed"
|
||||
fi
|
||||
|
||||
EXPECT_RECOVERY_COMPLETION=0
|
||||
if [[ "$ACTION" == "reboot_host" && "$MODE" == "success" ]]; then
|
||||
EXPECTED_TERMINAL=""
|
||||
EXPECT_RECOVERY_COMPLETION=1
|
||||
fi
|
||||
|
||||
DEADLINE=$(( $(date +%s) + WAIT_SEC ))
|
||||
SEEN_ACCEPTED=0
|
||||
SEEN_STARTED=0
|
||||
SEEN_TERMINAL=0
|
||||
|
||||
while [[ $(date +%s) -lt $DEADLINE ]]; do
|
||||
if grep -q '"command_id"' "$TMP_ACK_LOG" 2>/dev/null; then
|
||||
if grep -q "\"command_id\": \"$COMMAND_ID\"" "$TMP_ACK_LOG"; then
|
||||
grep -q '"status": "accepted"' "$TMP_ACK_LOG" && SEEN_ACCEPTED=1 || true
|
||||
grep -q '"status": "execution_started"' "$TMP_ACK_LOG" && SEEN_STARTED=1 || true
|
||||
if [[ -n "$EXPECTED_TERMINAL" ]]; then
|
||||
grep -q "\"status\": \"$EXPECTED_TERMINAL\"" "$TMP_ACK_LOG" && SEEN_TERMINAL=1 || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $SEEN_ACCEPTED -eq 1 && $SEEN_STARTED -eq 1 ]]; then
|
||||
if [[ -z "$EXPECTED_TERMINAL" || $SEEN_TERMINAL -eq 1 ]]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}Ack stream (filtered by command_id):${NC}"
|
||||
python3 - <<'PY' "$TMP_ACK_LOG" "$COMMAND_ID"
|
||||
import json
|
||||
import sys
|
||||
|
||||
path, command_id = sys.argv[1], sys.argv[2]
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(" ", 1)
|
||||
payload = parts[1] if len(parts) == 2 else parts[0]
|
||||
try:
|
||||
obj = json.loads(payload)
|
||||
except Exception:
|
||||
continue
|
||||
if obj.get("command_id") == command_id:
|
||||
print(json.dumps(obj, indent=2))
|
||||
PY
|
||||
|
||||
if [[ $SEEN_ACCEPTED -eq 1 && $SEEN_STARTED -eq 1 ]]; then
|
||||
if [[ -z "$EXPECTED_TERMINAL" ]]; then
|
||||
echo -e "${GREEN}PASS${NC}: observed accepted -> execution_started"
|
||||
echo -e "${YELLOW}NOTE${NC}: completed for reboot_host is expected only after client reconnect/recovery."
|
||||
elif [[ $SEEN_TERMINAL -eq 1 ]]; then
|
||||
echo -e "${GREEN}PASS${NC}: observed accepted -> execution_started -> $EXPECTED_TERMINAL"
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}: missing expected terminal state $EXPECTED_TERMINAL for command_id=$COMMAND_ID"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}: missing expected lifecycle states for command_id=$COMMAND_ID"
|
||||
if [[ -n "$EXPECTED_TERMINAL" ]]; then
|
||||
echo " observed: accepted=$SEEN_ACCEPTED execution_started=$SEEN_STARTED terminal($EXPECTED_TERMINAL)=$SEEN_TERMINAL"
|
||||
else
|
||||
echo " observed: accepted=$SEEN_ACCEPTED execution_started=$SEEN_STARTED"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f "$LAST_COMMAND_STATE_FILE" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Last command state:${NC}"
|
||||
python3 -m json.tool "$LAST_COMMAND_STATE_FILE" || cat "$LAST_COMMAND_STATE_FILE"
|
||||
fi
|
||||
@@ -8,6 +8,13 @@ VERSION=latest
|
||||
# MQTT Broker
|
||||
MQTT_BROKER=192.168.1.100
|
||||
MQTT_PORT=1883
|
||||
MQTT_USERNAME=infoscreen-client-<client-uuid-prefix>
|
||||
MQTT_PASSWORD=<set-per-device-20-char-random-password>
|
||||
MQTT_TLS_ENABLED=0
|
||||
# MQTT_TLS_CA_CERT=/etc/infoscreen/mqtt/ca.crt
|
||||
# MQTT_TLS_CERT=/etc/infoscreen/mqtt/client.crt
|
||||
# MQTT_TLS_KEY=/etc/infoscreen/mqtt/client.key
|
||||
# MQTT_TLS_INSECURE=0
|
||||
|
||||
# Timing (production values)
|
||||
HEARTBEAT_INTERVAL=60
|
||||
|
||||
@@ -9,6 +9,13 @@ LOG_LEVEL=DEBUG
|
||||
# MQTT Broker Configuration
|
||||
MQTT_BROKER=192.168.1.100 # Change to your MQTT server IP
|
||||
MQTT_PORT=1883
|
||||
MQTT_USERNAME=infoscreen-client-<client-uuid-prefix>
|
||||
MQTT_PASSWORD=<set-per-device-20-char-random-password>
|
||||
MQTT_TLS_ENABLED=0
|
||||
# MQTT_TLS_CA_CERT=/etc/infoscreen/mqtt/ca.crt
|
||||
# MQTT_TLS_CERT=/etc/infoscreen/mqtt/client.crt
|
||||
# MQTT_TLS_KEY=/etc/infoscreen/mqtt/client.key
|
||||
# MQTT_TLS_INSECURE=0
|
||||
|
||||
# Timing Configuration (shorter intervals for development)
|
||||
HEARTBEAT_INTERVAL=10 # Heartbeat frequency in seconds
|
||||
|
||||
343
src/README.md
343
src/README.md
@@ -1,274 +1,165 @@
|
||||
# Infoscreen Client - Raspberry Pi Development
|
||||
# Developer Guide
|
||||
|
||||
A presentation system client for Raspberry Pi that communicates with a server via MQTT to display presentations, videos, and web content in kiosk mode.
|
||||
This document is the developer-facing companion to the root [README.md](../README.md). It focuses on code structure, runtime boundaries, MQTT flow, and debugging during implementation work.
|
||||
|
||||
## Features
|
||||
For installation, operator usage, and deployment, start at [README.md](../README.md).
|
||||
|
||||
- 📡 MQTT communication with server
|
||||
- 📥 Automatic file downloads (presentations, videos)
|
||||
- 🖥️ **Automated display management** with dedicated Display Manager
|
||||
- 🎯 Event-driven content switching (presentations, videos, web pages)
|
||||
- ⏰ Time-based event scheduling with automatic start/stop
|
||||
- 🔄 Graceful application transitions (LibreOffice, Chromium, VLC)
|
||||
- 📸 Screenshot capture for dashboard monitoring
|
||||
- 👥 Group-based content management
|
||||
- 💖 Heartbeat monitoring
|
||||
## Architecture
|
||||
|
||||
## Quick Setup
|
||||
The client is split into two cooperating processes:
|
||||
|
||||
### 1. Flash Raspberry Pi OS
|
||||
- Use **Raspberry Pi OS (64-bit) with Desktop**
|
||||
- Enable SSH and configure WiFi in Pi Imager
|
||||
- Boot Pi and connect to network
|
||||
- `simclient.py`: MQTT communication, discovery, group assignment, event intake, heartbeat, dashboard publishing, power-intent intake.
|
||||
- `display_manager.py`: event polling, display orchestration, HDMI-CEC, screenshots, local process health state.
|
||||
|
||||
Primary runtime flow:
|
||||
|
||||
1. `simclient.py` receives group and event messages over MQTT.
|
||||
2. It writes the active event into `current_event.json`.
|
||||
3. `display_manager.py` polls that file and starts or stops the display process.
|
||||
4. `display_manager.py` writes health, screenshot, and power telemetry files.
|
||||
5. `simclient.py` publishes dashboard, health, and power-state messages.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `display_manager.py`: display lifecycle, HDMI-CEC, screenshots, local fallback logic.
|
||||
- `simclient.py`: MQTT callbacks, event persistence, dashboard publishing, power-intent validation, command intake.
|
||||
- `current_event.json`: active event state consumed by the display manager.
|
||||
- `current_process_health.json`: local health bridge for monitoring.
|
||||
- `power_intent_state.json`: latest validated power intent from MQTT.
|
||||
- `power_state.json`: latest applied power action telemetry.
|
||||
- `screenshots/meta.json`: screenshot metadata used by the dashboard path.
|
||||
- `../scripts/start-simclient.sh`: launcher for `simclient.py` (used by the systemd unit).
|
||||
- `../scripts/start-display-manager.sh`: launcher for `display_manager.py`.
|
||||
- `../scripts/infoscreen-simclient.service`: systemd unit for `simclient.py`.
|
||||
- `../scripts/infoscreen-display.service`: systemd unit for `display_manager.py`.
|
||||
- `../scripts/infoscreen-notify-failure@.service`: systemd template unit; fires on `OnFailure=`.
|
||||
- `../scripts/infoscreen-notify-failure.sh`: publishes `service_failed` MQTT alert when a unit gives up.
|
||||
|
||||
## Developer Workflow
|
||||
|
||||
On deployed devices, both processes are managed by systemd:
|
||||
|
||||
### 2. Install Development Environment
|
||||
```bash
|
||||
# Run on your Raspberry Pi:
|
||||
curl -sSL https://raw.githubusercontent.com/RobbStarkAustria/infoscreen_client_2025/main/pi-dev-setup.sh | bash
|
||||
# Start / stop / restart
|
||||
sudo systemctl start infoscreen-simclient infoscreen-display
|
||||
sudo systemctl restart infoscreen-simclient infoscreen-display
|
||||
|
||||
# Follow logs
|
||||
journalctl -u infoscreen-simclient -u infoscreen-display -f
|
||||
```
|
||||
|
||||
### 3. Configure MQTT Broker
|
||||
First-time systemd setup:
|
||||
|
||||
```bash
|
||||
sudo cp scripts/infoscreen-simclient.service /etc/systemd/system/
|
||||
sudo cp scripts/infoscreen-display.service /etc/systemd/system/
|
||||
sudo cp scripts/infoscreen-notify-failure@.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable infoscreen-simclient infoscreen-display
|
||||
```
|
||||
|
||||
Or run `src/pi-setup.sh` which includes the above as step 14.
|
||||
|
||||
For local development without systemd:
|
||||
|
||||
```bash
|
||||
cd ~/infoscreen-dev
|
||||
nano .env
|
||||
# Update MQTT_BROKER=your-server-ip
|
||||
```
|
||||
source venv/bin/activate
|
||||
|
||||
### 4. Test Setup
|
||||
```bash
|
||||
./scripts/test-mqtt.sh # Test MQTT connection
|
||||
./scripts/test-screenshot.sh # Test screenshot capture
|
||||
./scripts/test-presentation.sh # Test presentation tools
|
||||
```
|
||||
# Terminal 1
|
||||
./scripts/start-simclient.sh
|
||||
|
||||
### 5. Start Development
|
||||
```bash
|
||||
# Terminal 1: Start MQTT client (receives events)
|
||||
./scripts/start-dev.sh
|
||||
|
||||
# Terminal 2: Start Display Manager (controls screen)
|
||||
# Terminal 2
|
||||
./scripts/start-display-manager.sh
|
||||
|
||||
# Or use interactive menu:
|
||||
./dev-workflow.sh
|
||||
```
|
||||
|
||||
**Important**: You need **both** processes running:
|
||||
- `simclient.py` - Handles MQTT communication and writes events
|
||||
- `display_manager.py` - Reads events and controls display software
|
||||
Useful helpers:
|
||||
|
||||
See [DISPLAY_MANAGER.md](DISPLAY_MANAGER.md) for detailed documentation.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Daily Development
|
||||
```bash
|
||||
cd ~/infoscreen-dev
|
||||
./dev-workflow.sh # Interactive menu with all options
|
||||
```
|
||||
|
||||
**Menu Options:**
|
||||
1. Start development client (MQTT)
|
||||
2. Start Display Manager
|
||||
3. View live logs
|
||||
4. Test Display Manager
|
||||
5. Test screenshot capture
|
||||
6. Test MQTT connection
|
||||
7. Test presentation tools
|
||||
8. Git status and sync
|
||||
9. Restart systemd services
|
||||
10. Monitor system resources
|
||||
11. Open tmux session
|
||||
|
||||
### Remote Development (Recommended)
|
||||
```bash
|
||||
# From your main computer:
|
||||
# Add to ~/.ssh/config
|
||||
Host pi-dev
|
||||
HostName YOUR_PI_IP
|
||||
User pi
|
||||
|
||||
# Connect with VS Code
|
||||
code --remote ssh-remote+pi-dev ~/infoscreen-dev
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
~/infoscreen-dev/
|
||||
├── .env # Configuration
|
||||
├── src/ # Source code (this repository)
|
||||
│ ├── simclient.py # MQTT client (event receiver)
|
||||
│ ├── display_manager.py # Display controller (NEW!)
|
||||
│ ├── current_event.json # Current active event
|
||||
│ ├── DISPLAY_MANAGER.md # Display Manager documentation
|
||||
│ └── config/ # Client UUID and group ID
|
||||
├── venv/ # Python virtual environment
|
||||
├── presentation/ # Downloaded presentation files
|
||||
├── screenshots/ # Screenshot captures
|
||||
├── logs/ # Application logs
|
||||
│ ├── simclient.log # MQTT client logs
|
||||
│ └── display_manager.log # Display Manager logs
|
||||
└── scripts/ # Development helper scripts
|
||||
├── start-dev.sh # Start MQTT client
|
||||
├── start-display-manager.sh # Start Display Manager (NEW!)
|
||||
├── test-display-manager.sh # Test display events (NEW!)
|
||||
├── test-mqtt.sh # Test MQTT connection
|
||||
├── test-screenshot.sh # Test screenshot capture
|
||||
└── test-presentation.sh # Test presentation tools
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (.env)
|
||||
```bash
|
||||
# Development settings
|
||||
ENV=development
|
||||
DEBUG_MODE=1
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
# MQTT Configuration
|
||||
MQTT_BROKER=192.168.1.100 # Your MQTT server IP
|
||||
MQTT_PORT=1883
|
||||
|
||||
# Intervals (seconds)
|
||||
HEARTBEAT_INTERVAL=10 # Heartbeat frequency
|
||||
SCREENSHOT_INTERVAL=30 # Screenshot capture frequency
|
||||
DISPLAY_CHECK_INTERVAL=5 # Display Manager event check frequency
|
||||
```
|
||||
- `./dev-workflow.sh`
|
||||
- `./scripts/test-display-manager.sh`
|
||||
- `./scripts/test-mqtt.sh`
|
||||
- `./scripts/test-screenshot.sh`
|
||||
- `./scripts/test-power-intent.sh`
|
||||
- `./scripts/test-progress-bars.sh`
|
||||
|
||||
## MQTT Topics
|
||||
|
||||
### Client → Server
|
||||
- `infoscreen/discovery` - Client registration
|
||||
- `infoscreen/{client_id}/heartbeat` - Regular heartbeat
|
||||
- `infoscreen/{client_id}/dashboard` - Screenshot + status
|
||||
|
||||
- `infoscreen/discovery`
|
||||
- `infoscreen/{client_id}/heartbeat`
|
||||
- `infoscreen/{client_id}/dashboard`
|
||||
- `infoscreen/{client_id}/health` — includes `broker_connection` block with `reconnect_count`, `last_disconnect_at`
|
||||
- `infoscreen/{client_id}/power/state`
|
||||
- `infoscreen/{client_id}/commands/ack` — command acknowledgement (states: `accepted`, `rejected`, `execution_started`, `completed`, `failed`)
|
||||
- `infoscreen/{client_id}/command/ack` — legacy ack topic (also published for compatibility)
|
||||
- `infoscreen/{client_id}/service_failed` — retained alert published by `infoscreen-notify-failure.sh` when systemd gives up restarting a unit
|
||||
|
||||
### Server → Client
|
||||
- `infoscreen/{client_id}/discovery_ack` - Registration acknowledgment
|
||||
- `infoscreen/{client_id}/group_id` - Group assignment
|
||||
- `infoscreen/events/{group_id}` - Event messages with content
|
||||
|
||||
## Event Format
|
||||
- `infoscreen/{client_id}/discovery_ack`
|
||||
- `infoscreen/{client_id}/group_id`
|
||||
- `infoscreen/events/{group_id}`
|
||||
- `infoscreen/groups/{group_id}/power/intent`
|
||||
- `infoscreen/{client_id}/commands` — remote command intake (`reboot`, `shutdown`)
|
||||
|
||||
The Display Manager supports three event types:
|
||||
## Event and Display Notes
|
||||
|
||||
**Presentation Event:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Company Overview",
|
||||
"start": "2025-10-01 08:00:00",
|
||||
"end": "2025-10-01 18:00:00",
|
||||
"presentation": {
|
||||
"files": [
|
||||
{
|
||||
"url": "https://server/presentations/slide.pptx",
|
||||
"name": "slide.pptx"
|
||||
}
|
||||
],
|
||||
"slide_interval": 10,
|
||||
"auto_advance": true
|
||||
}
|
||||
}
|
||||
```
|
||||
Supported runtime content categories:
|
||||
|
||||
**Web Page Event:**
|
||||
```json
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Dashboard",
|
||||
"start": "2025-10-01 08:00:00",
|
||||
"end": "2025-10-01 18:00:00",
|
||||
"web": {
|
||||
"url": "https://dashboard.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
- presentation
|
||||
- video
|
||||
- web / webpage / website / webuntis
|
||||
|
||||
**Video Event:**
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Promo Video",
|
||||
"start": "2025-10-01 08:00:00",
|
||||
"end": "2025-10-01 18:00:00",
|
||||
"video": {
|
||||
"url": "https://server/videos/promo.mp4",
|
||||
"loop": true
|
||||
}
|
||||
}
|
||||
```
|
||||
Presentation behavior is documented in [../IMPRESSIVE_INTEGRATION.md](../IMPRESSIVE_INTEGRATION.md).
|
||||
|
||||
See [DISPLAY_MANAGER.md](DISPLAY_MANAGER.md) for complete event documentation.
|
||||
TV power coordination references:
|
||||
|
||||
- [../TV_POWER_INTENT_SERVER_CONTRACT_V1.md](../TV_POWER_INTENT_SERVER_CONTRACT_V1.md)
|
||||
- [../TV_POWER_RUNBOOK.md](../TV_POWER_RUNBOOK.md)
|
||||
|
||||
## Debugging
|
||||
|
||||
### View Logs
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
tail -f ~/infoscreen-dev/logs/simclient.log
|
||||
tail -f ~/infoscreen-dev/logs/display_manager.log ~/infoscreen-dev/src/simclient.log
|
||||
```
|
||||
|
||||
### MQTT Debugging
|
||||
```bash
|
||||
# Subscribe to all infoscreen topics
|
||||
mosquitto_sub -h YOUR_BROKER_IP -t "infoscreen/+/+"
|
||||
### Runtime Files
|
||||
|
||||
# Publish test event
|
||||
mosquitto_pub -h YOUR_BROKER_IP -t "infoscreen/events/test-group" -m '{"web":{"url":"https://google.com"}}'
|
||||
```bash
|
||||
cat ~/infoscreen-dev/src/current_event.json
|
||||
cat ~/infoscreen-dev/src/current_process_health.json
|
||||
cat ~/infoscreen-dev/src/power_intent_state.json
|
||||
cat ~/infoscreen-dev/src/power_state.json
|
||||
```
|
||||
|
||||
### System Service (Optional)
|
||||
```bash
|
||||
# Enable automatic startup
|
||||
sudo systemctl enable infoscreen-dev
|
||||
sudo systemctl start infoscreen-dev
|
||||
### MQTT Inspection
|
||||
|
||||
# View service logs
|
||||
sudo journalctl -u infoscreen-dev -f
|
||||
```bash
|
||||
mosquitto_sub -h YOUR_BROKER_IP -t 'infoscreen/#'
|
||||
```
|
||||
|
||||
## Hardware Requirements
|
||||
### Screenshots
|
||||
|
||||
- **Raspberry Pi 4 or 5** (recommended Pi 5 for best performance)
|
||||
- **SSD storage** (much faster than SD card)
|
||||
- **Display** connected via HDMI
|
||||
- **Network connection** (WiFi or Ethernet)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Display Issues
|
||||
```bash
|
||||
export DISPLAY=:0
|
||||
echo $DISPLAY
|
||||
ls -lh ~/infoscreen-dev/src/screenshots/
|
||||
cat ~/infoscreen-dev/src/screenshots/meta.json
|
||||
```
|
||||
|
||||
### Screenshot Issues
|
||||
```bash
|
||||
# Test screenshot manually
|
||||
scrot ~/test.png
|
||||
# Check permissions
|
||||
sudo usermod -a -G video pi
|
||||
```
|
||||
## Environment Notes
|
||||
|
||||
### MQTT Connection Issues
|
||||
```bash
|
||||
# Test broker connectivity
|
||||
telnet YOUR_BROKER_IP 1883
|
||||
# Check firewall
|
||||
sudo ufw status
|
||||
```
|
||||
- `ENV=development` disables HDMI-CEC in the display manager.
|
||||
- `POWER_CONTROL_MODE` controls local vs hybrid vs mqtt power behavior.
|
||||
- `COMMAND_HELPER_PATH` points to the shell script that executes privileged commands (reboot/shutdown). Use `mock-command-helper.sh` for local testing.
|
||||
- `COMMAND_MOCK_REBOOT_IMMEDIATE_COMPLETE=1` makes a mock reboot complete immediately instead of waiting for process restart. Only works when the helper basename is `mock-command-helper.sh`.
|
||||
- File download host rewriting is handled in `simclient.py` using `FILE_SERVER_*` settings.
|
||||
|
||||
## Development vs Production
|
||||
## Related Documents
|
||||
|
||||
This setup is optimized for **development**:
|
||||
- ✅ Fast iteration (edit → save → restart)
|
||||
- ✅ Native debugging and logging
|
||||
- ✅ Direct hardware access
|
||||
- ✅ Remote development friendly
|
||||
|
||||
For **production deployment** with multiple clients, consider containerization for easier updates and management.
|
||||
|
||||
## License
|
||||
|
||||
This project is part of the infoscreen presentation system for educational/research purposes.
|
||||
- [../README.md](../README.md)
|
||||
- [DISPLAY_MANAGER.md](DISPLAY_MANAGER.md)
|
||||
- [IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md)
|
||||
- [../CLIENT_MONITORING_SETUP.md](../CLIENT_MONITORING_SETUP.md)
|
||||
- [../SCREENSHOT_MQTT_FIX.md](../SCREENSHOT_MQTT_FIX.md)
|
||||
|
||||
@@ -79,6 +79,9 @@ CEC_DEVICE = os.getenv("CEC_DEVICE", "TV") # Target device name (TV, 0, etc.)
|
||||
CEC_TURN_OFF_DELAY = int(os.getenv("CEC_TURN_OFF_DELAY", "30")) # seconds after last event ends
|
||||
CEC_POWER_ON_WAIT = int(os.getenv("CEC_POWER_ON_WAIT", "3")) # seconds to wait after turning TV on
|
||||
CEC_POWER_OFF_WAIT = int(os.getenv("CEC_POWER_OFF_WAIT", "2")) # seconds to wait after turning TV off
|
||||
POWER_CONTROL_MODE = os.getenv("POWER_CONTROL_MODE", "local").strip().lower()
|
||||
POWER_INTENT_STATE_FILE = os.path.join(os.path.dirname(__file__), "power_intent_state.json")
|
||||
POWER_STATE_FILE = os.path.join(os.path.dirname(__file__), "power_state.json")
|
||||
|
||||
# Setup logging
|
||||
LOG_PATH = os.path.join(os.path.dirname(__file__), "..", "logs", "display_manager.log")
|
||||
@@ -131,6 +134,11 @@ class ProcessHealthState:
|
||||
self.restart_count = 0
|
||||
self.max_restarts = 3
|
||||
self.last_update = datetime.now(timezone.utc).isoformat()
|
||||
self.power_control_mode = None
|
||||
self.power_source = None
|
||||
self.last_intent_id = None
|
||||
self.last_power_action = None
|
||||
self.last_power_at = None
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
@@ -140,7 +148,14 @@ class ProcessHealthState:
|
||||
"process_pid": self.process_pid,
|
||||
"process_status": self.status,
|
||||
"restart_count": self.restart_count,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"power_control": {
|
||||
"mode": self.power_control_mode,
|
||||
"source": self.power_source,
|
||||
"last_intent_id": self.last_intent_id,
|
||||
"last_action": self.last_power_action,
|
||||
"last_power_at": self.last_power_at,
|
||||
},
|
||||
}
|
||||
|
||||
def save(self):
|
||||
@@ -188,6 +203,14 @@ class ProcessHealthState:
|
||||
self.save()
|
||||
monitoring_logger.info("Process stopped (event ended or no active event)")
|
||||
|
||||
def update_power_action(self, action: str, source: str, intent_id: Optional[str] = None):
|
||||
"""Record the last power action for dashboard observability."""
|
||||
self.last_power_action = action
|
||||
self.power_source = source
|
||||
self.last_intent_id = intent_id
|
||||
self.last_power_at = datetime.now(timezone.utc).isoformat()
|
||||
self.save()
|
||||
|
||||
|
||||
class HDMICECController:
|
||||
"""Controls HDMI-CEC to turn TV on/off automatically
|
||||
@@ -213,6 +236,7 @@ class HDMICECController:
|
||||
self.power_off_wait = power_off_wait
|
||||
self.tv_state = None # None = unknown, True = on, False = off
|
||||
self.turn_off_timer = None
|
||||
self.turn_off_guard = None
|
||||
|
||||
if not self.enabled:
|
||||
logging.info("HDMI-CEC control disabled")
|
||||
@@ -391,6 +415,16 @@ class HDMICECController:
|
||||
|
||||
def _turn_off_now(self) -> bool:
|
||||
"""Internal method to turn TV off immediately"""
|
||||
self.turn_off_timer = None
|
||||
|
||||
if callable(self.turn_off_guard):
|
||||
try:
|
||||
if not self.turn_off_guard():
|
||||
logging.info("Skipping TV OFF due to runtime guard condition")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.warning(f"Turn-off guard failed, continuing with OFF command: {e}")
|
||||
|
||||
# Skip if TV is already off
|
||||
if self.tv_state is False:
|
||||
logging.debug("TV already off, skipping CEC command")
|
||||
@@ -421,6 +455,10 @@ class HDMICECController:
|
||||
self.turn_off_timer = None
|
||||
logging.debug("Cancelled TV turn-off timer")
|
||||
|
||||
def set_turn_off_guard(self, guard_fn):
|
||||
"""Set callback that must return True before delayed turn-off executes."""
|
||||
self.turn_off_guard = guard_fn
|
||||
|
||||
|
||||
class DisplayProcess:
|
||||
"""Manages a running display application process"""
|
||||
@@ -598,9 +636,15 @@ class DisplayManager:
|
||||
self.client_settings_mtime: Optional[float] = None
|
||||
self.client_volume_multiplier = 1.0
|
||||
self._video_duration_cache: Dict[str, float] = {}
|
||||
self.power_control_mode = POWER_CONTROL_MODE if POWER_CONTROL_MODE in ("local", "hybrid", "mqtt") else "local"
|
||||
self.last_applied_intent_id: Optional[str] = None
|
||||
self.last_seen_intent_id: Optional[str] = None
|
||||
self.latest_valid_intent: Optional[Dict] = None
|
||||
self.mqtt_mode_safe_off_armed = False
|
||||
|
||||
# Initialize health state tracking for process monitoring
|
||||
self.health = ProcessHealthState()
|
||||
self.health.power_control_mode = self.power_control_mode
|
||||
|
||||
# Initialize HDMI-CEC controller
|
||||
self.cec = HDMICECController(
|
||||
@@ -610,6 +654,8 @@ class DisplayManager:
|
||||
power_on_wait=CEC_POWER_ON_WAIT,
|
||||
power_off_wait=CEC_POWER_OFF_WAIT
|
||||
)
|
||||
self.cec.set_turn_off_guard(self._allow_turn_off_now)
|
||||
logging.info(f"Power control mode: {self.power_control_mode}")
|
||||
|
||||
# Setup signal handlers for graceful shutdown
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
@@ -810,6 +856,152 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading event file: {e}")
|
||||
return None
|
||||
|
||||
def _parse_utc_iso(self, value: str) -> datetime:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("timestamp must be non-empty string")
|
||||
normalized = value.strip()
|
||||
if normalized.endswith('Z'):
|
||||
normalized = normalized[:-1] + '+00:00'
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
def _get_power_intent_state(self) -> Optional[Dict]:
|
||||
"""Read latest validated intent written by simclient."""
|
||||
try:
|
||||
if not os.path.exists(POWER_INTENT_STATE_FILE):
|
||||
self.latest_valid_intent = None
|
||||
return None
|
||||
with open(POWER_INTENT_STATE_FILE, 'r', encoding='utf-8') as f:
|
||||
state = json.load(f)
|
||||
if not isinstance(state, dict):
|
||||
self.latest_valid_intent = None
|
||||
return None
|
||||
if not state.get('valid'):
|
||||
self.latest_valid_intent = None
|
||||
return None
|
||||
payload = state.get('payload')
|
||||
if not isinstance(payload, dict):
|
||||
self.latest_valid_intent = None
|
||||
return None
|
||||
|
||||
expires_at = self._parse_utc_iso(payload.get('expires_at'))
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if now_utc > expires_at:
|
||||
logging.warning(
|
||||
"Ignoring stale power intent id=%s expires_at=%s",
|
||||
payload.get('intent_id'),
|
||||
payload.get('expires_at')
|
||||
)
|
||||
self.latest_valid_intent = None
|
||||
return None
|
||||
|
||||
self.latest_valid_intent = payload
|
||||
return payload
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not read power intent state: {e}")
|
||||
self.latest_valid_intent = None
|
||||
return None
|
||||
|
||||
def _write_power_state(self, applied_state: str, source: str, result: str, detail: str = "", intent_id: Optional[str] = None):
|
||||
"""Write last power control action for simclient telemetry publishing."""
|
||||
try:
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"reported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"power": {
|
||||
"applied_state": applied_state,
|
||||
"source": source,
|
||||
"result": result,
|
||||
"detail": detail,
|
||||
}
|
||||
}
|
||||
if intent_id:
|
||||
payload["intent_id"] = intent_id
|
||||
|
||||
tmp_path = POWER_STATE_FILE + ".tmp"
|
||||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp_path, POWER_STATE_FILE)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not write power state telemetry: {e}")
|
||||
|
||||
def _has_any_active_event_now(self) -> bool:
|
||||
"""Evaluate active event state directly from current_event.json."""
|
||||
try:
|
||||
if not os.path.exists(EVENT_FILE):
|
||||
return False
|
||||
with open(EVENT_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
events = data if isinstance(data, list) else [data]
|
||||
for item in events:
|
||||
if isinstance(item, dict) and self.is_event_active(item):
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _allow_turn_off_now(self) -> bool:
|
||||
"""Prevent delayed OFF while an active event or fresh ON intent is present."""
|
||||
if self._has_any_active_event_now():
|
||||
return False
|
||||
intent = self._get_power_intent_state()
|
||||
if intent and intent.get('desired_state') == 'on':
|
||||
return False
|
||||
return True
|
||||
|
||||
def _should_use_local_power_control(self, intent: Optional[Dict]) -> bool:
|
||||
if self.power_control_mode == 'local':
|
||||
return True
|
||||
if self.power_control_mode == 'hybrid':
|
||||
return intent is None
|
||||
# mqtt mode
|
||||
return False
|
||||
|
||||
def _apply_mqtt_power_intent(self, intent: Optional[Dict]):
|
||||
if self.power_control_mode not in ('hybrid', 'mqtt'):
|
||||
return
|
||||
|
||||
if intent is None:
|
||||
if self.power_control_mode == 'mqtt' and not self.mqtt_mode_safe_off_armed:
|
||||
logging.warning("No valid MQTT power intent in mqtt mode - scheduling safe delayed OFF")
|
||||
self.cec.turn_off(delayed=True)
|
||||
self._write_power_state("off", "mqtt_intent", "ok", "mqtt_mode_no_valid_intent_safe_off")
|
||||
self.health.update_power_action("off", "mqtt_intent")
|
||||
self.mqtt_mode_safe_off_armed = True
|
||||
return
|
||||
|
||||
intent_id = str(intent.get('intent_id', ''))
|
||||
desired_state = intent.get('desired_state')
|
||||
reason = intent.get('reason')
|
||||
duplicate = intent_id and intent_id == self.last_applied_intent_id
|
||||
|
||||
self.last_seen_intent_id = intent_id or self.last_seen_intent_id
|
||||
|
||||
if duplicate:
|
||||
self._write_power_state(desired_state or "unknown", "mqtt_intent", "skipped", "duplicate_intent_id", intent_id=intent_id)
|
||||
return
|
||||
|
||||
if desired_state == 'on':
|
||||
logging.info("Applying MQTT power intent ON id=%s reason=%s", intent_id, reason)
|
||||
self.cec.cancel_turn_off()
|
||||
success = self.cec.turn_on()
|
||||
self._write_power_state("on", "mqtt_intent", "ok" if success else "error", reason or "", intent_id=intent_id)
|
||||
self.health.update_power_action("on", "mqtt_intent", intent_id)
|
||||
self.last_applied_intent_id = intent_id
|
||||
self.mqtt_mode_safe_off_armed = False
|
||||
return
|
||||
|
||||
if desired_state == 'off':
|
||||
logging.info("Applying MQTT power intent OFF id=%s reason=%s", intent_id, reason)
|
||||
self.cec.turn_off(delayed=True)
|
||||
self._write_power_state("off", "mqtt_intent", "ok", reason or "", intent_id=intent_id)
|
||||
self.health.update_power_action("off", "mqtt_intent", intent_id)
|
||||
self.last_applied_intent_id = intent_id
|
||||
self.mqtt_mode_safe_off_armed = True
|
||||
return
|
||||
|
||||
def is_event_active(self, event: Dict) -> bool:
|
||||
"""Check if event should be displayed based on start/end times
|
||||
@@ -922,6 +1114,7 @@ class DisplayManager:
|
||||
# Turn off TV when display stops (with configurable delay)
|
||||
if turn_off_tv:
|
||||
self.cec.turn_off(delayed=True)
|
||||
self.health.update_power_action("off", "local_fallback")
|
||||
|
||||
def start_presentation(self, event: Dict) -> Optional[DisplayProcess]:
|
||||
"""Start presentation display (PDF/PowerPoint/LibreOffice) using Impressive
|
||||
@@ -1658,6 +1851,9 @@ class DisplayManager:
|
||||
|
||||
def process_events(self):
|
||||
"""Main processing loop - check for event changes and manage display"""
|
||||
power_intent = self._get_power_intent_state()
|
||||
self._apply_mqtt_power_intent(power_intent)
|
||||
local_power_control = self._should_use_local_power_control(power_intent)
|
||||
|
||||
event_data = self.read_event_file()
|
||||
|
||||
@@ -1665,7 +1861,7 @@ class DisplayManager:
|
||||
if not event_data:
|
||||
if self.current_process:
|
||||
logging.info("No active event - stopping current display")
|
||||
self.stop_current_display()
|
||||
self.stop_current_display(turn_off_tv=local_power_control)
|
||||
return
|
||||
|
||||
# Handle event arrays (take first event)
|
||||
@@ -1674,7 +1870,7 @@ class DisplayManager:
|
||||
if not events_to_process:
|
||||
if self.current_process:
|
||||
logging.info("Empty event list - stopping current display")
|
||||
self.stop_current_display()
|
||||
self.stop_current_display(turn_off_tv=local_power_control)
|
||||
return
|
||||
|
||||
# Process first active event
|
||||
@@ -1687,7 +1883,7 @@ class DisplayManager:
|
||||
if not active_event:
|
||||
if self.current_process:
|
||||
logging.info("No active events in time window - stopping current display")
|
||||
self.stop_current_display()
|
||||
self.stop_current_display(turn_off_tv=local_power_control)
|
||||
return
|
||||
|
||||
# Get event identifier
|
||||
@@ -1755,7 +1951,8 @@ class DisplayManager:
|
||||
else:
|
||||
# Everything is fine, continue
|
||||
# Cancel any pending TV turn-off since event is still active
|
||||
self.cec.cancel_turn_off()
|
||||
if local_power_control:
|
||||
self.cec.cancel_turn_off()
|
||||
self._apply_runtime_video_settings(active_event)
|
||||
return
|
||||
else:
|
||||
@@ -1773,7 +1970,9 @@ class DisplayManager:
|
||||
logging.info(f" Event end time (UTC): {active_event['end']}")
|
||||
|
||||
# Turn on TV before starting display
|
||||
self.cec.turn_on()
|
||||
if local_power_control:
|
||||
self.cec.turn_on()
|
||||
self.health.update_power_action("on", "local_fallback")
|
||||
|
||||
new_process = self.start_display_for_event(active_event)
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ log_warn() { echo -e "${YELLOW}⚠️ $1${NC}"; }
|
||||
log_err() { echo -e "${RED}❌ $1${NC}"; }
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="$HOME/infoscreen-dev"
|
||||
# Resolve the actual unprivileged user even when the script is invoked via sudo.
|
||||
ACTUAL_USER="${SUDO_USER:-$USER}"
|
||||
ACTUAL_HOME="$(eval echo "~$ACTUAL_USER")"
|
||||
|
||||
PROJECT_DIR="$ACTUAL_HOME/infoscreen-dev"
|
||||
REPO_URL="https://github.com/RobbStarkAustria/infoscreen-client-dev-2025.git" # Public HTTPS clone
|
||||
VENV_DIR="$PROJECT_DIR/venv"
|
||||
REQ_FILE="$PROJECT_DIR/src/requirements.txt"
|
||||
@@ -125,13 +129,57 @@ EOF
|
||||
chmod +x "$PROJECT_DIR/scripts/start-dev.sh"
|
||||
log_ok "start-dev.sh created"
|
||||
|
||||
# 13. SSH enable (for remote dev)
|
||||
# 13. Install command helper + sudoers rule for reboot/shutdown command execution
|
||||
log_step "Installing command helper and sudoers policy..."
|
||||
HELPER_SRC="$PROJECT_DIR/scripts/infoscreen-cmd-helper.sh"
|
||||
HELPER_DST="/usr/local/bin/infoscreen-cmd-helper.sh"
|
||||
SUDOERS_FILE="/etc/sudoers.d/infoscreen-command-helper"
|
||||
|
||||
if [ -f "$HELPER_SRC" ]; then
|
||||
sudo install -m 0755 "$HELPER_SRC" "$HELPER_DST"
|
||||
echo "$USER ALL=(ALL) NOPASSWD: $HELPER_DST" | sudo tee "$SUDOERS_FILE" >/dev/null
|
||||
sudo chmod 0440 "$SUDOERS_FILE"
|
||||
sudo visudo -cf "$SUDOERS_FILE" >/dev/null
|
||||
log_ok "Command helper installed at $HELPER_DST and sudoers rule validated"
|
||||
else
|
||||
log_warn "Command helper source not found: $HELPER_SRC"
|
||||
fi
|
||||
|
||||
# 14. Systemd service units (simclient + display manager)
|
||||
log_step "Installing systemd service units..."
|
||||
SERVICES_SRC="$PROJECT_DIR/scripts"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
|
||||
for unit in infoscreen-simclient.service infoscreen-display.service "infoscreen-notify-failure@.service"; do
|
||||
if [ -f "$SERVICES_SRC/$unit" ]; then
|
||||
# Substitute the dev-machine username/home with the actual target user.
|
||||
sed -e "s|olafn|$ACTUAL_USER|g" \
|
||||
-e "s|/home/$ACTUAL_USER|$ACTUAL_HOME|g" \
|
||||
"$SERVICES_SRC/$unit" | sudo tee "$SYSTEMD_DIR/$unit" > /dev/null
|
||||
log_ok "Installed $SYSTEMD_DIR/$unit (user=$ACTUAL_USER)"
|
||||
else
|
||||
log_warn "Service unit not found: $SERVICES_SRC/$unit"
|
||||
fi
|
||||
done
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
for unit in infoscreen-simclient.service infoscreen-display.service; do
|
||||
if [ -f "$SYSTEMD_DIR/$unit" ]; then
|
||||
sudo systemctl enable "$unit" >/dev/null 2>&1 || true
|
||||
log_ok "Enabled: $unit"
|
||||
fi
|
||||
done
|
||||
|
||||
log_ok "Systemd units installed. Start with: sudo systemctl start infoscreen-simclient infoscreen-display"
|
||||
|
||||
# 15. SSH enable (for remote dev)
|
||||
log_step "Ensuring SSH service enabled..."
|
||||
sudo systemctl enable ssh >/dev/null 2>&1 || true
|
||||
sudo systemctl start ssh >/dev/null 2>&1 || true
|
||||
log_ok "SSH service active"
|
||||
|
||||
# 14. Summary
|
||||
# 16. Summary
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 Setup complete!${NC}"
|
||||
echo "Project: $PROJECT_DIR"
|
||||
|
||||
1071
src/simclient.py
1071
src/simclient.py
File diff suppressed because it is too large
Load Diff
287
tests/test_command_intake.py
Normal file
287
tests/test_command_intake.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Unit tests for reboot/shutdown command intake primitives.
|
||||
|
||||
Run from project root (venv activated):
|
||||
python -m pytest tests/test_command_intake.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from simclient import ( # noqa: E402
|
||||
NIL_COMMAND_ID,
|
||||
command_requires_recovery_completion,
|
||||
command_mock_reboot_immediate_complete_enabled,
|
||||
configure_mqtt_security,
|
||||
mqtt,
|
||||
validate_command_payload,
|
||||
publish_command_ack,
|
||||
_prune_processed_commands,
|
||||
load_processed_commands,
|
||||
persist_processed_commands,
|
||||
)
|
||||
|
||||
|
||||
class FakePublishResult:
|
||||
def __init__(self, rc):
|
||||
self.rc = rc
|
||||
|
||||
|
||||
class FakeMqttClient:
|
||||
def __init__(self, rc=0):
|
||||
self.rc = rc
|
||||
self.calls = []
|
||||
|
||||
def publish(self, topic, payload, qos=0, retain=False):
|
||||
self.calls.append({
|
||||
"topic": topic,
|
||||
"payload": payload,
|
||||
"qos": qos,
|
||||
"retain": retain,
|
||||
})
|
||||
return FakePublishResult(self.rc)
|
||||
|
||||
|
||||
class SequencedMqttClient:
|
||||
def __init__(self, rc_sequence):
|
||||
self._rc_sequence = list(rc_sequence)
|
||||
self.calls = []
|
||||
|
||||
def publish(self, topic, payload, qos=0, retain=False):
|
||||
rc = self._rc_sequence.pop(0) if self._rc_sequence else 0
|
||||
self.calls.append({
|
||||
"topic": topic,
|
||||
"payload": payload,
|
||||
"qos": qos,
|
||||
"retain": retain,
|
||||
"rc": rc,
|
||||
})
|
||||
return FakePublishResult(rc)
|
||||
|
||||
|
||||
class FakeSecurityClient:
|
||||
def __init__(self):
|
||||
self.username = None
|
||||
self.password = None
|
||||
self.tls_kwargs = None
|
||||
self.tls_insecure = None
|
||||
|
||||
def username_pw_set(self, username, password=None):
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
def tls_set(self, **kwargs):
|
||||
self.tls_kwargs = kwargs
|
||||
|
||||
def tls_insecure_set(self, enabled):
|
||||
self.tls_insecure = enabled
|
||||
|
||||
|
||||
def _valid_payload(seconds_valid=240):
|
||||
now = datetime.now(timezone.utc)
|
||||
exp = now + timedelta(seconds=seconds_valid)
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"command_id": "5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||
"client_uuid": "9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||
"action": "reboot_host",
|
||||
"issued_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"expires_at": exp.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"requested_by": 1,
|
||||
"reason": "operator_request",
|
||||
}
|
||||
|
||||
|
||||
class TestValidateCommandPayload(unittest.TestCase):
|
||||
def test_accepts_valid_payload(self):
|
||||
payload = _valid_payload()
|
||||
ok, normalized, code, msg = validate_command_payload(payload, payload["client_uuid"])
|
||||
self.assertTrue(ok)
|
||||
self.assertIsNone(code)
|
||||
self.assertIsNone(msg)
|
||||
self.assertEqual(normalized["action"], "reboot_host")
|
||||
|
||||
def test_rejects_extra_fields(self):
|
||||
payload = _valid_payload()
|
||||
payload["extra"] = "x"
|
||||
ok, _, code, msg = validate_command_payload(payload, payload["client_uuid"])
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(code, "invalid_schema")
|
||||
self.assertIn("unexpected fields", msg)
|
||||
|
||||
def test_rejects_stale_command(self):
|
||||
payload = _valid_payload()
|
||||
old_issued = datetime.now(timezone.utc) - timedelta(hours=3)
|
||||
old_expires = datetime.now(timezone.utc) - timedelta(hours=2)
|
||||
payload["issued_at"] = old_issued.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
payload["expires_at"] = old_expires.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
ok, _, code, _ = validate_command_payload(payload, payload["client_uuid"])
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(code, "stale_command")
|
||||
|
||||
def test_rejects_action_outside_enum(self):
|
||||
payload = _valid_payload()
|
||||
payload["action"] = "restart_service"
|
||||
ok, _, code, msg = validate_command_payload(payload, payload["client_uuid"])
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(code, "invalid_schema")
|
||||
self.assertIn("action must be one of", msg)
|
||||
|
||||
def test_rejects_client_uuid_mismatch(self):
|
||||
payload = _valid_payload()
|
||||
ok, _, code, msg = validate_command_payload(
|
||||
payload,
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(code, "invalid_schema")
|
||||
self.assertIn("client_uuid", msg)
|
||||
|
||||
|
||||
class TestCommandLifecyclePolicy(unittest.TestCase):
|
||||
def test_reboot_requires_recovery_completion(self):
|
||||
self.assertTrue(command_requires_recovery_completion("reboot_host"))
|
||||
self.assertFalse(command_requires_recovery_completion("shutdown_host"))
|
||||
|
||||
def test_mock_reboot_immediate_completion_enabled_for_mock_helper(self):
|
||||
with patch("simclient.COMMAND_MOCK_REBOOT_IMMEDIATE_COMPLETE", True), \
|
||||
patch("simclient.COMMAND_HELPER_PATH", "/home/pi/scripts/mock-command-helper.sh"):
|
||||
self.assertTrue(command_mock_reboot_immediate_complete_enabled("reboot_host"))
|
||||
|
||||
def test_mock_reboot_immediate_completion_disabled_for_live_helper(self):
|
||||
with patch("simclient.COMMAND_MOCK_REBOOT_IMMEDIATE_COMPLETE", True), \
|
||||
patch("simclient.COMMAND_HELPER_PATH", "/usr/local/bin/infoscreen-cmd-helper.sh"):
|
||||
self.assertFalse(command_mock_reboot_immediate_complete_enabled("reboot_host"))
|
||||
|
||||
|
||||
class TestMqttSecurityConfiguration(unittest.TestCase):
|
||||
def test_configure_username_password(self):
|
||||
fake_client = FakeSecurityClient()
|
||||
with patch("simclient.MQTT_USER", ""), \
|
||||
patch("simclient.MQTT_PASSWORD_BROKER", ""), \
|
||||
patch("simclient.MQTT_USERNAME", "client-user"), \
|
||||
patch("simclient.MQTT_PASSWORD", "client-pass"), \
|
||||
patch("simclient.MQTT_TLS_ENABLED", False):
|
||||
configured = configure_mqtt_security(fake_client)
|
||||
|
||||
self.assertEqual(fake_client.username, "client-user")
|
||||
self.assertEqual(fake_client.password, "client-pass")
|
||||
self.assertFalse(configured["tls"])
|
||||
|
||||
def test_configure_tls(self):
|
||||
fake_client = FakeSecurityClient()
|
||||
with patch("simclient.MQTT_USER", ""), \
|
||||
patch("simclient.MQTT_PASSWORD_BROKER", ""), \
|
||||
patch("simclient.MQTT_USERNAME", ""), \
|
||||
patch("simclient.MQTT_PASSWORD", ""), \
|
||||
patch("simclient.MQTT_TLS_ENABLED", True), \
|
||||
patch("simclient.MQTT_TLS_CA_CERT", "/tmp/ca.pem"), \
|
||||
patch("simclient.MQTT_TLS_CERT", "/tmp/client.pem"), \
|
||||
patch("simclient.MQTT_TLS_KEY", "/tmp/client.key"), \
|
||||
patch("simclient.MQTT_TLS_INSECURE", True):
|
||||
configured = configure_mqtt_security(fake_client)
|
||||
|
||||
self.assertTrue(configured["tls"])
|
||||
self.assertEqual(fake_client.tls_kwargs["ca_certs"], "/tmp/ca.pem")
|
||||
self.assertEqual(fake_client.tls_kwargs["certfile"], "/tmp/client.pem")
|
||||
self.assertEqual(fake_client.tls_kwargs["keyfile"], "/tmp/client.key")
|
||||
self.assertTrue(fake_client.tls_insecure)
|
||||
|
||||
|
||||
class TestAckPublish(unittest.TestCase):
|
||||
def test_failed_ack_forces_non_null_error_fields(self):
|
||||
fake_client = FakeMqttClient(rc=0)
|
||||
ok = publish_command_ack(
|
||||
fake_client,
|
||||
"9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||
NIL_COMMAND_ID,
|
||||
"failed",
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(len(fake_client.calls), 2)
|
||||
payload = json.loads(fake_client.calls[0]["payload"])
|
||||
self.assertEqual(payload["status"], "failed")
|
||||
self.assertTrue(isinstance(payload["error_code"], str) and payload["error_code"])
|
||||
self.assertTrue(isinstance(payload["error_message"], str) and payload["error_message"])
|
||||
|
||||
def test_retry_on_broker_disconnect_then_success(self):
|
||||
# First loop (2 topics): NO_CONN, NO_CONN. Second loop: success, success.
|
||||
fake_client = SequencedMqttClient([
|
||||
mqtt.MQTT_ERR_NO_CONN,
|
||||
mqtt.MQTT_ERR_NO_CONN,
|
||||
mqtt.MQTT_ERR_SUCCESS,
|
||||
mqtt.MQTT_ERR_SUCCESS,
|
||||
])
|
||||
future_expiry = (datetime.now(timezone.utc) + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
with patch("simclient.time.sleep", return_value=None) as sleep_mock:
|
||||
ok = publish_command_ack(
|
||||
fake_client,
|
||||
"9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||
"5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||
"accepted",
|
||||
expires_at=future_expiry,
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(len(fake_client.calls), 4)
|
||||
sleep_mock.assert_called_once()
|
||||
|
||||
def test_stop_retry_when_expired(self):
|
||||
fake_client = SequencedMqttClient([
|
||||
mqtt.MQTT_ERR_NO_CONN,
|
||||
mqtt.MQTT_ERR_NO_CONN,
|
||||
])
|
||||
past_expiry = (datetime.now(timezone.utc) - timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
with patch("simclient.time.sleep", return_value=None) as sleep_mock:
|
||||
ok = publish_command_ack(
|
||||
fake_client,
|
||||
"9b8d1856-ff34-4864-a726-12de072d0f77",
|
||||
"5d1f8b4b-7e85-44fb-8f38-3f5d5da5e2e4",
|
||||
"accepted",
|
||||
expires_at=past_expiry,
|
||||
)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(len(fake_client.calls), 2)
|
||||
sleep_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestProcessedCommandsState(unittest.TestCase):
|
||||
def test_prune_keeps_recent_only(self):
|
||||
recent = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
old = (datetime.now(timezone.utc) - timedelta(hours=30)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
commands = {
|
||||
"a": {"processed_at": recent, "status": "completed"},
|
||||
"b": {"processed_at": old, "status": "completed"},
|
||||
}
|
||||
pruned = _prune_processed_commands(commands)
|
||||
self.assertIn("a", pruned)
|
||||
self.assertNotIn("b", pruned)
|
||||
|
||||
def test_load_and_persist_round_trip(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
state_file = os.path.join(tmpdir, "processed_commands.json")
|
||||
with patch("simclient.PROCESSED_COMMANDS_FILE", state_file):
|
||||
persist_processed_commands({
|
||||
"x": {
|
||||
"status": "completed",
|
||||
"processed_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
})
|
||||
loaded = load_processed_commands()
|
||||
self.assertIn("x", loaded)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
313
tests/test_power_intent.py
Normal file
313
tests/test_power_intent.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Unit tests for the TV power intent validation and state management.
|
||||
|
||||
Run from project root (venv activated):
|
||||
python -m pytest tests/test_power_intent.py -v
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Ensure src/ is importable without running MQTT code
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from simclient import (
|
||||
validate_power_intent_payload,
|
||||
write_power_intent_state,
|
||||
_parse_utc_iso,
|
||||
POWER_INTENT_STATE_FILE,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_intent(
|
||||
desired: str = "on",
|
||||
seconds_valid: int = 90,
|
||||
group_id: int = 2,
|
||||
intent_id: str = "test-intent-001",
|
||||
poll_interval: int = 15,
|
||||
offset_issued: timedelta = timedelta(0),
|
||||
) -> dict:
|
||||
"""Build a valid v1 power-intent payload."""
|
||||
now = datetime.now(timezone.utc) + offset_issued
|
||||
exp = now + timedelta(seconds=seconds_valid)
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"intent_id": intent_id,
|
||||
"group_id": group_id,
|
||||
"desired_state": desired,
|
||||
"reason": "active_event" if desired == "on" else "no_active_event",
|
||||
"issued_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"expires_at": exp.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"poll_interval_sec": poll_interval,
|
||||
"active_event_ids": [1] if desired == "on" else [],
|
||||
"event_window_start": now.strftime("%Y-%m-%dT%H:%M:%SZ") if desired == "on" else None,
|
||||
"event_window_end": exp.strftime("%Y-%m-%dT%H:%M:%SZ") if desired == "on" else None,
|
||||
}
|
||||
|
||||
|
||||
def _make_stale_intent(desired: str = "on") -> dict:
|
||||
"""Build an expired v1 payload (issued_at and expires_at both in the past)."""
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "stale-001",
|
||||
"group_id": 2,
|
||||
"desired_state": desired,
|
||||
"reason": "no_active_event",
|
||||
"issued_at": "2026-01-01T00:00:00Z",
|
||||
"expires_at": "2026-01-01T01:30:00Z",
|
||||
"poll_interval_sec": 15,
|
||||
"active_event_ids": [],
|
||||
"event_window_start": None,
|
||||
"event_window_end": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _parse_utc_iso
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseUtcIso(unittest.TestCase):
|
||||
def test_z_suffix(self):
|
||||
dt = _parse_utc_iso("2026-01-15T10:30:00Z")
|
||||
self.assertEqual(dt.tzinfo, timezone.utc)
|
||||
self.assertEqual(dt.year, 2026)
|
||||
self.assertEqual(dt.second, 0)
|
||||
|
||||
def test_plus00_suffix(self):
|
||||
dt = _parse_utc_iso("2026-01-15T10:30:00+00:00")
|
||||
self.assertEqual(dt.tzinfo, timezone.utc)
|
||||
|
||||
def test_none_raises(self):
|
||||
with self.assertRaises(Exception):
|
||||
_parse_utc_iso(None)
|
||||
|
||||
def test_garbage_raises(self):
|
||||
with self.assertRaises(Exception):
|
||||
_parse_utc_iso("not-a-date")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: validate_power_intent_payload — accepted paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidateAccepted(unittest.TestCase):
|
||||
def test_valid_on_no_group_check(self):
|
||||
intent = _make_intent("on")
|
||||
ok, norm, err = validate_power_intent_payload(intent)
|
||||
self.assertTrue(ok, err)
|
||||
self.assertIsNotNone(norm)
|
||||
self.assertEqual(norm["desired_state"], "on")
|
||||
self.assertEqual(norm["group_id"], 2)
|
||||
|
||||
def test_valid_off_no_group_check(self):
|
||||
intent = _make_intent("off")
|
||||
ok, norm, err = validate_power_intent_payload(intent)
|
||||
self.assertTrue(ok, err)
|
||||
self.assertEqual(norm["desired_state"], "off")
|
||||
|
||||
def test_valid_on_with_matching_group_id_str(self):
|
||||
intent = _make_intent("on", group_id=5)
|
||||
ok, norm, err = validate_power_intent_payload(intent, expected_group_id="5")
|
||||
self.assertTrue(ok, err)
|
||||
|
||||
def test_valid_on_with_matching_group_id_int(self):
|
||||
intent = _make_intent("on", group_id=7)
|
||||
ok, norm, err = validate_power_intent_payload(intent, expected_group_id=7)
|
||||
self.assertTrue(ok, err)
|
||||
|
||||
def test_normalized_output_contains_required_keys(self):
|
||||
intent = _make_intent("on")
|
||||
ok, norm, _ = validate_power_intent_payload(intent)
|
||||
self.assertTrue(ok)
|
||||
for key in ("intent_id", "desired_state", "issued_at", "expires_at",
|
||||
"group_id", "poll_interval_sec", "active_event_ids",
|
||||
"event_window_start", "event_window_end"):
|
||||
self.assertIn(key, norm, f"missing key: {key}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: validate_power_intent_payload — rejected paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidateRejected(unittest.TestCase):
|
||||
def test_missing_required_fields(self):
|
||||
ok, _, err = validate_power_intent_payload({"schema_version": "1.0"})
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("missing required field", err)
|
||||
|
||||
def test_wrong_schema_version(self):
|
||||
intent = _make_intent("on")
|
||||
intent["schema_version"] = "2.0"
|
||||
ok, _, err = validate_power_intent_payload(intent)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("schema_version", err)
|
||||
|
||||
def test_invalid_desired_state(self):
|
||||
intent = _make_intent("on")
|
||||
intent["desired_state"] = "standby"
|
||||
ok, _, err = validate_power_intent_payload(intent)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("desired_state", err)
|
||||
|
||||
def test_group_id_mismatch_string(self):
|
||||
intent = _make_intent("on", group_id=2)
|
||||
ok, _, err = validate_power_intent_payload(intent, expected_group_id="99")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("group_id mismatch", err)
|
||||
|
||||
def test_group_id_mismatch_int(self):
|
||||
intent = _make_intent("on", group_id=2)
|
||||
ok, _, err = validate_power_intent_payload(intent, expected_group_id=99)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("group_id mismatch", err)
|
||||
|
||||
def test_expired_intent(self):
|
||||
ok, _, err = validate_power_intent_payload(_make_stale_intent("on"))
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("expired", err)
|
||||
|
||||
def test_expires_before_issued(self):
|
||||
intent = _make_intent("on")
|
||||
# Swap the timestamps so expires < issued
|
||||
intent["expires_at"] = intent["issued_at"]
|
||||
ok, _, err = validate_power_intent_payload(intent)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_zero_poll_interval(self):
|
||||
intent = _make_intent("on", poll_interval=0)
|
||||
ok, _, err = validate_power_intent_payload(intent)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("poll_interval_sec", err)
|
||||
|
||||
def test_negative_poll_interval(self):
|
||||
intent = _make_intent("on", poll_interval=-5)
|
||||
ok, _, err = validate_power_intent_payload(intent)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_active_event_ids_not_list(self):
|
||||
intent = _make_intent("on")
|
||||
intent["active_event_ids"] = "not-a-list"
|
||||
ok, _, err = validate_power_intent_payload(intent)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("active_event_ids", err)
|
||||
|
||||
def test_missing_intent_id(self):
|
||||
intent = _make_intent("on")
|
||||
del intent["intent_id"]
|
||||
ok, _, err = validate_power_intent_payload(intent)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("missing required field", err)
|
||||
|
||||
def test_invalid_issued_at_format(self):
|
||||
intent = _make_intent("on")
|
||||
intent["issued_at"] = "not-a-timestamp"
|
||||
ok, _, err = validate_power_intent_payload(intent)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("timestamp", err)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: write_power_intent_state atomic write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWritePowerIntentState(unittest.TestCase):
|
||||
def test_writes_valid_json(self):
|
||||
data = {"intent_id": "abc", "desired_state": "on", "group_id": 2}
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||||
tmp_path = f.name
|
||||
try:
|
||||
with patch("simclient.POWER_INTENT_STATE_FILE", tmp_path):
|
||||
write_power_intent_state(data)
|
||||
with open(tmp_path) as f:
|
||||
loaded = json.load(f)
|
||||
self.assertEqual(loaded["intent_id"], "abc")
|
||||
self.assertEqual(loaded["desired_state"], "on")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_atomic_write_replaces_existing(self):
|
||||
existing = {"intent_id": "old"}
|
||||
new_data = {"intent_id": "new", "desired_state": "off"}
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".json", delete=False, mode="w"
|
||||
) as f:
|
||||
json.dump(existing, f)
|
||||
tmp_path = f.name
|
||||
try:
|
||||
with patch("simclient.POWER_INTENT_STATE_FILE", tmp_path):
|
||||
write_power_intent_state(new_data)
|
||||
with open(tmp_path) as f:
|
||||
loaded = json.load(f)
|
||||
self.assertEqual(loaded["intent_id"], "new")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: display_manager.ProcessHealthState power fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProcessHealthStatePowerFields(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Import here to avoid triggering display_manager side effects at module level
|
||||
from display_manager import ProcessHealthState
|
||||
self.ProcessHealthState = ProcessHealthState
|
||||
|
||||
def test_initial_power_fields_are_none(self):
|
||||
h = self.ProcessHealthState()
|
||||
self.assertIsNone(h.power_control_mode)
|
||||
self.assertIsNone(h.power_source)
|
||||
self.assertIsNone(h.last_intent_id)
|
||||
self.assertIsNone(h.last_power_action)
|
||||
self.assertIsNone(h.last_power_at)
|
||||
|
||||
def test_to_dict_contains_power_control(self):
|
||||
h = self.ProcessHealthState()
|
||||
d = h.to_dict()
|
||||
self.assertIn("power_control", d)
|
||||
pc = d["power_control"]
|
||||
self.assertIn("mode", pc)
|
||||
self.assertIn("source", pc)
|
||||
self.assertIn("last_intent_id", pc)
|
||||
self.assertIn("last_action", pc)
|
||||
self.assertIn("last_power_at", pc)
|
||||
|
||||
def test_update_power_action_sets_fields(self):
|
||||
h = self.ProcessHealthState()
|
||||
h.power_control_mode = "hybrid"
|
||||
h.update_power_action("on", "mqtt_intent", "intent-xyz")
|
||||
self.assertEqual(h.last_power_action, "on")
|
||||
self.assertEqual(h.power_source, "mqtt_intent")
|
||||
self.assertEqual(h.last_intent_id, "intent-xyz")
|
||||
self.assertIsNotNone(h.last_power_at)
|
||||
|
||||
def test_update_power_action_without_intent_id(self):
|
||||
h = self.ProcessHealthState()
|
||||
h.update_power_action("off", "local_fallback")
|
||||
self.assertEqual(h.last_power_action, "off")
|
||||
self.assertEqual(h.power_source, "local_fallback")
|
||||
self.assertIsNone(h.last_intent_id)
|
||||
|
||||
def test_to_dict_reflects_update(self):
|
||||
h = self.ProcessHealthState()
|
||||
h.power_control_mode = "mqtt"
|
||||
h.update_power_action("off", "mqtt_intent", "intent-abc")
|
||||
d = h.to_dict()
|
||||
pc = d["power_control"]
|
||||
self.assertEqual(pc["mode"], "mqtt")
|
||||
self.assertEqual(pc["source"], "mqtt_intent")
|
||||
self.assertEqual(pc["last_intent_id"], "intent-abc")
|
||||
self.assertEqual(pc["last_action"], "off")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user