docs: refactor docs structure and tighten assistant instruction policy

shrink root README into a landing page with a docs map and focused contributor guidance
add TV_POWER_RUNBOOK as the canonical TV power rollout and canary runbook
add CHANGELOG and move project history out of README-style docs
refactor src README into a developer-focused guide (architecture, runtime files, MQTT, debugging)
prune redundant older HDMI docs and keep a canonical HDMI_CEC_SETUP path
update copilot instructions to a high-signal policy format with strict anti-shadow-README design rules
align references across docs to current files, scripts, and TV power behavior
This commit is contained in:
RobbStarkAustria
2026-04-01 10:01:58 +02:00
parent fb0980aa88
commit 82f43f75ba
20 changed files with 2228 additions and 2267 deletions

View File

@@ -41,6 +41,14 @@ 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_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) CEC_POWER_OFF_WAIT=5 # Seconds to wait after power OFF command (increased for slower TVs)
# 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=local # local | hybrid | mqtt
# Optional: MQTT authentication (if your broker requires username/password) # Optional: MQTT authentication (if your broker requires username/password)
#MQTT_USERNAME= #MQTT_USERNAME=
#MQTT_PASSWORD= #MQTT_PASSWORD=

View File

@@ -1,596 +1,124 @@
# Copilot Instructions - Infoscreen Client # 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 ## Instruction File Design 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`)
-**Dashboard payload uses grouped v2 schema** (`message/content/runtime/metadata`, `schema_version="2.0"`)
-**Event-triggered screenshots**: `display_manager` arms a `threading.Timer` after start/stop, captures, writes `meta.json` with `send_immediately=true`; simclient fires within ≤1s
-**Payload assembly is centralized** in `_build_dashboard_payload()` — do not build dashboard JSON at call sites
### Key Files & Locations Treat this file as policy, not as project handbook.
- **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)
### Common Tasks Quick Reference - Scope rule: keep only durable constraints, architectural invariants, and high-value task pointers for assistants.
| Task | File | Key Method/Section | - 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.
| Add event type | `display_manager.py` | `start_display_for_event()` | - Single-source rule: each topic has one canonical document; this file should only reference it.
| Modify presentation | `display_manager.py` | `start_presentation()` | - No shadow-README rule: do not add long setup guides, full command catalogs, troubleshooting playbooks, or large directory trees.
| 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()` |
--- Allowed content:
## Project Overview - Critical do/don't rules.
**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. - Short architecture snapshot.
- Runtime coordination file map.
- Minimal task pointers to key methods.
- Documentation policy for where detailed content belongs.
**Architecture**: Two-process design Disallowed content:
- `simclient.py` - MQTT communication (container/native)
- `display_manager.py` - Display control (host OS with X11/Wayland access)
## 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 Update checklist for contributors:
- **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)
### System Components 1. Is the new text a durable assistant rule or invariant?
- **Main Client** (`simclient.py`) - Core MQTT client and event processor 2. If it is operational detail, did you place it in the specialist doc and only link it here?
- **Display Manager** (`display_manager.py`) - Controls display applications (presentations, videos, web) 3. Did you avoid duplicating existing docs?
- **Discovery System** - Automatic client registration with server 4. Does this file remain below the hard cap?
- **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
## Key Features & Functionality Use specialist docs for deep operational details:
### MQTT Communication Patterns - `README.md` (landing page + docs map)
- **Discovery**: `infoscreen/discovery``infoscreen/{client_id}/discovery_ack` - `TV_POWER_RUNBOOK.md` (TV power rollout and canary)
- **Heartbeat**: Regular `infoscreen/{client_id}/heartbeat` messages - `TV_POWER_INTENT_SERVER_CONTRACT_V1.md` (frozen contract)
- **Health**: `infoscreen/{client_id}/health` (event/process/pid/status) - `IMPRESSIVE_INTEGRATION.md` (presentation behavior)
- **Client logs**: `infoscreen/{client_id}/logs/error|warn` (selective forwarding) - `HDMI_CEC_SETUP.md` (CEC setup/troubleshooting)
### MQTT Reconnection & Heartbeat (Nov 2025) - `SCREENSHOT_MQTT_FIX.md` (screenshot race-condition fixes)
- The client uses Paho MQTT v2 callback API with `client.loop_start()` and `client.reconnect_delay_set()` to handle automatic reconnection. - `src/README.md` (developer-focused architecture/debugging)
- `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}`
### Event Types Supported ## Critical Rules
```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
}
}
```
### Presentation System (Impressive-Based) - ALWAYS use Impressive for PDF presentations.
- **Server-side conversion**: PPTX files are converted to PDF by the server using Gotenberg - NEVER suggest xdotool-based slideshow control.
- **Client receives PDFs**: All presentations arrive as pre-rendered PDF files - NEVER suggest converting presentations to video as a workaround.
- **Direct display**: PDF files are displayed natively with Impressive (no client-side conversion) - Virtual environment must include `pygame` and `pillow` for Impressive.
- **Auto-advance**: Native Impressive `--auto` parameter (no xdotool needed) - Keep screenshot consent notice in docs when describing dashboard screenshots.
- **Loop mode**: Impressive `--wrap` parameter for infinite looping - Keep screenshot updates consistent between `latest.jpg` and `meta.json`.
- **Auto-quit**: Impressive `--autoquit` parameter to exit after last slide - Event-trigger screenshots must preserve metadata and send quickly (`send_immediately=true`).
- **Virtual Environment**: Uses venv with pygame + pillow for reliable operation - Dashboard payload must stay grouped v2 (`message/content/runtime/metadata`, `schema_version="2.0"`).
- **Reliable**: Works consistently on Raspberry Pi without window focus issues - 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`.
### Client Identification ## Architecture Snapshot
- **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`
## Directory Structure Two-process design:
```
~/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
```
## Configuration & Environment Variables - `src/simclient.py`: MQTT communication, discovery, group assignment, event intake, heartbeat, dashboard publish, power intent ingestion.
- `src/display_manager.py`: content display lifecycle, HDMI-CEC, screenshot capture, runtime process health.
### Development vs Production Runtime coordination files:
- **Development**: `ENV=development`, verbose logging, frequent heartbeats
- **Production**: `ENV=production`, minimal logging, longer intervals
HDMI-CEC behavior: - `src/current_event.json` (active event)
- 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 (14) still work for direct `cec-client` checks. - `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 ## TV Power Coordination Rules
```bash
# Environment
ENV=development|production
DEBUG_MODE=1|0
LOG_LEVEL=DEBUG|INFO|WARNING|ERROR
# MQTT Configuration - `POWER_CONTROL_MODE` supports: `local`, `hybrid`, `mqtt`.
MQTT_BROKER=192.168.1.100 # Primary MQTT broker - Phase 1 intent topic is group-scoped: `infoscreen/groups/{group_id}/power/intent`.
MQTT_PORT=1883 # MQTT port - In hybrid mode, valid fresh MQTT intent is preferred with local fallback behavior.
MQTT_BROKER_FALLBACKS=host1,host2 # Fallback brokers - 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) ## HDMI-CEC Rules
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)
# Screenshot Configuration - In `ENV=development`, display manager automatically disables CEC.
SCREENSHOT_MAX_WIDTH=800 # Downscale width (preserves aspect ratio) - `scripts/test-hdmi-cec.sh` integration path respects development mode; manual CEC options still work.
SCREENSHOT_JPEG_QUALITY=70 # JPEG compression quality (1-95) - Keep delayed turn-off behavior safe across adjacent events.
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) ## Screenshot System Rules
# 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
```
### File Server URL Resolution - Capture is performed by `display_manager.py`; transmission by `simclient.py`.
- The MQTT client (`simclient.py`) downloads presentation files listed in events. - Keep event-trigger screenshot behavior intact (`event_start` / `event_stop`).
- To avoid DNS issues when event URLs use `http://server:8000/...`, the client normalizes such URLs to the configured file server. - Maintain one-second responsiveness for triggered send handling.
- By default, the file server host is the same as `MQTT_BROKER`, with port `8000` and scheme `http`. - Prefer `latest.jpg` for dashboard transmission, with safe fallback to newest timestamped file.
- 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.
## Development Patterns & Best Practices ## Common Task Pointers
### Error Handling - Add event type: `src/display_manager.py` -> `start_display_for_event()`
- Robust MQTT connection with fallbacks and retries - Presentation behavior: `src/display_manager.py` -> `start_presentation()`
- Graceful degradation when services unavailable - Power intent validation: `src/simclient.py` -> `validate_power_intent_payload()`
- Comprehensive logging with rotating file handlers - Power intent application: `src/display_manager.py` -> `_apply_mqtt_power_intent()`
- Exception handling for all external operations - Screenshot capture logic: `src/display_manager.py` -> `_capture_screenshot()`
- Dashboard payload: `src/simclient.py` -> `_build_dashboard_payload()`
- File URL rewriting: `src/simclient.py` -> `resolve_file_url()`
### State Management ## Documentation Policy
- 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)
### Threading Architecture When updating docs:
- Main thread: MQTT communication and heartbeat
- Background thread: Screenshot monitoring service
- Thread-safe operations for shared resources
### File Operations - Keep `README.md` concise and link-heavy.
- Automatic directory creation for all output paths - Put rollout/runbook content into specialist docs (for example `TV_POWER_RUNBOOK.md`).
- Safe file operations with proper exception handling - Keep implementation history in `CHANGELOG.md`.
- Atomic writes for configuration files - Prefer updating one canonical doc per topic instead of duplicating the same content in multiple files.
- Automatic cleanup of temporary/outdated files
## Development Workflow ## Assistant Workflow Expectations
### Local Development Setup - Prefer minimal, targeted changes.
1. Clone repository to `~/infoscreen-dev` - Preserve existing behavior unless explicitly changing it.
2. Create virtual environment: `python3 -m venv venv` - Validate changes with relevant scripts/log checks where possible.
3. Install dependencies: `pip install -r src/requirements.txt` (includes pygame + pillow for PDF slideshows) - Keep references and examples aligned with current files and topics.
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 dont 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 (14) 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
- **Event-triggered captures**: `_trigger_event_screenshot(type, delay)` arms a one-shot `threading.Timer` after event start/stop; timer is cancelled and replaced on rapid event switches; default delays: presentation=4s, video=2s, web=5s (env-configurable)
- **IPC signal file** (`screenshots/meta.json`): written atomically by `display_manager` after each capture; contains `type`, `captured_at`, `file`, `send_immediately`; `send_immediately=true` for event-triggered, `false` for periodic
### 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, grouped v2 schema
- **Schema version**: `"2.0"` (legacy flat fields removed; all fields grouped)
- **Payload builder**: `_build_dashboard_payload()` in `simclient.py` — single source of truth
- **Payload Structure** (v2):
```json
{
"message": { "client_id": "UUID", "status": "alive" },
"content": {
"screenshot": {
"filename": "latest.jpg",
"data": "base64...",
"timestamp": "ISO datetime",
"size": 12345
}
},
"runtime": {
"system_info": { "hostname": "...", "ip": "...", "uptime": 123456.78 },
"process_health": { "event_type": "...", "process_status": "...", ... }
},
"metadata": {
"schema_version": "2.0",
"producer": "simclient",
"published_at": "ISO datetime",
"capture": {
"type": "periodic | event_start | event_stop",
"captured_at": "ISO datetime",
"age_s": 0.9,
"triggered": false,
"send_immediately": false
},
"transport": { "topic": "infoscreen/.../dashboard", "qos": 0, "publisher": "simclient" }
}
}
```
- **Capture types**: `periodic` (interval-based), `event_start` (N seconds after event launch), `event_stop` (1s after process killed)
- **Triggered send**: `display_manager` sets `send_immediately=true` in `meta.json`; simclient 1-second tick detects and fires within ≤1s
- **Logging**: `Dashboard published: schema=2.0 type=<type> screenshot=<file> (<bytes>) age=<s>`
### 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

2
.gitignore vendored
View File

@@ -135,3 +135,5 @@ logs/
src/pi-dev-setup-new.sh src/pi-dev-setup-new.sh
src/current_process_health.json src/current_process_health.json
src/power_intent_state.json
src/power_state.json

29
CHANGELOG.md Normal file
View File

@@ -0,0 +1,29 @@
# Changelog
## April 2026
- 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.

View File

@@ -1,5 +1,7 @@
# HDMI-CEC Development Mode Behavior # 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 ## Overview
HDMI-CEC TV control is **automatically disabled** in development mode to prevent constantly switching the TV on/off during testing and development work. 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) - **Implementation**: `src/display_manager.py` (lines 48-76)
- **Configuration**: `.env` (CEC section) - **Configuration**: `.env` (CEC section)
- **Testing**: `scripts/test-hdmi-cec.sh`, `scripts/test-tv-response.sh` - **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 ## Summary

View File

@@ -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.

View File

@@ -1,5 +1,12 @@
# HDMI-CEC Setup and Configuration # 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 ## 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. 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.

View File

@@ -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

816
README.md
View File

@@ -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 ## Key Features
- **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
## 📋 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 ## Quick Start
- Raspberry Pi 4/5 (or compatible)
- HDMI display
- Network connectivity (WiFi or Ethernet)
- SSD storage recommended
### Software ### 1. Install Dependencies
- 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
```bash ```bash
# Clone repository
cd ~/ cd ~/
git clone <repository-url> infoscreen-dev git clone <repository-url> infoscreen-dev
cd infoscreen-dev cd infoscreen-dev
# Install system dependencies
sudo apt-get update sudo apt-get update
sudo apt-get install -y \ sudo apt-get install -y \
python3 python3-pip python3-venv \ python3 python3-pip python3-venv \
@@ -51,729 +32,204 @@ sudo apt-get install -y \
cec-utils \ cec-utils \
scrot imagemagick scrot imagemagick
# For Wayland systems, install screenshot tools: # For Wayland systems:
# sudo apt-get install grim gnome-screenshot # sudo apt-get install grim gnome-screenshot
# Create Python virtual environment
python3 -m venv venv python3 -m venv venv
source venv/bin/activate source venv/bin/activate
# Install Python dependencies
pip install -r src/requirements.txt 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 ```bash
# Screenshot capture behavior ENV=production
SCREENSHOT_ALWAYS=0 # Set to 1 for testing (forces capture even without active display) DEBUG_MODE=0
LOG_LEVEL=INFO
# Environment MQTT_BROKER=192.168.1.100
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_PORT=1883 MQTT_PORT=1883
# Timing (seconds) HEARTBEAT_INTERVAL=60
HEARTBEAT_INTERVAL=60 # How often client sends status updates SCREENSHOT_INTERVAL=180
SCREENSHOT_INTERVAL=180 # How often simclient transmits screenshots SCREENSHOT_CAPTURE_INTERVAL=180
SCREENSHOT_CAPTURE_INTERVAL=180 # How often display_manager captures screenshots DISPLAY_CHECK_INTERVAL=15
DISPLAY_CHECK_INTERVAL=15 # How often display_manager checks for new events
# File/API Server (used to download presentation files) FILE_SERVER_HOST=
# Defaults to MQTT_BROKER host with port 8000 and http scheme FILE_SERVER_PORT=8000
FILE_SERVER_HOST= # Optional; if empty, defaults to MQTT_BROKER FILE_SERVER_SCHEME=http
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
# HDMI-CEC TV Control (optional) CEC_ENABLED=true
CEC_ENABLED=true # Enable automatic TV power control CEC_DEVICE=0
CEC_DEVICE=0 # Target device (0 recommended for TV) CEC_TURN_OFF_DELAY=30
CEC_TURN_OFF_DELAY=30 # Seconds to wait before turning off TV CEC_POWER_ON_WAIT=5
CEC_POWER_ON_WAIT=5 # Seconds to wait after power ON (for TV boot) CEC_POWER_OFF_WAIT=5
CEC_POWER_OFF_WAIT=5 # Seconds to wait after power OFF
POWER_CONTROL_MODE=local
``` ```
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 ### 3. Start Services
```bash ```bash
# Start MQTT client (handles events, heartbeat, discovery)
cd ~/infoscreen-dev/src cd ~/infoscreen-dev/src
python3 simclient.py python3 simclient.py
```
# In another terminal: Start Display Manager In a second terminal:
```bash
cd ~/infoscreen-dev/src cd ~/infoscreen-dev/src
python3 display_manager.py python3 display_manager.py
``` ```
Or use the startup script: Or use the helper script:
```bash ```bash
./scripts/start-display-manager.sh ./scripts/start-display-manager.sh
``` ```
## 📊 Presentation System ## Runtime Model
### 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.01.0 (mapped internally to VLC's 0100 volume scale).
- Effective playback volume is calculated as `event.video.volume * client_config.audio.video_volume_multiplier` and then mapped to VLC's 0100 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.
- `FILE_SERVER_HOST` - Optional. File server hostname/IP. Defaults to `MQTT_BROKER` if empty The client runs as two cooperating processes:
- `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
#### HDMI-CEC TV Control (Optional) - `src/simclient.py`: MQTT communication, discovery, heartbeats, event ingestion, dashboard publishing, power intent intake.
Automatic TV power management based on event scheduling. - `src/display_manager.py`: display orchestration, HDMI-CEC, screenshots, local runtime health state.
- `CEC_ENABLED` - Enable automatic TV control: `true` or `false` Important runtime files:
- **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)
### 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:** ### Presentations
- 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
**Best practices:** Presentations are rendered server-side to PDF and displayed with Impressive. Auto-advance, loop, page progress, and auto-progress are supported.
- 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`
### MQTT Topics See [IMPRESSIVE_INTEGRATION.md](IMPRESSIVE_INTEGRATION.md) for full behavior, event examples, and troubleshooting.
#### Client → Server ### Videos
- `infoscreen/discovery` - Initial client announcement
- `infoscreen/{client_id}/heartbeat` - Regular status updates
- `infoscreen/{client_id}/dashboard` - Dashboard payload v2 (grouped schema: message/content/runtime/metadata, includes screenshot base64, capture type, schema version)
- `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
#### Server → Client Video events support:
- `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
### Client Identification - `url`
- `autoplay`
- `loop`
- `volume`
**Hardware Token:** SHA256 hash of: The Display Manager prefers `python-vlc`; if unavailable it falls back to the external VLC binary.
- CPU serial number
- MAC addresses (all network interfaces)
**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:** - `infoscreen/groups/{group_id}/power/intent`
```bash
which impressive
# If not found: sudo apt-get install impressive
```
**Check logs:** Key references:
```bash
tail -f logs/display_manager.log
```
**Check disk space:** - Frozen contract: [TV_POWER_INTENT_SERVER_CONTRACT_V1.md](TV_POWER_INTENT_SERVER_CONTRACT_V1.md)
```bash - Rollout and canary testing: [TV_POWER_RUNBOOK.md](TV_POWER_RUNBOOK.md)
df -h - 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:** - `./scripts/test-display-manager.sh`: event and process testing.
- `auto_advance: true` is set - `./scripts/test-impressive.sh`: single-play presentation.
- `slide_interval` is specified (default: 10) - `./scripts/test-impressive-loop.sh`: looping presentation.
- `./scripts/test-mqtt.sh`: MQTT broker connectivity.
- `./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:** ## Troubleshooting
```bash
./scripts/test-impressive.sh
```
### Presentation doesn't loop Use the specialist docs instead of treating this file as the full troubleshooting manual:
**Verify event JSON:** - Presentation and Impressive issues: [IMPRESSIVE_INTEGRATION.md](IMPRESSIVE_INTEGRATION.md)
- `loop: true` is set - 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:** Quick checks:
```bash
./scripts/test-impressive-loop.sh
```
### 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 both services: `./scripts/restart-all.sh`
Symptoms: ## Deployment
- `Failed to resolve 'server'` or `NameResolutionError` when downloading files
- `Invalid URL 'http # http or https://...'` in `logs/simclient.log`
What to check: For production you typically run both `simclient.py` and `display_manager.py` via systemd or Docker.
- 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 dont include inline comments as part of the value (e.g., keep `FILE_SERVER_SCHEME=http` on its own line)
Fixes: - Container setup: [src/CONTAINER_TRANSITION.md](src/CONTAINER_TRANSITION.md)
- 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` - Production compose file: [src/docker-compose.production.yml](src/docker-compose.production.yml)
- To override fully, set `FILE_SERVER_BASE_URL` (e.g., `http://192.168.1.100:8000`); this takes precedence over host/port/scheme - Display manager architecture: [src/DISPLAY_MANAGER.md](src/DISPLAY_MANAGER.md)
- After changing `.env`, restart the simclient process
Expected healthy log sequence: If running directly on the host, ensure:
- `Lade Datei herunter von: http://<broker-ip>:8000/...`
- Followed by `"GET /... HTTP/1.1" 200` and `Datei erfolgreich heruntergeladen:`
### 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
``` ### Operator / Deployment
[h264_v4l2m2m @ ...] Could not find a valid device
[h264_v4l2m2m @ ...] can't configure decoder
[... ] avcodec decoder error: cannot start codec (h264_v4l2m2m)
```
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`. ### Feature-Specific
- Disable hardware decoding so libVLC/ffmpeg uses software decoding (reliable but higher CPU). You can test this by launching the `vlc` binary with:
```bash - [IMPRESSIVE_INTEGRATION.md](IMPRESSIVE_INTEGRATION.md)
vlc --avcodec-hw=none 'http://<your-video-url>' - [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 - [src/README.md](src/README.md)
instance = vlc.Instance('--avcodec-hw=none', '--no-video-title-show', '--no-video-deco') - [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)
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:** - test with the relevant helper scripts,
```bash - verify logs stay clean,
./scripts/test-mqtt.sh - 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. - keep `.github/copilot-instructions.md` policy-focused,
- Heartbeats are sent only when connected. During brief reconnect windows, Paho may return rc=4 (`NO_CONN`). - follow its "Instruction File Design Rules" section,
- A single rc=4 warning after broker restarts or short network stalls is expected; the next heartbeat usually succeeds. - avoid turning it into a shadow README.
- Investigate only if rc=4 repeats across multiple intervals without subsequent successful heartbeat logs.
### 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`. ## License
- 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 published: schema=2.0 type=periodic screenshot=latest.jpg"
# For event transitions: "Dashboard published: schema=2.0 type=event_start ..."
```
## 📚 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 (14) 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
[Add your license here] [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.
- Event-triggered screenshots added: `display_manager` captures a screenshot shortly after every event start and stop and signals `simclient` via `meta.json` (`send_immediately=true`). Capture delays are content-type aware (presentation: 4s, video: 2s, web: 5s, configurable via `.env`).
- `simclient` screenshot service thread now runs on a 1-second tick instead of a blocking sleep, so triggered sends fire within ≤1s of the `meta.json` signal.
- Dashboard payload migrated to grouped v2 schema (`message`, `content`, `runtime`, `metadata`). Legacy flat fields removed. `metadata.schema_version` is `"2.0"`. Payload assembly centralized in `_build_dashboard_payload()`.
- Tunable trigger delays added: `SCREENSHOT_TRIGGER_DELAY_PRESENTATION`, `SCREENSHOT_TRIGGER_DELAY_VIDEO`, `SCREENSHOT_TRIGGER_DELAY_WEB`.
- Rapid event switches handled safely: pending trigger timer is cancelled and replaced when a new event starts before the delay expires.

View 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.

View 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.

View 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.

View 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
View 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.

View File

@@ -65,6 +65,8 @@ while true; do
echo " 4) Scan for devices" echo " 4) Scan for devices"
echo " 5) Test Display Manager CEC integration" echo " 5) Test Display Manager CEC integration"
echo " 6) View CEC logs from Display Manager" 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 " q) Quit"
echo "" echo ""
read -p "Enter choice: " choice read -p "Enter choice: " choice
@@ -249,6 +251,35 @@ PYTEST
echo "" echo ""
read -p "Press Enter to continue..." 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) q|Q)
echo "Exiting..." echo "Exiting..."
exit 0 exit 0

246
scripts/test-power-intent.sh Executable file
View File

@@ -0,0 +1,246 @@
#!/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}"
# ── 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" -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" -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" -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" -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

View File

@@ -1,274 +1,129 @@
# 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 ## Architecture
- 📥 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
## Quick Setup The client is split into two cooperating processes:
### 1. Flash Raspberry Pi OS - `simclient.py`: MQTT communication, discovery, group assignment, event intake, heartbeat, dashboard publishing, power-intent intake.
- Use **Raspberry Pi OS (64-bit) with Desktop** - `display_manager.py`: event polling, display orchestration, HDMI-CEC, screenshots, local process health state.
- Enable SSH and configure WiFi in Pi Imager
- Boot Pi and connect to network
### 2. Install Development Environment Primary runtime flow:
```bash
# Run on your Raspberry Pi: 1. `simclient.py` receives group and event messages over MQTT.
curl -sSL https://raw.githubusercontent.com/RobbStarkAustria/infoscreen_client_2025/main/pi-dev-setup.sh | bash 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.
- `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.
## Developer Workflow
Typical local workflow:
### 3. Configure MQTT Broker
```bash ```bash
cd ~/infoscreen-dev cd ~/infoscreen-dev
nano .env source venv/bin/activate
# Update MQTT_BROKER=your-server-ip
```
### 4. Test Setup # Terminal 1
```bash
./scripts/test-mqtt.sh # Test MQTT connection
./scripts/test-screenshot.sh # Test screenshot capture
./scripts/test-presentation.sh # Test presentation tools
```
### 5. Start Development
```bash
# Terminal 1: Start MQTT client (receives events)
./scripts/start-dev.sh ./scripts/start-dev.sh
# Terminal 2: Start Display Manager (controls screen) # Terminal 2
./scripts/start-display-manager.sh ./scripts/start-display-manager.sh
# Or use interactive menu:
./dev-workflow.sh
``` ```
**Important**: You need **both** processes running: Useful helpers:
- `simclient.py` - Handles MQTT communication and writes events
- `display_manager.py` - Reads events and controls display software
See [DISPLAY_MANAGER.md](DISPLAY_MANAGER.md) for detailed documentation. - `./dev-workflow.sh`
- `./scripts/test-display-manager.sh`
## Development Workflow - `./scripts/test-mqtt.sh`
- `./scripts/test-screenshot.sh`
### Daily Development - `./scripts/test-power-intent.sh`
```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
```
## MQTT Topics ## MQTT Topics
### Client → Server ### Client → Server
- `infoscreen/discovery` - Client registration
- `infoscreen/{client_id}/heartbeat` - Regular heartbeat - `infoscreen/discovery`
- `infoscreen/{client_id}/dashboard` - Screenshot + status - `infoscreen/{client_id}/heartbeat`
- `infoscreen/{client_id}/dashboard`
- `infoscreen/{client_id}/health`
- `infoscreen/{client_id}/power/state`
### Server → Client ### 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`
The Display Manager supports three event types: ## Event and Display Notes
**Presentation Event:** Supported runtime content categories:
```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
}
}
```
**Web Page Event:** - presentation
```json - video
{ - web / webpage / website / webuntis
"id": 2,
"title": "Dashboard",
"start": "2025-10-01 08:00:00",
"end": "2025-10-01 18:00:00",
"web": {
"url": "https://dashboard.example.com"
}
}
```
**Video Event:** Presentation behavior is documented in [../IMPRESSIVE_INTEGRATION.md](../IMPRESSIVE_INTEGRATION.md).
```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
}
}
```
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 ## Debugging
### View Logs ### Logs
```bash ```bash
tail -f ~/infoscreen-dev/logs/simclient.log tail -f ~/infoscreen-dev/logs/display_manager.log ~/infoscreen-dev/src/simclient.log
``` ```
### MQTT Debugging ### Runtime Files
```bash
# Subscribe to all infoscreen topics
mosquitto_sub -h YOUR_BROKER_IP -t "infoscreen/+/+"
# Publish test event ```bash
mosquitto_pub -h YOUR_BROKER_IP -t "infoscreen/events/test-group" -m '{"web":{"url":"https://google.com"}}' 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) ### MQTT Inspection
```bash
# Enable automatic startup
sudo systemctl enable infoscreen-dev
sudo systemctl start infoscreen-dev
# View service logs ```bash
sudo journalctl -u infoscreen-dev -f 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 ```bash
export DISPLAY=:0 ls -lh ~/infoscreen-dev/src/screenshots/
echo $DISPLAY cat ~/infoscreen-dev/src/screenshots/meta.json
``` ```
### Screenshot Issues ## Environment Notes
```bash
# Test screenshot manually
scrot ~/test.png
# Check permissions
sudo usermod -a -G video pi
```
### MQTT Connection Issues - `ENV=development` disables HDMI-CEC in the display manager.
```bash - `POWER_CONTROL_MODE` controls local vs hybrid vs mqtt power behavior.
# Test broker connectivity - File download host rewriting is handled in `simclient.py` using `FILE_SERVER_*` settings.
telnet YOUR_BROKER_IP 1883
# Check firewall
sudo ufw status
```
## Development vs Production ## Related Documents
This setup is optimized for **development**: - [../README.md](../README.md)
- ✅ Fast iteration (edit → save → restart) - [DISPLAY_MANAGER.md](DISPLAY_MANAGER.md)
- ✅ Native debugging and logging - [IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md)
- ✅ Direct hardware access - [../CLIENT_MONITORING_SETUP.md](../CLIENT_MONITORING_SETUP.md)
- ✅ Remote development friendly - [../SCREENSHOT_MQTT_FIX.md](../SCREENSHOT_MQTT_FIX.md)
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.

View File

@@ -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_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_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 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 # Setup logging
LOG_PATH = os.path.join(os.path.dirname(__file__), "..", "logs", "display_manager.log") LOG_PATH = os.path.join(os.path.dirname(__file__), "..", "logs", "display_manager.log")
@@ -131,6 +134,11 @@ class ProcessHealthState:
self.restart_count = 0 self.restart_count = 0
self.max_restarts = 3 self.max_restarts = 3
self.last_update = datetime.now(timezone.utc).isoformat() 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: def to_dict(self) -> Dict:
return { return {
@@ -140,7 +148,14 @@ class ProcessHealthState:
"process_pid": self.process_pid, "process_pid": self.process_pid,
"process_status": self.status, "process_status": self.status,
"restart_count": self.restart_count, "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): def save(self):
@@ -188,6 +203,14 @@ class ProcessHealthState:
self.save() self.save()
monitoring_logger.info("Process stopped (event ended or no active event)") 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: class HDMICECController:
"""Controls HDMI-CEC to turn TV on/off automatically """Controls HDMI-CEC to turn TV on/off automatically
@@ -213,6 +236,7 @@ class HDMICECController:
self.power_off_wait = power_off_wait self.power_off_wait = power_off_wait
self.tv_state = None # None = unknown, True = on, False = off self.tv_state = None # None = unknown, True = on, False = off
self.turn_off_timer = None self.turn_off_timer = None
self.turn_off_guard = None
if not self.enabled: if not self.enabled:
logging.info("HDMI-CEC control disabled") logging.info("HDMI-CEC control disabled")
@@ -391,6 +415,16 @@ class HDMICECController:
def _turn_off_now(self) -> bool: def _turn_off_now(self) -> bool:
"""Internal method to turn TV off immediately""" """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 # Skip if TV is already off
if self.tv_state is False: if self.tv_state is False:
logging.debug("TV already off, skipping CEC command") logging.debug("TV already off, skipping CEC command")
@@ -421,6 +455,10 @@ class HDMICECController:
self.turn_off_timer = None self.turn_off_timer = None
logging.debug("Cancelled TV turn-off timer") 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: class DisplayProcess:
"""Manages a running display application process""" """Manages a running display application process"""
@@ -598,9 +636,15 @@ class DisplayManager:
self.client_settings_mtime: Optional[float] = None self.client_settings_mtime: Optional[float] = None
self.client_volume_multiplier = 1.0 self.client_volume_multiplier = 1.0
self._video_duration_cache: Dict[str, float] = {} 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 # Initialize health state tracking for process monitoring
self.health = ProcessHealthState() self.health = ProcessHealthState()
self.health.power_control_mode = self.power_control_mode
# Initialize HDMI-CEC controller # Initialize HDMI-CEC controller
self.cec = HDMICECController( self.cec = HDMICECController(
@@ -610,6 +654,8 @@ class DisplayManager:
power_on_wait=CEC_POWER_ON_WAIT, power_on_wait=CEC_POWER_ON_WAIT,
power_off_wait=CEC_POWER_OFF_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 # Setup signal handlers for graceful shutdown
signal.signal(signal.SIGTERM, self._signal_handler) signal.signal(signal.SIGTERM, self._signal_handler)
@@ -811,6 +857,152 @@ class DisplayManager:
logging.error(f"Error reading event file: {e}") logging.error(f"Error reading event file: {e}")
return None 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: def is_event_active(self, event: Dict) -> bool:
"""Check if event should be displayed based on start/end times """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) # Turn off TV when display stops (with configurable delay)
if turn_off_tv: if turn_off_tv:
self.cec.turn_off(delayed=True) self.cec.turn_off(delayed=True)
self.health.update_power_action("off", "local_fallback")
def start_presentation(self, event: Dict) -> Optional[DisplayProcess]: def start_presentation(self, event: Dict) -> Optional[DisplayProcess]:
"""Start presentation display (PDF/PowerPoint/LibreOffice) using Impressive """Start presentation display (PDF/PowerPoint/LibreOffice) using Impressive
@@ -1658,6 +1851,9 @@ class DisplayManager:
def process_events(self): def process_events(self):
"""Main processing loop - check for event changes and manage display""" """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() event_data = self.read_event_file()
@@ -1665,7 +1861,7 @@ class DisplayManager:
if not event_data: if not event_data:
if self.current_process: if self.current_process:
logging.info("No active event - stopping current display") logging.info("No active event - stopping current display")
self.stop_current_display() self.stop_current_display(turn_off_tv=local_power_control)
return return
# Handle event arrays (take first event) # Handle event arrays (take first event)
@@ -1674,7 +1870,7 @@ class DisplayManager:
if not events_to_process: if not events_to_process:
if self.current_process: if self.current_process:
logging.info("Empty event list - stopping current display") logging.info("Empty event list - stopping current display")
self.stop_current_display() self.stop_current_display(turn_off_tv=local_power_control)
return return
# Process first active event # Process first active event
@@ -1687,7 +1883,7 @@ class DisplayManager:
if not active_event: if not active_event:
if self.current_process: if self.current_process:
logging.info("No active events in time window - stopping current display") 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 return
# Get event identifier # Get event identifier
@@ -1755,6 +1951,7 @@ class DisplayManager:
else: else:
# Everything is fine, continue # Everything is fine, continue
# Cancel any pending TV turn-off since event is still active # Cancel any pending TV turn-off since event is still active
if local_power_control:
self.cec.cancel_turn_off() self.cec.cancel_turn_off()
self._apply_runtime_video_settings(active_event) self._apply_runtime_video_settings(active_event)
return return
@@ -1773,7 +1970,9 @@ class DisplayManager:
logging.info(f" Event end time (UTC): {active_event['end']}") logging.info(f" Event end time (UTC): {active_event['end']}")
# Turn on TV before starting display # Turn on TV before starting display
if local_power_control:
self.cec.turn_on() self.cec.turn_on()
self.health.update_power_action("on", "local_fallback")
new_process = self.start_display_for_event(active_event) new_process = self.start_display_for_event(active_event)

View File

@@ -147,6 +147,9 @@ CLIENT_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), "config", "client
# Screenshot IPC (written by display_manager, polled by simclient) # Screenshot IPC (written by display_manager, polled by simclient)
SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "screenshots") SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "screenshots")
SCREENSHOT_META_FILE = os.path.join(SCREENSHOT_DIR, "meta.json") SCREENSHOT_META_FILE = os.path.join(SCREENSHOT_DIR, "meta.json")
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")
discovered = False discovered = False
@@ -237,6 +240,127 @@ def is_empty_event(event_data):
return False return False
def _parse_utc_iso(value: str):
"""Parse ISO8601 timestamp with optional trailing Z into UTC-aware datetime."""
if not isinstance(value, str) or not value.strip():
raise ValueError("timestamp must be a 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 validate_power_intent_payload(payload, expected_group_id=None):
"""Validate frozen TV power intent contract v1 payload.
Returns tuple: (is_valid, result_dict, error_message)
"""
if not isinstance(payload, dict):
return False, None, "payload must be an object"
required_fields = (
"schema_version", "intent_id", "group_id", "desired_state", "reason",
"issued_at", "expires_at", "poll_interval_sec", "active_event_ids",
"event_window_start", "event_window_end"
)
for field in required_fields:
if field not in payload:
return False, None, f"missing required field: {field}"
if payload.get("schema_version") != "1.0":
return False, None, f"unsupported schema_version: {payload.get('schema_version')}"
desired_state = payload.get("desired_state")
if desired_state not in ("on", "off"):
return False, None, f"invalid desired_state: {desired_state}"
reason = payload.get("reason")
if reason not in ("active_event", "no_active_event"):
return False, None, f"invalid reason: {reason}"
intent_id = payload.get("intent_id")
if not isinstance(intent_id, str) or not intent_id.strip():
return False, None, "intent_id must be a non-empty string"
try:
group_id = int(payload.get("group_id"))
except Exception:
return False, None, f"invalid group_id: {payload.get('group_id')}"
if expected_group_id is not None:
try:
expected_group_id_int = int(expected_group_id)
except Exception:
expected_group_id_int = None
if expected_group_id_int is not None and expected_group_id_int != group_id:
return False, None, f"group_id mismatch: payload={group_id} expected={expected_group_id_int}"
try:
issued_at = _parse_utc_iso(payload.get("issued_at"))
expires_at = _parse_utc_iso(payload.get("expires_at"))
except Exception as e:
return False, None, f"invalid timestamp: {e}"
if expires_at <= issued_at:
return False, None, "expires_at must be later than issued_at"
if datetime.now(timezone.utc) > expires_at:
return False, None, "intent expired"
try:
poll_interval_sec = int(payload.get("poll_interval_sec"))
except Exception:
return False, None, f"invalid poll_interval_sec: {payload.get('poll_interval_sec')}"
if poll_interval_sec <= 0:
return False, None, "poll_interval_sec must be > 0"
active_event_ids = payload.get("active_event_ids")
if not isinstance(active_event_ids, list):
return False, None, "active_event_ids must be a list"
normalized_event_ids = []
for item in active_event_ids:
try:
normalized_event_ids.append(int(item))
except Exception:
return False, None, f"invalid active_event_id value: {item}"
for field in ("event_window_start", "event_window_end"):
value = payload.get(field)
if value is not None:
try:
_parse_utc_iso(value)
except Exception as e:
return False, None, f"invalid {field}: {e}"
normalized = {
"schema_version": "1.0",
"intent_id": intent_id.strip(),
"group_id": group_id,
"desired_state": desired_state,
"reason": reason,
"issued_at": payload.get("issued_at"),
"expires_at": payload.get("expires_at"),
"poll_interval_sec": poll_interval_sec,
"active_event_ids": normalized_event_ids,
"event_window_start": payload.get("event_window_start"),
"event_window_end": payload.get("event_window_end"),
}
return True, normalized, None
def write_power_intent_state(data):
"""Atomically write power intent state for display_manager consumption."""
try:
tmp_path = POWER_INTENT_STATE_FILE + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, POWER_INTENT_STATE_FILE)
except Exception as e:
logging.error(f"Error writing power intent state: {e}")
def on_message(client, userdata, msg, properties=None): def on_message(client, userdata, msg, properties=None):
global discovered global discovered
logging.info(f"Received: {msg.topic} {msg.payload.decode()}") logging.info(f"Received: {msg.topic} {msg.payload.decode()}")
@@ -563,6 +687,64 @@ def read_health_state():
return None return None
def read_power_state():
"""Read last power action state produced by display_manager."""
try:
if not os.path.exists(POWER_STATE_FILE):
return None
with open(POWER_STATE_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logging.debug(f"Could not read power state file: {e}")
return None
def publish_power_state_message(client, client_id, power_state: dict):
"""Publish power action telemetry to MQTT (best effort)."""
try:
if not isinstance(power_state, dict):
return
payload = dict(power_state)
payload["client_id"] = client_id
payload.setdefault("reported_at", datetime.now(timezone.utc).isoformat())
topic = f"infoscreen/{client_id}/power/state"
res = client.publish(topic, json.dumps(payload), qos=0)
if res.rc == mqtt.MQTT_ERR_SUCCESS:
p = payload.get("power", {})
logging.info(
"Power state published: state=%s source=%s result=%s",
p.get("applied_state"),
p.get("source"),
p.get("result"),
)
except Exception as e:
logging.debug(f"Could not publish power state: {e}")
def power_state_service_thread(client, client_id):
"""Background publisher for power action state changes."""
logging.info("Power state service started")
last_mtime = None
while True:
try:
time.sleep(1)
if not os.path.exists(POWER_STATE_FILE):
continue
mtime = os.path.getmtime(POWER_STATE_FILE)
if last_mtime is not None and mtime <= last_mtime:
continue
last_mtime = mtime
state = read_power_state()
if state:
publish_power_state_message(client, client_id, state)
except Exception as e:
logging.debug(f"Power state service error: {e}")
time.sleep(2)
def save_client_settings(settings_data): def save_client_settings(settings_data):
"""Persist dashboard-managed client settings for the display manager.""" """Persist dashboard-managed client settings for the display manager."""
try: try:
@@ -822,6 +1004,9 @@ def main():
group_id_path = os.path.join(os.path.dirname(__file__), "config", "last_group_id.txt") group_id_path = os.path.join(os.path.dirname(__file__), "config", "last_group_id.txt")
current_group_id = load_last_group_id(group_id_path) current_group_id = load_last_group_id(group_id_path)
event_topic = None event_topic = None
power_intent_topic = None
last_power_intent_id = None
last_power_issued_at = None
# paho-mqtt v2: opt into latest callback API to avoid deprecation warnings. # paho-mqtt v2: opt into latest callback API to avoid deprecation warnings.
client_kwargs = {"protocol": mqtt.MQTTv311} client_kwargs = {"protocol": mqtt.MQTTv311}
@@ -881,6 +1066,25 @@ def main():
current_group_id = new_group_id current_group_id = new_group_id
save_last_group_id(group_id_path, new_group_id) save_last_group_id(group_id_path, new_group_id)
def subscribe_power_intent_topic(new_group_id):
nonlocal power_intent_topic
if POWER_CONTROL_MODE not in ("hybrid", "mqtt"):
return
new_topic = f"infoscreen/groups/{new_group_id}/power/intent"
if power_intent_topic == new_topic:
logging.info(f"Power intent topic already subscribed: {power_intent_topic}")
return
if power_intent_topic:
client.unsubscribe(power_intent_topic)
logging.info(f"Unsubscribed from power intent topic: {power_intent_topic}")
power_intent_topic = new_topic
client.subscribe(power_intent_topic, qos=1)
logging.info(f"Subscribed to power intent topic: {power_intent_topic}")
# on_connect callback: Subscribe to all topics after connection is established # on_connect callback: Subscribe to all topics after connection is established
def on_connect(client, userdata, flags, rc, properties=None): def on_connect(client, userdata, flags, rc, properties=None):
if rc == 0: if rc == 0:
@@ -926,6 +1130,9 @@ def main():
nonlocal event_topic nonlocal event_topic
event_topic = None # force re-subscribe regardless of previous state event_topic = None # force re-subscribe regardless of previous state
subscribe_event_topic(current_group_id) subscribe_event_topic(current_group_id)
nonlocal power_intent_topic
power_intent_topic = None
subscribe_power_intent_topic(current_group_id)
# Send discovery message after reconnection to re-register with server # Send discovery message after reconnection to re-register with server
if is_reconnect: if is_reconnect:
@@ -1020,11 +1227,95 @@ def main():
logging.info(f"group_id unchanged: {new_group_id}, ensuring event topic is subscribed") logging.info(f"group_id unchanged: {new_group_id}, ensuring event topic is subscribed")
# Always call subscribe_event_topic to ensure subscription # Always call subscribe_event_topic to ensure subscription
subscribe_event_topic(new_group_id) subscribe_event_topic(new_group_id)
subscribe_power_intent_topic(new_group_id)
else: else:
logging.warning("Empty group_id received!") logging.warning("Empty group_id received!")
client.message_callback_add(group_id_topic, on_group_id_message) client.message_callback_add(group_id_topic, on_group_id_message)
logging.info(f"Current group_id at start: {current_group_id if current_group_id else 'none'}") logging.info(f"Current group_id at start: {current_group_id if current_group_id else 'none'}")
def on_power_intent_message(client, userdata, msg, properties=None):
nonlocal last_power_intent_id, last_power_issued_at
payload_text = msg.payload.decode().strip()
received_at = datetime.now(timezone.utc).isoformat()
# A retained null-message clears the topic and arrives as an empty payload.
if not payload_text:
logging.info("Power intent retained message cleared (empty payload)")
write_power_intent_state({
"valid": False,
"mode": POWER_CONTROL_MODE,
"error": "retained_cleared",
"received_at": received_at,
"topic": msg.topic,
})
return
try:
payload = json.loads(payload_text)
except json.JSONDecodeError as e:
logging.warning(f"Invalid power intent JSON: {e}")
write_power_intent_state({
"valid": False,
"mode": POWER_CONTROL_MODE,
"error": f"invalid_json: {e}",
"received_at": received_at,
"topic": msg.topic,
})
return
is_valid, normalized, error = validate_power_intent_payload(payload, expected_group_id=current_group_id)
if not is_valid:
logging.warning(f"Rejected power intent: {error}")
write_power_intent_state({
"valid": False,
"mode": POWER_CONTROL_MODE,
"error": error,
"received_at": received_at,
"topic": msg.topic,
})
return
try:
issued_dt = _parse_utc_iso(normalized["issued_at"])
except Exception:
issued_dt = None
if last_power_issued_at and issued_dt and issued_dt < last_power_issued_at:
logging.warning(
f"Rejected out-of-order power intent {normalized['intent_id']} issued_at={normalized['issued_at']}"
)
write_power_intent_state({
"valid": False,
"mode": POWER_CONTROL_MODE,
"error": "out_of_order_intent",
"received_at": received_at,
"topic": msg.topic,
})
return
duplicate_intent_id = normalized["intent_id"] == last_power_intent_id
if issued_dt:
last_power_issued_at = issued_dt
last_power_intent_id = normalized["intent_id"]
logging.info(
"Power intent accepted: id=%s desired_state=%s reason=%s expires_at=%s duplicate=%s",
normalized["intent_id"],
normalized["desired_state"],
normalized["reason"],
normalized["expires_at"],
duplicate_intent_id,
)
write_power_intent_state({
"valid": True,
"mode": POWER_CONTROL_MODE,
"received_at": received_at,
"topic": msg.topic,
"duplicate_intent_id": duplicate_intent_id,
"payload": normalized,
})
config_topic = f"infoscreen/{client_id}/config" config_topic = f"infoscreen/{client_id}/config"
def on_config_message(client, userdata, msg, properties=None): def on_config_message(client, userdata, msg, properties=None):
payload = msg.payload.decode().strip() payload = msg.payload.decode().strip()
@@ -1047,6 +1338,21 @@ def main():
client.message_callback_add(config_topic, on_config_message) client.message_callback_add(config_topic, on_config_message)
if POWER_CONTROL_MODE in ("hybrid", "mqtt"):
if current_group_id:
subscribe_power_intent_topic(current_group_id)
else:
logging.info("Power control mode active but no group_id yet; waiting for group assignment")
def on_power_intent_dispatch(client, userdata, msg, properties=None):
on_power_intent_message(client, userdata, msg, properties)
# Register a generic callback so topic changes on group switch do not require re-registration.
client.message_callback_add("infoscreen/groups/+/power/intent", on_power_intent_dispatch)
logging.info(f"Power control mode active: {POWER_CONTROL_MODE}")
else:
logging.info(f"Power control mode is local; MQTT power intents disabled")
# Discovery-Phase: Sende Discovery bis ACK empfangen # Discovery-Phase: Sende Discovery bis ACK empfangen
# The loop is already started, just wait and send discovery messages # The loop is already started, just wait and send discovery messages
discovery_attempts = 0 discovery_attempts = 0
@@ -1079,6 +1385,14 @@ def main():
screenshot_thread.start() screenshot_thread.start()
logging.info("Screenshot service thread started") logging.info("Screenshot service thread started")
power_state_thread = threading.Thread(
target=power_state_service_thread,
args=(client, client_id),
daemon=True,
)
power_state_thread.start()
logging.info("Power state service thread started")
# Heartbeat-Loop with connection state monitoring # Heartbeat-Loop with connection state monitoring
last_heartbeat = 0 last_heartbeat = 0
logging.info("Entering heartbeat loop (network loop already running in background thread)") logging.info("Entering heartbeat loop (network loop already running in background thread)")

313
tests/test_power_intent.py Normal file
View 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()