diff --git a/.env.template b/.env.template index 119738d..cd723a3 100644 --- a/.env.template +++ b/.env.template @@ -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_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) #MQTT_USERNAME= #MQTT_PASSWORD= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 853ec64..e38733c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,596 +1,124 @@ # Copilot Instructions - Infoscreen Client -## Quick Start for AI Assistants +## Purpose +This file defines durable, high-signal instructions for AI assistants working in this repository. -### Critical Rules -- ✅ **ALWAYS use Impressive** for PDF presentations (has native auto-advance/loop) -- ❌ **NEVER suggest xdotool** approaches (failed on Raspberry Pi due to focus issues) -- ❌ **NEVER suggest video conversion** (adds complexity, had black screen issues) -- ✅ **Virtual environment MUST have** pygame + pillow (required for Impressive) -- ✅ **Client-side resize/compress** screenshots before MQTT transmission -- ✅ **Server renders PPTX → PDF via Gotenberg** (client only displays PDFs, no LibreOffice needed) -- ✅ **Keep screenshot consent notice in docs** when describing dashboard screenshot feature -- ✅ **Event-start/event-stop screenshots must preserve metadata** - See SCREENSHOT_MQTT_FIX.md for critical race condition that was fixed -- ✅ **Screenshot updates must keep `latest.jpg` and `meta.json` in sync** (simclient prefers `latest.jpg`) -- ✅ **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 +## Instruction File Design Rules -### Key Files & Locations -- **Display logic**: `src/display_manager.py` (controls presentations/video/web) -- **MQTT client**: `src/simclient.py` (event management, heartbeat, discovery) -- **Runtime state**: `src/current_event.json` (current active event) -- **Process health bridge**: `src/current_process_health.json` (display_manager -> simclient) -- **Config**: `src/config/client_uuid.txt`, `src/config/last_group_id.txt`, `.env` -- **Logs**: `logs/display_manager.log`, `logs/simclient.log`, `logs/monitoring.log` -- **Screenshots**: `src/screenshots/` (shared volume between processes) +Treat this file as policy, not as project handbook. -### Common Tasks Quick Reference -| Task | File | Key Method/Section | -|------|------|-------------------| -| Add event type | `display_manager.py` | `start_display_for_event()` | -| Modify presentation | `display_manager.py` | `start_presentation()` | -| Modify process monitoring | `display_manager.py` | `ProcessHealthState`, `process_events()` | -| Publish health/log topics | `simclient.py` | `read_health_state()`, `publish_health_message()`, `publish_log_message()` | -| Change MQTT topics | `simclient.py` | Topic constants/handlers | -| Update screenshot | `display_manager.py` | `_capture_screenshot()` | -| File downloads | `simclient.py` | `resolve_file_url()` | +- Scope rule: keep only durable constraints, architectural invariants, and high-value task pointers for assistants. +- Size rule: target 80-140 lines; hard cap 180 lines. +- Canonical-doc rule: link to specialist docs for operational depth instead of copying their content. +- Single-source rule: each topic has one canonical document; this file should only reference it. +- No shadow-README rule: do not add long setup guides, full command catalogs, troubleshooting playbooks, or large directory trees. ---- +Allowed content: -## Project Overview -**Infoscreen Client** - Digital signage system for Raspberry Pi. Displays presentations, videos, and web content in kiosk mode. Server-managed via MQTT for educational/research environments with multiple displays. +- Critical do/don't rules. +- Short architecture snapshot. +- Runtime coordination file map. +- Minimal task pointers to key methods. +- Documentation policy for where detailed content belongs. -**Architecture**: Two-process design -- `simclient.py` - MQTT communication (container/native) -- `display_manager.py` - Display control (host OS with X11/Wayland access) +Disallowed content: -## Architecture & Technology Stack +- Comprehensive installation/deployment tutorials. +- Large environment-variable reference sections. +- Extended troubleshooting matrices. +- Repeated feature deep-dives already documented elsewhere. +- Historical release notes (keep those in `CHANGELOG.md`). -### Core Technologies -- **Python 3.x** - Main application language -- **MQTT (paho-mqtt)** - Real-time messaging with server -- **Impressive** - PDF presenter with native auto-advance and loop support -- **Environment Variables** - Configuration management via `.env` files -- **JSON** - Data exchange format for events and configuration -- **Base64** - Screenshot transmission encoding -- **Threading** - Background services (screenshot monitoring) +Update checklist for contributors: -### System Components -- **Main Client** (`simclient.py`) - Core MQTT client and event processor -- **Display Manager** (`display_manager.py`) - Controls display applications (presentations, videos, web) -- **Discovery System** - Automatic client registration with server -- **Heartbeat Monitoring** - Regular status updates and keepalive -- **Event Processing** - Handles presentation/content switching commands -- **Screenshot Service** - Dashboard monitoring via image capture (captured by display_manager.py, transmitted by simclient.py) -- **File Management** - Downloads and manages presentation files -- **Group Management** - Supports organizing clients into groups +1. Is the new text a durable assistant rule or invariant? +2. If it is operational detail, did you place it in the specialist doc and only link it here? +3. Did you avoid duplicating existing docs? +4. Does this file remain below the hard cap? -## Key Features & Functionality +Use specialist docs for deep operational details: -### MQTT Communication Patterns -- **Discovery**: `infoscreen/discovery` → `infoscreen/{client_id}/discovery_ack` -- **Heartbeat**: Regular `infoscreen/{client_id}/heartbeat` messages -- **Health**: `infoscreen/{client_id}/health` (event/process/pid/status) -- **Client logs**: `infoscreen/{client_id}/logs/error|warn` (selective forwarding) -### MQTT Reconnection & Heartbeat (Nov 2025) -- The client uses Paho MQTT v2 callback API with `client.loop_start()` and `client.reconnect_delay_set()` to handle automatic reconnection. -- `on_connect` re-subscribes to all topics (`discovery_ack`, `config`, `group_id`, current group events) and re-sends discovery on reconnect to re-register with the server. -- Heartbeats are gated by `client.is_connected()` and retry once on `NO_CONN` (rc=4). Occasional rc=4 warnings are normal right after broker restarts or brief network stalls and typically followed by a successful heartbeat. -- Do not treat single rc=4 heartbeat warnings as failures. Investigate only if multiple consecutive heartbeats fail without recovery. -- **Dashboard**: Screenshot transmission via `infoscreen/{client_id}/dashboard` (includes base64-encoded screenshot, timestamp, client status, system info) -- **Group Assignment**: Server sends group via `infoscreen/{client_id}/group_id` -- **Events**: Content commands via `infoscreen/events/{group_id}` +- `README.md` (landing page + docs map) +- `TV_POWER_RUNBOOK.md` (TV power rollout and canary) +- `TV_POWER_INTENT_SERVER_CONTRACT_V1.md` (frozen contract) +- `IMPRESSIVE_INTEGRATION.md` (presentation behavior) +- `HDMI_CEC_SETUP.md` (CEC setup/troubleshooting) +- `SCREENSHOT_MQTT_FIX.md` (screenshot race-condition fixes) +- `src/README.md` (developer-focused architecture/debugging) -### Event Types Supported -```json -{ - "presentation": { - "files": [{"url": "https://server/file.pptx", "filename": "file.pptx"}], - "auto_advance": true, - "slide_interval": 10, - "loop": true - }, - "web": { - "url": "https://example.com" - }, - "video": { - "url": "https://server/video.mp4", - "loop": false, - "autoplay": true, - "volume": 0.8 - } -} -``` +## Critical Rules -### Presentation System (Impressive-Based) -- **Server-side conversion**: PPTX files are converted to PDF by the server using Gotenberg -- **Client receives PDFs**: All presentations arrive as pre-rendered PDF files -- **Direct display**: PDF files are displayed natively with Impressive (no client-side conversion) -- **Auto-advance**: Native Impressive `--auto` parameter (no xdotool needed) -- **Loop mode**: Impressive `--wrap` parameter for infinite looping -- **Auto-quit**: Impressive `--autoquit` parameter to exit after last slide -- **Virtual Environment**: Uses venv with pygame + pillow for reliable operation -- **Reliable**: Works consistently on Raspberry Pi without window focus issues +- ALWAYS use Impressive for PDF presentations. +- NEVER suggest xdotool-based slideshow control. +- NEVER suggest converting presentations to video as a workaround. +- Virtual environment must include `pygame` and `pillow` for Impressive. +- Keep screenshot consent notice in docs when describing dashboard screenshots. +- Keep screenshot updates consistent between `latest.jpg` and `meta.json`. +- Event-trigger screenshots must preserve metadata and send quickly (`send_immediately=true`). +- Dashboard payload must stay grouped v2 (`message/content/runtime/metadata`, `schema_version="2.0"`). +- Dashboard payload assembly is centralized in `_build_dashboard_payload()`. +- Root `README.md` is a landing page; do not re-expand it into a full manual. +- TV power rollout guidance lives in `TV_POWER_RUNBOOK.md`. +- TV power contract truth lives in `TV_POWER_INTENT_SERVER_CONTRACT_V1.md`. -### Client Identification -- **Hardware Token**: SHA256 hash of serial number + MAC addresses -- **Persistent UUID**: Stored in `config/client_uuid.txt` -- **Group Membership**: Persistent group assignment in `config/last_group_id.txt` +## Architecture Snapshot -## Directory Structure -``` -~/infoscreen-dev/ -├── .env # Environment configuration -├── README.md # Complete project documentation -├── IMPRESSIVE_INTEGRATION.md # Presentation system details -├── QUICK_REFERENCE.md # Quick command reference -├── .github/ # GitHub configuration -│ └── copilot-instructions.md -├── src/ # Source code -│ ├── simclient.py # MQTT client (event management) -│ ├── display_manager.py # Display controller (Impressive integration) -│ ├── current_event.json # Current active event (runtime) -│ ├── config/ # Persistent client data -│ │ ├── client_uuid.txt -│ │ └── last_group_id.txt -│ ├── presentation/ # Downloaded presentation files & PDFs -│ └── screenshots/ # Screenshot captures for monitoring -├── scripts/ # Production & testing utilities -│ ├── start-dev.sh # Start development client -│ ├── start-display-manager.sh # Start Display Manager -│ ├── test-display-manager.sh # Interactive testing menu -│ ├── test-impressive.sh # Test Impressive (auto-quit) -│ ├── test-impressive-loop.sh # Test Impressive (loop mode) -│ ├── test-mqtt.sh # MQTT connectivity test -│ ├── test-screenshot.sh # Screenshot capture test -│ └── present-pdf-auto-advance.sh # PDF presentation wrapper -├── logs/ # Application logs -│ ├── simclient.log -│ └── display_manager.log -└── venv/ # Python virtual environment -``` +Two-process design: -## Configuration & Environment Variables +- `src/simclient.py`: MQTT communication, discovery, group assignment, event intake, heartbeat, dashboard publish, power intent ingestion. +- `src/display_manager.py`: content display lifecycle, HDMI-CEC, screenshot capture, runtime process health. -### Development vs Production -- **Development**: `ENV=development`, verbose logging, frequent heartbeats -- **Production**: `ENV=production`, minimal logging, longer intervals +Runtime coordination files: -HDMI-CEC behavior: -- In development mode (`ENV=development`) the Display Manager automatically disables HDMI-CEC to avoid constantly switching the TV during local testing. The test helper `scripts/test-hdmi-cec.sh` also respects this: option 5 (Display Manager CEC integration) detects dev mode and skips running CEC commands. Manual options (1–4) still work for direct `cec-client` checks. +- `src/current_event.json` (active event) +- `src/current_process_health.json` (health bridge) +- `src/power_intent_state.json` (simclient -> display_manager) +- `src/power_state.json` (display_manager -> simclient -> MQTT) +- `src/screenshots/meta.json` and `src/screenshots/latest.jpg` -### Key Environment Variables -```bash -# Environment -ENV=development|production -DEBUG_MODE=1|0 -LOG_LEVEL=DEBUG|INFO|WARNING|ERROR +## TV Power Coordination Rules -# MQTT Configuration -MQTT_BROKER=192.168.1.100 # Primary MQTT broker -MQTT_PORT=1883 # MQTT port -MQTT_BROKER_FALLBACKS=host1,host2 # Fallback brokers +- `POWER_CONTROL_MODE` supports: `local`, `hybrid`, `mqtt`. +- Phase 1 intent topic is group-scoped: `infoscreen/groups/{group_id}/power/intent`. +- In hybrid mode, valid fresh MQTT intent is preferred with local fallback behavior. +- Retained clear is an empty payload and should be handled cleanly (not as broken JSON). +- Use `scripts/test-power-intent.sh` for ON/OFF, stale, malformed, retained-clear, and telemetry checks. -# Timing (seconds) -HEARTBEAT_INTERVAL=10 # Status update frequency -SCREENSHOT_INTERVAL=30 # Dashboard screenshot transmission frequency (simclient.py) -SCREENSHOT_CAPTURE_INTERVAL=30 # Screenshot capture frequency (display_manager.py) +## HDMI-CEC Rules -# Screenshot Configuration -SCREENSHOT_MAX_WIDTH=800 # Downscale width (preserves aspect ratio) -SCREENSHOT_JPEG_QUALITY=70 # JPEG compression quality (1-95) -SCREENSHOT_MAX_FILES=20 # Number of screenshots to keep (rotation) -SCREENSHOT_ALWAYS=0 # Force capture even when no display active (testing) - -# File/API Server (used to download presentation files) -# Defaults to the same host as MQTT_BROKER, port 8000, scheme http. -# If incoming event URLs use host 'server' (or are host-less), simclient rewrites them to this server. -FILE_SERVER_HOST= # optional; if empty, defaults to MQTT_BROKER -FILE_SERVER_PORT=8000 # default API port -FILE_SERVER_SCHEME=http # http or https -# FILE_SERVER_BASE_URL= # optional full override, e.g., http://192.168.1.100:8000 -``` +- In `ENV=development`, display manager automatically disables CEC. +- `scripts/test-hdmi-cec.sh` integration path respects development mode; manual CEC options still work. +- Keep delayed turn-off behavior safe across adjacent events. -### File Server URL Resolution -- The MQTT client (`simclient.py`) downloads presentation files listed in events. -- To avoid DNS issues when event URLs use `http://server:8000/...`, the client normalizes such URLs to the configured file server. -- By default, the file server host is the same as `MQTT_BROKER`, with port `8000` and scheme `http`. -- You can override behavior using `.env` variables above; `FILE_SERVER_BASE_URL` takes precedence over individual host/port/scheme. -- Inline comments in `.env` are supported; keep comments after a space and `#` so values stay clean. +## Screenshot System Rules -## Development Patterns & Best Practices +- Capture is performed by `display_manager.py`; transmission by `simclient.py`. +- Keep event-trigger screenshot behavior intact (`event_start` / `event_stop`). +- Maintain one-second responsiveness for triggered send handling. +- Prefer `latest.jpg` for dashboard transmission, with safe fallback to newest timestamped file. -### Error Handling -- Robust MQTT connection with fallbacks and retries -- Graceful degradation when services unavailable -- Comprehensive logging with rotating file handlers -- Exception handling for all external operations +## Common Task Pointers -### State Management -- Event state persisted in `current_event.json` -- Client configuration persisted across restarts -- Group membership maintained with server synchronization -- Clean state transitions (delete old events on group changes) +- Add event type: `src/display_manager.py` -> `start_display_for_event()` +- Presentation behavior: `src/display_manager.py` -> `start_presentation()` +- Power intent validation: `src/simclient.py` -> `validate_power_intent_payload()` +- Power intent application: `src/display_manager.py` -> `_apply_mqtt_power_intent()` +- Screenshot capture logic: `src/display_manager.py` -> `_capture_screenshot()` +- Dashboard payload: `src/simclient.py` -> `_build_dashboard_payload()` +- File URL rewriting: `src/simclient.py` -> `resolve_file_url()` -### Threading Architecture -- Main thread: MQTT communication and heartbeat -- Background thread: Screenshot monitoring service -- Thread-safe operations for shared resources +## Documentation Policy -### File Operations -- Automatic directory creation for all output paths -- Safe file operations with proper exception handling -- Atomic writes for configuration files -- Automatic cleanup of temporary/outdated files +When updating docs: -## Development Workflow +- Keep `README.md` concise and link-heavy. +- Put rollout/runbook content into specialist docs (for example `TV_POWER_RUNBOOK.md`). +- Keep implementation history in `CHANGELOG.md`. +- Prefer updating one canonical doc per topic instead of duplicating the same content in multiple files. -### Local Development Setup -1. Clone repository to `~/infoscreen-dev` -2. Create virtual environment: `python3 -m venv venv` -3. Install dependencies: `pip install -r src/requirements.txt` (includes pygame + pillow for PDF slideshows) -4. Configure `.env` file with MQTT broker settings -5. Use `./scripts/start-dev.sh` for MQTT client or `./scripts/start-display-manager.sh` for display manager -6. **Important**: Virtual environment must include pygame and pillow for PDF auto-advance to work +## Assistant Workflow Expectations -### Testing Components -- `./scripts/test-mqtt.sh` - MQTT connectivity -- `./scripts/test-screenshot.sh` - Screenshot capture -- `./scripts/test-display-manager.sh` - Interactive testing menu -- `./scripts/test-impressive.sh` - Test auto-quit presentation mode -- `./scripts/test-impressive-loop.sh` - Test loop presentation mode -- `./scripts/test-utc-timestamps.sh` - Event timing validation -- Manual event testing via mosquitto_pub or test-display-manager.sh - -### Production Deployment -- Docker containerization available (`docker-compose.production.yml`) -- Systemd service integration for auto-start -- Resource limits and health checks configured -- Persistent volume mounts for data -### System Dependencies -- Python 3.x runtime + virtual environment -- MQTT broker connectivity -- Display server: X11 or Wayland (for screenshots) -- **Impressive** - PDF presenter (primary tool, requires pygame + pillow in venv) -- **Chromium/Chrome** - Web kiosk mode -- **VLC** - Video playback (python-vlc preferred, vlc binary fallback) -- **Screenshot tools**: - - X11: `scrot` or `import` (ImageMagick) or `xwd`+`convert` - - Wayland: `grim` or `gnome-screenshot` or `spectacle` - -**Note:** LibreOffice is NOT required on the client. PPTX→PDF conversion is handled server-side by Gotenberg. - -### Video Playback (python-vlc) -- **Preferred**: python-vlc (programmatic control: autoplay, loop, volume) -- **Fallback**: External vlc binary -- **Fields**: `url`, `autoplay` (bool), `loop` (bool), `volume` (0.0-1.0 → 0-100) -- **URL rewriting**: `server` host → configured file server -- **Fullscreen**: enforced for python-vlc on startup (with short retry toggles); external fallback uses `--fullscreen` -- **External VLC audio**: `muted=true` (or effective volume 0%) starts with `--no-audio`; otherwise startup loudness is applied via `--gain=<0.00-1.00>` -- **Runtime volume semantics**: python-vlc supports live updates; external VLC fallback is startup-parameter based -- **Monitoring PID semantics**: python-vlc runs in-process, so PID is `display_manager.py` runtime PID; external fallback uses external `vlc` PID -- **HW decode errors**: `h264_v4l2m2m` failures are normal if V4L2 M2M unavailable; use software decode -- Robust payload parsing with fallbacks -- Topic-specific message handlers -- Retained message support where appropriate - -### Logging & Timestamp Policy (Mar 2026) -- Client logs are standardized to UTC with `Z` suffix to avoid DST/localtime drift. -- Applies to `display_manager.log`, `simclient.log`, and `monitoring.log`. -- MQTT payload timestamps for heartbeat/dashboard/health/log messages are UTC ISO timestamps. -- Screenshot metadata timestamps included by `simclient.py` are UTC ISO timestamps. -- Prefer UTC-aware calls (`datetime.now(timezone.utc)`) and UTC log formatters for new code. - -## Hardware Considerations - -### Target Platform -- **Primary**: Raspberry Pi 4/5 with desktop environment -- **Storage**: SSD recommended for performance -- **Display**: HDMI output for presentation display -- **Network**: WiFi or Ethernet connectivity required - -### System Dependencies -- Python 3.x runtime -- Network connectivity for MQTT -- Display server (X11 or Wayland) for screenshot capture -- **Impressive** - PDF presenter with auto-advance (primary presentation tool) -- **pygame** - Required for Impressive (installed in venv) -- **Pillow/PIL** - Required for Impressive PDF rendering (installed in venv) -- Chromium/Chrome - Web content display (kiosk mode) -- VLC or MPV - Video playback - -**Note:** LibreOffice is NOT needed on the client. The server converts PPTX to PDF using Gotenberg. - -### Video playback details (python-vlc) - -- The Display Manager now prefers using python-vlc (libvlc) when available for video playback. This enables programmatic control (autoplay, loop, volume) and cleaner termination/cleanup. If python-vlc is not available, the external `vlc` binary is used as a fallback. -- Supported video event fields: `url`, `autoplay` (boolean), `loop` (boolean), `volume` (float 0.0-1.0). The manager converts `volume` to VLC's 0-100 scale. -- External VLC fallback applies audio at startup: `--no-audio` when muted/effective 0%, otherwise `--gain` from effective volume. -- Live volume adjustments are reliable in python-vlc mode; external VLC fallback uses startup parameters and should be treated as static per launch. -- URLs using the placeholder host `server` (for example `http://server:8000/...`) are rewritten to the configured file server before playback. The resolution priority is: `FILE_SERVER_BASE_URL` > `FILE_SERVER_HOST` (or `MQTT_BROKER`) + `FILE_SERVER_PORT` + `FILE_SERVER_SCHEME`. -- Hardware-accelerated decoding errors (e.g., `h264_v4l2m2m`) may appear when the platform does not expose a V4L2 M2M device. To avoid these errors the Display Manager can be configured to disable hw-decoding (see README env var `VLC_HW_ACCEL`). By default the manager will attempt hw-acceleration when libvlc supports it. -- Fullscreen / kiosk: the manager will attempt to make libVLC windows fullscreen (remove decorations) when using python-vlc, and the README contains recommended system-level kiosk/session setup for a truly panel-free fullscreen experience. - - -## Security & Privacy - -### Data Protection -- Hardware identification via cryptographic hash -- No sensitive data in plain text logs -- Local storage of minimal required data only -- Secure MQTT communication (configurable) - -### Network Security -- Configurable MQTT authentication (if broker requires) -- Firewall-friendly design (outbound connections only) -- Multiple broker fallback for reliability - -## Presentation System Architecture - -### How It Works -1. **Server-side Conversion** → Server converts PPTX to PDF using Gotenberg -2. **Event Received** → Client receives event with pre-rendered PDF file reference -3. **Download PDF** → Client downloads PDF from file server -4. **Cache PDF** → Downloaded PDF stored in `presentation/` directory -5. **Display with Impressive** → Launch with venv environment and parameters: - - `--fullscreen` - Full screen mode - - `--nooverview` - No slide overview - - `--auto N` - Auto-advance every N seconds - - `--wrap` - Loop infinitely (if `loop: true`) - - `--autoquit` - Exit after last slide (if `loop: false`) - -### Key Parameters -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `auto_advance` | boolean | `false` | Enable automatic slide advancement | -| `slide_interval` | integer | `10` | Seconds between slides | -| `loop` | boolean | `false` | Loop presentation vs. quit after last slide | - -### Why Impressive? -- ✅ **Native auto-advance** - No xdotool or window management hacks -- ✅ **Built-in loop support** - Reliable `--wrap` parameter -- ✅ **Works on Raspberry Pi** - No focus/window issues -- ✅ **Simple integration** - Clean command-line interface -- ✅ **Maintainable** - ~50 lines of code vs. 200+ with xdotool approaches - -### Implementation Location -- **File**: `src/display_manager.py` -- **Method**: `start_presentation()` -- **Key Logic**: - 1. Receive event with PDF file reference (server already converted PPTX) - 2. Download PDF file if not cached - 3. Set up virtual environment for Impressive (pygame + pillow) - 4. Build Impressive command with appropriate parameters - 5. Launch process and monitor - -## Common Development Tasks - -When working on this codebase: - -1. **Adding new event types**: Extend the event processing logic in `display_manager.py` → `start_display_for_event()` -2. **Modifying presentation behavior**: Update `display_manager.py` → `start_presentation()` -3. **Configuration changes**: Update environment variable parsing and validation -4. **MQTT topics**: Follow the established `infoscreen/` namespace pattern -5. **Error handling**: Always include comprehensive logging and graceful fallbacks -6. **State persistence**: Use the established `config/` directory pattern -7. **Testing**: Use `./scripts/test-display-manager.sh` for interactive testing -8. **Presentation testing**: Use `./scripts/test-impressive*.sh` scripts -9. **File download host resolution**: If the API server differs from the MQTT broker or uses HTTPS, set `FILE_SERVER_*` in `.env` or adjust `resolve_file_url()` in `src/simclient.py`. - -## Troubleshooting Guidelines - -### Common Issues -- **MQTT Connection**: Check broker reachability, try fallback brokers -- **Screenshots**: Verify display environment and permissions -- **File Downloads**: Check network connectivity and disk space - - If event URLs use host `server` and DNS fails, the client rewrites to `MQTT_BROKER` by default. - - Ensure `MQTT_BROKER` points to the correct server IP; if the API differs, set `FILE_SERVER_HOST` or `FILE_SERVER_BASE_URL`. - - Match scheme/port via `FILE_SERVER_SCHEME`/`FILE_SERVER_PORT` for HTTPS or non-default ports. -- **Group Changes**: Monitor log for group assignment messages -- **Service Startup**: Check systemd logs and environment configuration - -### Debugging Tools -- Log files in `logs/simclient.log` and `logs/display_manager.log` with rotation -- MQTT message monitoring with mosquitto_sub -- Interactive testing menu: `./scripts/test-display-manager.sh` -- Component test scripts: `test-impressive*.sh`, `test-mqtt.sh`, etc. -- Process monitoring: Check for `impressive`, `libreoffice`, `chromium`, `vlc` processes - -### File download URL troubleshooting -- Symptoms: - - `Failed to resolve 'server'` or `NameResolutionError` when downloading files - - `Invalid URL 'http # http or https://...'` in `simclient.log` -- What to check: - - Look for lines like `Lade Datei herunter von:` in `logs/simclient.log` to see the effective URL used - - Ensure the URL host is the MQTT broker IP (or your configured file server), not `server` - - Verify `.env` values don’t include inline comments as part of the value (e.g., keep `FILE_SERVER_SCHEME=http` on its own line) -- Fixes: - - If your API is on the same host as the broker: leave `FILE_SERVER_HOST` empty (defaults to `MQTT_BROKER`), keep `FILE_SERVER_PORT=8000`, and set `FILE_SERVER_SCHEME=http` or `https` - - To override fully, set `FILE_SERVER_BASE_URL` (e.g., `http://192.168.1.100:8000`); this takes precedence - - After changing `.env`, restart the simclient process -- Expected healthy log sequence: - - `Lade Datei herunter von: http://:8000/...` - - Followed by `"GET /... HTTP/1.1" 200` and `Datei erfolgreich heruntergeladen:` - -### Screenshot MQTT Transmission Issue (Event-Start/Event-Stop) -- **Symptom**: Event-triggered screenshots (event_start, event_stop) are NOT appearing on the dashboard, only periodic screenshots transmitted -- **Root Cause**: Race condition in metadata/file-pointer handling where periodic captures can overwrite event-triggered metadata or `latest.jpg` before simclient processes it (See SCREENSHOT_MQTT_FIX.md for details) -- **What to check**: - - Display manager logs show event_start/event_stop screenshots ARE being captured: `Screenshot captured: ... type=event_start` - - But `meta.json` is stale or `latest.jpg` does not move - - MQTT heartbeats lack screenshot data at event transitions -- **How to verify the fix**: - - Run: `./test-screenshot-meta-fix.sh` should output `[SUCCESS] Event-triggered metadata preserved!` - - Check display_manager.py: `_write_screenshot_meta()` has protection logic to skip periodic overwrites of event metadata - - Check display_manager.py: periodic `latest.jpg` updates are also protected when triggered metadata is pending - - Check simclient.py: `screenshot_service_thread()` logs show pending event-triggered captures being processed immediately -- **Permanent Fix**: Already applied in display_manager.py and simclient.py. Prevents periodic captures from overwriting pending trigger state and includes stale-trigger self-healing. - -### Screenshot Capture After Restart (No Active Event) -- In `ENV=development`, display_manager performs periodic idle captures so dashboard does not appear dead during no-event windows. -- In `ENV=production`, periodic captures remain event/process-driven unless `SCREENSHOT_ALWAYS=1`. -- If display_manager is started from non-interactive shells (systemd/nohup/ssh), it now attempts `DISPLAY=:0` and `XAUTHORITY=~/.Xauthority` fallback for X11 capture tools. - -## Important Notes for AI Assistants - -### Virtual Environment Requirements (Critical) -- **pygame and pillow MUST be installed in venv** - Required for Impressive to work -- **Display manager uses venv context** - Ensures Impressive has access to dependencies -- **Installation command**: `pip install pygame pillow` (already in requirements.txt) -- **If pygame missing**: Impressive will fail with "No module named 'pygame'" error - -### Presentation System -- **ALWAYS use Impressive** for PDF presentations (primary solution) -- **DO NOT suggest xdotool approaches** - they failed on Raspberry Pi due to window focus issues -- **DO NOT suggest video conversion** - adds complexity, had black screen issues -- **All presentations are PDFs** - server converts PPTX to PDF using Gotenberg -- **No client-side conversion** - client only displays pre-rendered PDFs -- **Virtual environment is required** - pygame + pillow must be available for Impressive -- **Loop mode uses `--wrap`** - not custom scripts or workarounds -- **Auto-quit uses `--autoquit`** - native Impressive parameter - -### Testing Approach -- Use `./scripts/test-display-manager.sh` for interactive testing -- Use `./scripts/test-impressive-loop.sh` to verify loop functionality -- Test individual components with specific test scripts -- Always check logs in `logs/` directory for debugging - -CEC testing notes: -- In development mode, the CEC integration path is skipped on purpose. To test end-to-end, either set `ENV=production` temporarily or use the manual options (1–4) in `scripts/test-hdmi-cec.sh`. - -### Code Changes -- Display logic is in `src/display_manager.py`, not `simclient.py` -- MQTT client (`simclient.py`) writes events to `current_event.json` -- Display Manager reads `current_event.json` and launches appropriate applications -- Two separate processes: simclient.py (MQTT) + display_manager.py (display control) - -### Documentation -- **README.md** - Start here for comprehensive overview -- **IMPRESSIVE_INTEGRATION.md** - Deep dive into presentation system -- **QUICK_REFERENCE.md** - Quick commands and examples -- Source code has extensive comments and logging - -This system is designed for reliability and ease of maintenance in educational environments with multiple deployed clients. The Impressive-based presentation solution provides native auto-advance and loop support without complex window management hacks. - -## Screenshot System (Nov 2025) - -The screenshot capture and transmission system has been implemented with separation of concerns: - -### Architecture -- **Capture**: `display_manager.py` captures screenshots in a background thread and writes to shared `screenshots/` directory -- **Transmission**: `simclient.py` reads latest screenshot from shared directory and transmits via MQTT dashboard topic -- **Sharing**: Volume-based sharing between display_manager (host OS) and simclient (container) - -### Capture Strategy (display_manager.py) -- **Session Detection**: Automatically detects Wayland vs X11 session -- **Wayland Tools**: Tries `grim`, `gnome-screenshot`, `spectacle` (in order) -- **X11 Tools**: Tries `scrot`, `import` (ImageMagick), `xwd`+`convert` (in order) -- **Processing**: Downscales to max width (default 800px), JPEG compresses (default quality 70) -- **Output**: Creates timestamped files (`screenshot_YYYYMMDD_HHMMSS.jpg`) plus `latest.jpg` symlink -- **Rotation**: Keeps max N files (default 20), deletes older -- **Timing**: Production captures when display process is active (unless `SCREENSHOT_ALWAYS=1`); development allows periodic idle captures to keep dashboard fresh -- **Reliability**: Stale/invalid pending trigger metadata is ignored automatically to avoid lock-up of periodic updates -- **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= screenshot= () age=` - -### 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 \ No newline at end of file +- Prefer minimal, targeted changes. +- Preserve existing behavior unless explicitly changing it. +- Validate changes with relevant scripts/log checks where possible. +- Keep references and examples aligned with current files and topics. diff --git a/.gitignore b/.gitignore index 990b1f6..22adfc3 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,5 @@ logs/ src/pi-dev-setup-new.sh src/current_process_health.json +src/power_intent_state.json +src/power_state.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e10f8d5 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/HDMI_CEC_DEV_MODE.md b/HDMI_CEC_DEV_MODE.md index 1816eb5..a995129 100644 --- a/HDMI_CEC_DEV_MODE.md +++ b/HDMI_CEC_DEV_MODE.md @@ -1,5 +1,7 @@ # HDMI-CEC Development Mode Behavior +This is a focused reference for development-mode behavior. For the canonical HDMI-CEC setup and operator guide, use [HDMI_CEC_SETUP.md](HDMI_CEC_SETUP.md). + ## Overview HDMI-CEC TV control is **automatically disabled** in development mode to prevent constantly switching the TV on/off during testing and development work. @@ -152,7 +154,7 @@ vim .env # Set ENV=production - **Implementation**: `src/display_manager.py` (lines 48-76) - **Configuration**: `.env` (CEC section) - **Testing**: `scripts/test-hdmi-cec.sh`, `scripts/test-tv-response.sh` -- **Documentation**: `HDMI_CEC_SETUP.md`, `HDMI_CEC_IMPLEMENTATION.md` +- **Documentation**: `HDMI_CEC_SETUP.md`, `HDMI_CEC_FLOW_DIAGRAM.md` ## Summary diff --git a/HDMI_CEC_IMPLEMENTATION.md b/HDMI_CEC_IMPLEMENTATION.md deleted file mode 100644 index 81f8c60..0000000 --- a/HDMI_CEC_IMPLEMENTATION.md +++ /dev/null @@ -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. diff --git a/HDMI_CEC_SETUP.md b/HDMI_CEC_SETUP.md index 289ce83..399bd61 100644 --- a/HDMI_CEC_SETUP.md +++ b/HDMI_CEC_SETUP.md @@ -1,5 +1,12 @@ # HDMI-CEC Setup and Configuration +This is the canonical HDMI-CEC operator document. + +Related reference material: + +- [HDMI_CEC_DEV_MODE.md](HDMI_CEC_DEV_MODE.md): development-mode behavior. +- [HDMI_CEC_FLOW_DIAGRAM.md](HDMI_CEC_FLOW_DIAGRAM.md): flow and sequence diagrams. + ## Overview The Infoscreen Client now includes automatic TV control via HDMI-CEC (Consumer Electronics Control). This allows the Raspberry Pi to turn the connected TV on/off automatically based on event scheduling. diff --git a/HDMI_CEC_SUMMARY.md b/HDMI_CEC_SUMMARY.md deleted file mode 100644 index 93c4c00..0000000 --- a/HDMI_CEC_SUMMARY.md +++ /dev/null @@ -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 diff --git a/README.md b/README.md index 29448ab..e112c41 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,29 @@ -# Infoscreen Client - Display Manager +# Infoscreen Client -Digital signage system for Raspberry Pi that displays presentations, videos, and web content in kiosk mode. Centrally managed via MQTT with automatic client discovery, heartbeat monitoring, and screenshot-based dashboard monitoring. +Digital signage client for Raspberry Pi that displays presentations, videos, and web content in kiosk mode. It is managed centrally via MQTT and includes HDMI-CEC TV control, screenshot-based dashboard monitoring, and process health reporting. -## 🎯 Key Features +Dashboard screenshots can contain visible on-screen content. Keep that in mind when enabling or documenting remote monitoring. -- **Automatic Presentation Display** - Server renders PPTX to PDF; client displays PDFs with Impressive -- **Auto-Advance Slideshows** - Configurable timing for automatic slide progression -- **Loop Mode** - Presentations can loop infinitely or quit after last slide -- **HDMI-CEC TV Control** - Automatic TV power on/off based on event scheduling -- **MQTT Integration** - Real-time event management from central server -- **Group Management** - Organize clients into groups for targeted content -- **Heartbeat Monitoring** - Regular status updates and screenshot dashboard -- **Client Process Monitoring** - Health-state bridge, crash/restart tracking, and monitoring log -- **Screenshot Dashboard** - Automatic screen capture with Wayland/X11 support, client-side compression -- **Multi-Content Support** - Presentations, videos, and web pages -- **Kiosk Mode** - Full-screen display with automatic startup +## Key Features -## 📋 System Requirements +- Server-side PPTX to PDF rendering; client displays PDFs with Impressive. +- Presentation auto-advance, loop mode, and progress indicators. +- Video playback with `python-vlc` when available and external VLC fallback. +- Web and WebUntis display in kiosk mode. +- HDMI-CEC TV power control with local fallback and MQTT-coordinated power intent. +- MQTT discovery, heartbeat, group assignment, and event delivery. +- Screenshot dashboard with Wayland/X11 capture tool fallbacks. +- Process health bridge between `display_manager.py` and `simclient.py`. -### Hardware -- Raspberry Pi 4/5 (or compatible) -- HDMI display -- Network connectivity (WiFi or Ethernet) -- SSD storage recommended +## Quick Start -### Software -- Raspberry Pi OS (Bookworm or newer) -- Python 3.x -- Impressive (PDF presenter with auto-advance) -- Chromium browser (for web content) -- VLC or MPV (for video playback) -- Screenshot tools: `scrot` or ImageMagick (X11) OR `grim` or `gnome-screenshot` (Wayland) -- CEC Utils (for HDMI-CEC TV control - optional) - -## 🚀 Quick Start - -### 1. Installation +### 1. Install Dependencies ```bash -# Clone repository cd ~/ git clone infoscreen-dev cd infoscreen-dev -# Install system dependencies + sudo apt-get update sudo apt-get install -y \ python3 python3-pip python3-venv \ @@ -51,729 +32,204 @@ sudo apt-get install -y \ cec-utils \ scrot imagemagick -# For Wayland systems, install screenshot tools: +# For Wayland systems: # sudo apt-get install grim gnome-screenshot -# Create Python virtual environment python3 -m venv venv source venv/bin/activate - -# Install Python dependencies pip install -r src/requirements.txt ``` -### 2. Configuration +### 2. Configure `.env` -Create `.env` file in project root (or copy from `.env.template`): +Copy `.env.template` to `.env` and set at least: ```bash -# Screenshot capture behavior -SCREENSHOT_ALWAYS=0 # Set to 1 for testing (forces capture even without active display) +ENV=production +DEBUG_MODE=0 +LOG_LEVEL=INFO -# Environment -ENV=production # development | production (CEC disabled in development) -DEBUG_MODE=0 # 1 to enable debug mode -LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR - -# MQTT Configuration -MQTT_BROKER=192.168.1.100 # Your MQTT broker IP/hostname +MQTT_BROKER=192.168.1.100 MQTT_PORT=1883 -# Timing (seconds) -HEARTBEAT_INTERVAL=60 # How often client sends status updates -SCREENSHOT_INTERVAL=180 # How often simclient transmits screenshots -SCREENSHOT_CAPTURE_INTERVAL=180 # How often display_manager captures screenshots -DISPLAY_CHECK_INTERVAL=15 # How often display_manager checks for new events +HEARTBEAT_INTERVAL=60 +SCREENSHOT_INTERVAL=180 +SCREENSHOT_CAPTURE_INTERVAL=180 +DISPLAY_CHECK_INTERVAL=15 -# File/API Server (used to download presentation files) -# Defaults to MQTT_BROKER host with port 8000 and http scheme -FILE_SERVER_HOST= # Optional; if empty, defaults to MQTT_BROKER -FILE_SERVER_PORT=8000 # Default API port -FILE_SERVER_SCHEME=http # http or https -# FILE_SERVER_BASE_URL= # Optional full override, e.g., http://192.168.1.100:8000 +FILE_SERVER_HOST= +FILE_SERVER_PORT=8000 +FILE_SERVER_SCHEME=http -# HDMI-CEC TV Control (optional) -CEC_ENABLED=true # Enable automatic TV power control -CEC_DEVICE=0 # Target device (0 recommended for TV) -CEC_TURN_OFF_DELAY=30 # Seconds to wait before turning off TV -CEC_POWER_ON_WAIT=5 # Seconds to wait after power ON (for TV boot) -CEC_POWER_OFF_WAIT=5 # Seconds to wait after power OFF +CEC_ENABLED=true +CEC_DEVICE=0 +CEC_TURN_OFF_DELAY=30 +CEC_POWER_ON_WAIT=5 +CEC_POWER_OFF_WAIT=5 +POWER_CONTROL_MODE=local ``` +Mode summary: + +- `POWER_CONTROL_MODE=local`: local event-time CEC only. +- `POWER_CONTROL_MODE=hybrid`: prefer fresh MQTT intent, fallback to local timing. +- `POWER_CONTROL_MODE=mqtt`: MQTT intent authoritative, with safe fallback behavior. + ### 3. Start Services ```bash -# Start MQTT client (handles events, heartbeat, discovery) cd ~/infoscreen-dev/src python3 simclient.py +``` -# In another terminal: Start Display Manager +In a second terminal: + +```bash cd ~/infoscreen-dev/src python3 display_manager.py ``` -Or use the startup script: +Or use the helper script: + ```bash ./scripts/start-display-manager.sh ``` -## 📊 Presentation System - -### How It Works - -The system uses **Impressive** as the PDF presenter with native auto-advance and loop support: - -1. **Server-side rendering**: PPTX files are converted to PDF by the server using Gotenberg -2. **Client receives PDFs**: Events contain pre-rendered PDF files ready for display -3. **Direct display**: PDF files are displayed directly with Impressive (no client-side conversion needed) -4. **Auto-advance** uses Impressive's built-in `--auto` parameter -5. **Loop mode** uses Impressive's `--wrap` parameter (infinite loop) -6. **Auto-quit** uses Impressive's `--autoquit` parameter (exit after last slide) - -### Event JSON Format - -#### Looping Presentation (Typical for Events) -```json -{ - "id": "event_123", - "start": "2025-10-01 14:00:00", - "end": "2025-10-01 16:00:00", - "presentation": { - "files": [ - { - "name": "slides.pptx", - "url": "https://server/files/slides.pptx" - } - ], - "auto_advance": true, - "slide_interval": 10, - "loop": true - } -} -``` - -**Result:** Slides advance every 10 seconds, presentation loops infinitely until event ends. - -#### Single Playthrough -```json -{ - "presentation": { - "files": [{"name": "welcome.pptx"}], - "auto_advance": true, - "slide_interval": 5, - "loop": false - } -} -``` - -**Result:** Slides advance every 5 seconds, exits after last slide. - -### Presentation Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `auto_advance` | boolean | `false` | Enable automatic slide advancement | -| `slide_interval` | integer | `10` | Seconds between slides | -| `loop` | boolean | `false` | Loop presentation vs. quit after last slide | - -### Scheduler-Specific Fields - -The scheduler may send additional fields that are preserved in `current_event.json`: - -| Field | Type | Description | -|-------|------|-------------| -| `page_progress` | boolean | Show overall progress bar in presentation (Impressive `--page-progress`). Can be provided at `presentation.page_progress` (preferred) or top-level. | -| `auto_progress` | boolean | Show per-page auto-advance countdown (Impressive `--auto-progress`). Can be provided at `presentation.auto_progress` (preferred) or top-level. | -| `occurrence_of_id` | integer | Original event ID for recurring events | -| `recurrence_rule` | string | iCal recurrence rule (RRULE format) | -| `recurrence_end` | string | End date for recurring events | - -**Note:** All fields from the scheduler are automatically preserved when events are stored in `current_event.json`. The client does not filter or modify scheduler-specific metadata. - -#### Progress Bar Display - -When using Impressive PDF presenter: -- `page_progress: true` - Shows a progress bar at the bottom indicating position in the presentation -- `auto_progress: true` - Shows a countdown progress bar for each slide during auto-advance -- Both options can be enabled simultaneously for maximum visual feedback - -## 🎥 Video Events - -```json -{ - "video": { - "url": "https://server/videos/intro.mp4", - "loop": true, - "autoplay": true, - "volume": 0.8 - } -} -``` - -Notes: -- The Display Manager prefers `python-vlc` (libvlc) when available. This gives programmatic control over playback (autoplay, loop, volume) and ensures the player is cleanly stopped and released when events end. -- Supported video event fields: - - `url` (string): HTTP/HTTPS or streaming URL. URLs using the placeholder host `server` are rewritten to the configured file server (see File/API Server configuration). - - `autoplay` (boolean): start playback automatically when the event becomes active (default: true). - - `loop` (boolean): loop playback indefinitely. - - `volume` (float): 0.0–1.0 (mapped internally to VLC's 0–100 volume scale). -- Effective playback volume is calculated as `event.video.volume * client_config.audio.video_volume_multiplier` and then mapped to VLC's 0–100 scale. Example: `volume: 0.8` with `audio.video_volume_multiplier: 0.5` results in 40% VLC volume. -- If `python-vlc` is not installed, the Display Manager will fall back to launching the external `vlc` binary. -- External VLC audio rendering behavior: - - When `muted: true` (or effective volume resolves to 0), fallback starts VLC with `--no-audio`. - - When not muted, fallback applies startup loudness with `--gain=<0.00-1.00>` derived from effective volume. - - Runtime volume updates are best-effort in `python-vlc` mode; external VLC fallback is startup-parameter based. -- HDMI-CEC remains the recommended mechanism for TV power control only. TV volume via CEC is not implemented because support is device-dependent and much less reliable than controlling VLC directly. -- The client-wide multiplier is intended to be sent over the existing MQTT config topic `infoscreen/{client_id}/config` and is persisted locally in `src/config/client_settings.json` for the Display Manager. -- Fullscreen behavior: - - External VLC fallback uses `--fullscreen`. - - `python-vlc` mode enforces fullscreen on startup and retries fullscreen toggling briefly because video outputs may attach asynchronously. - - For a truly panel-free fullscreen (no taskbar), run the Display Manager inside a minimal kiosk X session or a dedicated user session without a desktop panel. -- Monitoring PID behavior: - - External VLC fallback reports the external `vlc` process PID. - - `python-vlc` mode is in-process, so monitoring reports the `display_manager.py` runtime PID. - -## 🌐 Web Events - -```json -{ - "web": { - "url": "https://dashboard.example.com" - } -} -``` - -Opens webpage in Chromium kiosk mode (fullscreen, no UI). - -## 🗂️ Project Structure - -``` -infoscreen-dev/ -├── .env # Environment configuration -├── README.md # This file -├── IMPRESSIVE_INTEGRATION.md # Detailed presentation system docs -├── src/ -│ ├── simclient.py # MQTT client (events, heartbeat, discovery) -│ ├── display_manager.py # Display controller (manages applications) -│ ├── requirements.txt # Python dependencies -│ ├── current_event.json # Current active event (runtime) -│ ├── config/ # Persistent client data -│ │ ├── client_uuid.txt -│ │ └── last_group_id.txt -│ ├── presentation/ # Downloaded presentation files -│ └── screenshots/ # Dashboard screenshots -├── scripts/ -│ ├── start-dev.sh # Start development client -│ ├── start-display-manager.sh # Start Display Manager -│ ├── test-display-manager.sh # Interactive testing -│ ├── test-impressive.sh # Test Impressive (auto-quit mode) -│ ├── test-impressive-loop.sh # Test Impressive (loop mode) -│ ├── test-mqtt.sh # Test MQTT connectivity -│ ├── test-screenshot.sh # Test screenshot capture -│ ├── test-utc-timestamps.sh # Test event timing -│ └── present-pdf-auto-advance.sh # PDF presentation wrapper -└── logs/ # Application logs -``` - -## 🧪 Testing - -### Test Display Manager - -```bash -./scripts/test-display-manager.sh -``` - -Interactive menu for testing: -- Check Display Manager status -- Create test events (presentation, video, webpage) -- View active processes -- Cycle through different event types - -### Test Impressive Presentation - -**Single playthrough (auto-quit):** -```bash -./scripts/test-impressive.sh -``` - -**Loop mode (infinite):** -```bash -./scripts/test-impressive-loop.sh -``` - -### Test MQTT Connectivity - -```bash -./scripts/test-mqtt.sh -``` - -Verifies MQTT broker connectivity and topic access. - -### Test Screenshot Capture - -```bash -./scripts/test-screenshot.sh -``` - -Captures test screenshot for dashboard monitoring. - -**Manual test:** -```bash -export SCREENSHOT_ALWAYS=1 -export SCREENSHOT_CAPTURE_INTERVAL=5 -python3 src/display_manager.py & -sleep 15 -ls -lh src/screenshots/ -``` - -## 🔧 Configuration Details - -### Environment Variables - -All configuration is done via `.env` file in the project root. Copy `.env.template` to `.env` and adjust values for your environment. - -#### Environment -- `ENV` - Environment mode: `development` or `production` - - **Important:** CEC TV control is automatically disabled in `development` mode -- `DEBUG_MODE` - Enable debug output: `1` (on) or `0` (off) -- `LOG_LEVEL` - Logging verbosity: `DEBUG`, `INFO`, `WARNING`, or `ERROR` - -#### MQTT Configuration -- `MQTT_BROKER` - **Required.** MQTT broker IP address or hostname -- `MQTT_PORT` - MQTT broker port (default: `1883`) -- `MQTT_USERNAME` - Optional. MQTT authentication username (if broker requires it) -- `MQTT_PASSWORD` - Optional. MQTT authentication password (if broker requires it) - -#### Timing Configuration (seconds) -- `HEARTBEAT_INTERVAL` - How often client sends status updates to server (default: `60`) -- `SCREENSHOT_INTERVAL` - How often simclient transmits screenshots via MQTT (default: `180`) -- `SCREENSHOT_CAPTURE_INTERVAL` - How often display_manager captures screenshots (default: `180`) -- `DISPLAY_CHECK_INTERVAL` - How often display_manager checks for new events (default: `15`) - -#### Screenshot Configuration -- `SCREENSHOT_ALWAYS` - Force screenshot capture even when no display is active - - `0` - In production: capture only when a display process is active; in development: periodic idle captures are allowed so dashboard stays fresh - - `1` - Always capture screenshots (useful for testing) - -#### File/API Server Configuration -These settings control how the client downloads presentation files and other content. +## Runtime Model -- `FILE_SERVER_HOST` - Optional. File server hostname/IP. Defaults to `MQTT_BROKER` if empty -- `FILE_SERVER_PORT` - File server port (default: `8000`) -- `FILE_SERVER_SCHEME` - Protocol: `http` or `https` (default: `http`) -- `FILE_SERVER_BASE_URL` - Optional. Full base URL override (e.g., `http://192.168.1.100:8000`) - - When set, this takes precedence over HOST/PORT/SCHEME settings +The client runs as two cooperating processes: -#### HDMI-CEC TV Control (Optional) -Automatic TV power management based on event scheduling. +- `src/simclient.py`: MQTT communication, discovery, heartbeats, event ingestion, dashboard publishing, power intent intake. +- `src/display_manager.py`: display orchestration, HDMI-CEC, screenshots, local runtime health state. -- `CEC_ENABLED` - Enable automatic TV control: `true` or `false` - - **Note:** Automatically disabled when `ENV=development` to avoid TV cycling during testing -- `CEC_DEVICE` - Target CEC device address (recommended: `0` for TV) -- `CEC_TURN_OFF_DELAY` - Seconds to wait before turning off TV after last event ends (default: `30`) -- `CEC_POWER_ON_WAIT` - Seconds to wait after power ON command for TV to boot (default: `5`) -- `CEC_POWER_OFF_WAIT` - Seconds to wait after power OFF command (default: `5`, increase for slower TVs) +Important runtime files: -### File Server URL Resolution +- `src/current_event.json`: active event from scheduler. +- `src/current_process_health.json`: process health bridge for dashboard and monitoring. +- `src/power_intent_state.json`: latest validated MQTT power intent. +- `src/power_state.json`: last applied power action telemetry. +- `src/screenshots/`: shared screenshot directory. -The MQTT client ([src/simclient.py](src/simclient.py)) downloads presentation files and videos from the configured file server. +## Content Types -**URL Rewriting:** -- Event URLs using placeholder host `server` (e.g., `http://server:8000/...`) are automatically rewritten to the configured file server -- By default, file server = `MQTT_BROKER` host with port `8000` and `http` scheme -- Use `FILE_SERVER_BASE_URL` for complete override, or set individual HOST/PORT/SCHEME variables +### Presentations -**Best practices:** -- Keep inline comments in `.env` after a space and `#` to avoid parsing issues -- Match the scheme (`http`/`https`) to your actual server configuration -- For HTTPS or non-standard ports, explicitly set `FILE_SERVER_SCHEME` and `FILE_SERVER_PORT` +Presentations are rendered server-side to PDF and displayed with Impressive. Auto-advance, loop, page progress, and auto-progress are supported. -### MQTT Topics +See [IMPRESSIVE_INTEGRATION.md](IMPRESSIVE_INTEGRATION.md) for full behavior, event examples, and troubleshooting. -#### Client → Server -- `infoscreen/discovery` - Initial client announcement -- `infoscreen/{client_id}/heartbeat` - Regular status updates -- `infoscreen/{client_id}/dashboard` - 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 +### Videos -#### Server → Client -- `infoscreen/{client_id}/discovery_ack` - Server response with client ID -- `infoscreen/{client_id}/group_id` - Group assignment -- `infoscreen/events/{group_id}` - Event commands for group +Video events support: -### Client Identification +- `url` +- `autoplay` +- `loop` +- `volume` -**Hardware Token:** SHA256 hash of: -- CPU serial number -- MAC addresses (all network interfaces) +The Display Manager prefers `python-vlc`; if unavailable it falls back to the external VLC binary. -**Persistent UUID:** Stored in `src/config/client_uuid.txt` +### Web Pages -**Group Membership:** Stored in `src/config/last_group_id.txt` +Web and WebUntis events are displayed in Chromium kiosk mode. -## 🔍 Troubleshooting +## TV Power Intent -### Display Manager doesn't start presentations +Phase 1 TV power coordination uses the group topic: -**Check Impressive installation:** -```bash -which impressive -# If not found: sudo apt-get install impressive -``` +- `infoscreen/groups/{group_id}/power/intent` -**Check logs:** -```bash -tail -f logs/display_manager.log -``` +Key references: -**Check disk space:** -```bash -df -h -``` +- Frozen contract: [TV_POWER_INTENT_SERVER_CONTRACT_V1.md](TV_POWER_INTENT_SERVER_CONTRACT_V1.md) +- Rollout and canary testing: [TV_POWER_RUNBOOK.md](TV_POWER_RUNBOOK.md) +- Client implementation handoff: [TV_POWER_HANDOFF_CLIENT.md](TV_POWER_HANDOFF_CLIENT.md) -**Note:** PPTX conversion happens server-side via Gotenberg. The client only receives and displays pre-rendered PDF files. +## Testing -### Slides don't auto-advance +Use the helper scripts in `scripts/` for focused tests: -**Verify event JSON:** -- `auto_advance: true` is set -- `slide_interval` is specified (default: 10) +- `./scripts/test-display-manager.sh`: event and process testing. +- `./scripts/test-impressive.sh`: single-play presentation. +- `./scripts/test-impressive-loop.sh`: looping presentation. +- `./scripts/test-mqtt.sh`: MQTT broker connectivity. +- `./scripts/test-screenshot.sh`: screenshot capture. +- `./scripts/test-hdmi-cec.sh`: HDMI-CEC diagnostics and runtime state inspection. +- `./scripts/test-power-intent.sh`: MQTT power intent publishing, rejection tests, and telemetry checks. -**Test Impressive directly:** -```bash -./scripts/test-impressive.sh -``` +## Troubleshooting -### Presentation doesn't loop +Use the specialist docs instead of treating this file as the full troubleshooting manual: -**Verify event JSON:** -- `loop: true` is set +- Presentation and Impressive issues: [IMPRESSIVE_INTEGRATION.md](IMPRESSIVE_INTEGRATION.md) +- HDMI-CEC setup and TV control: [HDMI_CEC_SETUP.md](HDMI_CEC_SETUP.md) +- Screenshot race condition and metadata sync: [SCREENSHOT_MQTT_FIX.md](SCREENSHOT_MQTT_FIX.md) +- Monitoring and dashboard behavior: [CLIENT_MONITORING_SETUP.md](CLIENT_MONITORING_SETUP.md) +- Developer-oriented MQTT/event details: [src/README.md](src/README.md) -**Test loop mode:** -```bash -./scripts/test-impressive-loop.sh -``` +Quick checks: -### File downloads fail +- Follow logs: `tail -f logs/display_manager.log src/simclient.log` +- Inspect screenshots: `ls -lh src/screenshots/` +- Inspect power state: `cat src/power_intent_state.json` and `cat src/power_state.json` +- Restart both services: `./scripts/restart-all.sh` -Symptoms: -- `Failed to resolve 'server'` or `NameResolutionError` when downloading files -- `Invalid URL 'http # http or https://...'` in `logs/simclient.log` +## Deployment -What to check: -- Look for lines like `Lade Datei herunter von:` in `logs/simclient.log` to see the effective URL used -- Ensure the URL host is the MQTT broker IP (or your configured file server), not `server` -- Verify `.env` values don’t include inline comments as part of the value (e.g., keep `FILE_SERVER_SCHEME=http` on its own line) +For production you typically run both `simclient.py` and `display_manager.py` via systemd or Docker. -Fixes: -- If your API is on the same host as the broker: leave `FILE_SERVER_HOST` empty (defaults to `MQTT_BROKER`), keep `FILE_SERVER_PORT=8000`, and set `FILE_SERVER_SCHEME=http` or `https` -- To override fully, set `FILE_SERVER_BASE_URL` (e.g., `http://192.168.1.100:8000`); this takes precedence over host/port/scheme -- After changing `.env`, restart the simclient process +- Container setup: [src/CONTAINER_TRANSITION.md](src/CONTAINER_TRANSITION.md) +- Production compose file: [src/docker-compose.production.yml](src/docker-compose.production.yml) +- Display manager architecture: [src/DISPLAY_MANAGER.md](src/DISPLAY_MANAGER.md) -Expected healthy log sequence: -- `Lade Datei herunter von: http://:8000/...` -- Followed by `"GET /... HTTP/1.1" 200` and `Datei erfolgreich heruntergeladen:` +If running directly on the host, ensure: -### VLC hardware decode / renderer issues +- the display session is available (`DISPLAY` / `XAUTHORITY` for X11), +- the screenshot tools for your session type are installed, +- `ENV=production` is set when you want HDMI-CEC active. -If you see messages like: +## Documentation Map -``` -[h264_v4l2m2m @ ...] Could not find a valid device -[h264_v4l2m2m @ ...] can't configure decoder -[... ] avcodec decoder error: cannot start codec (h264_v4l2m2m) -``` +### Operator / Deployment -that indicates libVLC / ffmpeg attempted to use the platform V4L2 M2M hardware decoder but the kernel/device isn't available. Options to resolve: +- [QUICK_REFERENCE.md](QUICK_REFERENCE.md) +- [HDMI_CEC_SETUP.md](HDMI_CEC_SETUP.md) +- [TV_POWER_RUNBOOK.md](TV_POWER_RUNBOOK.md) +- [CLIENT_MONITORING_SETUP.md](CLIENT_MONITORING_SETUP.md) +- [CHANGELOG.md](CHANGELOG.md) -- Enable the V4L2 M2M codec driver on the system (platform-specific; on Raspberry Pi ensure correct kernel/firmware and codec modules are loaded). Check `v4l2-ctl --list-devices` and `ls /dev/video*` after installing `v4l-utils`. -- Disable hardware decoding so libVLC/ffmpeg uses software decoding (reliable but higher CPU). You can test this by launching the `vlc` binary with: +### Feature-Specific -```bash -vlc --avcodec-hw=none 'http://' -``` +- [IMPRESSIVE_INTEGRATION.md](IMPRESSIVE_INTEGRATION.md) +- [SCREENSHOT_MQTT_FIX.md](SCREENSHOT_MQTT_FIX.md) +- [SCHEDULER_FIELDS_SUPPORT.md](SCHEDULER_FIELDS_SUPPORT.md) +- [SERVER_VOLUME_CONTROL_SETUP.md](SERVER_VOLUME_CONTROL_SETUP.md) -Or modify `src/display_manager.py` to create the libVLC instance with software-decoding forced: +### Development / Internal -```python -instance = vlc.Instance('--avcodec-hw=none', '--no-video-title-show', '--no-video-deco') -``` +- [src/README.md](src/README.md) +- [src/DISPLAY_MANAGER.md](src/DISPLAY_MANAGER.md) +- [src/IMPLEMENTATION_SUMMARY.md](src/IMPLEMENTATION_SUMMARY.md) +- [TV_POWER_COORDINATION_TASKLIST.md](TV_POWER_COORDINATION_TASKLIST.md) +- [TV_POWER_HANDOFF_SERVER.md](TV_POWER_HANDOFF_SERVER.md) -This is the fastest workaround if hardware decode is not required or not available on the device. +## Contributing -### MQTT connection issues +Before changing runtime behavior: -**Test broker connectivity:** -```bash -./scripts/test-mqtt.sh -``` +- test with the relevant helper scripts, +- verify logs stay clean, +- update the specialist doc for the feature you changed. -### MQTT reconnect and heartbeat behavior +When editing AI assistant guidance files: -- On reconnect, the client re-subscribes all topics in `on_connect` and re-sends discovery to re-register. -- Heartbeats are sent only when connected. During brief reconnect windows, Paho may return rc=4 (`NO_CONN`). -- A single rc=4 warning after broker restarts or short network stalls is expected; the next heartbeat usually succeeds. -- Investigate only if rc=4 repeats across multiple intervals without subsequent successful heartbeat logs. +- keep `.github/copilot-instructions.md` policy-focused, +- follow its "Instruction File Design Rules" section, +- avoid turning it into a shadow README. -### Monitoring and UTC timestamps +Recent project history is tracked in [CHANGELOG.md](CHANGELOG.md). -Client-side monitoring is implemented with a health-state bridge between `display_manager.py` and `simclient.py`. - -- Health bridge file: `src/current_process_health.json` -- Local monitoring log: `logs/monitoring.log` -- Process states: `running`, `crashed`, `stopped` -- Restart tracking: bounded restart attempts per active event - -UTC timestamp policy: - -- `display_manager.log`, `simclient.log`, and `monitoring.log` are written in UTC (`...Z`) -- MQTT payload timestamps (heartbeat/dashboard/health/log messages) are UTC ISO timestamps -- Screenshot metadata timestamps are UTC ISO timestamps - -This prevents daylight-saving and timezone drift issues across clients. - -### VLC/PulseAudio warnings in remote sessions - -Warnings such as `pulse audio output error: overflow, flushing` can appear when testing through remote desktop/audio forwarding (for example, NoMachine) or virtual/dummy display setups. - -- If playback and audio are stable on a real HDMI display, this is usually non-fatal. -- If warnings appear only in remote sessions, treat them as environment-related rather than a core video playback bug. - -### Screenshots not uploading - -**Check which session type you're running:** -```bash -echo $WAYLAND_DISPLAY # Set if Wayland -echo $DISPLAY # Set if X11 -echo $XAUTHORITY # Should point to ~/.Xauthority for X11 captures -``` - -If `DISPLAY` is empty for non-interactive starts (systemd/nohup/ssh), the display manager now falls back to `:0` and tries `~/.Xauthority` automatically. - -**Install appropriate screenshot tool:** -```bash -# For X11: -sudo apt-get install scrot imagemagick - -# For Wayland: -sudo apt-get install grim gnome-screenshot -``` - -**Test screenshot capture:** -```bash -export SCREENSHOT_ALWAYS=1 # Force capture even without active event -./scripts/test-screenshot.sh -ls -lh src/screenshots/ -``` - -**Check logs for session detection:** -```bash -tail -f logs/display_manager.log | grep -i screenshot -# Should show: "Screenshot session=wayland" or "Screenshot session=x11" -``` - -**If you see stale dashboard images after restarts:** -```bash -cat src/screenshots/meta.json -stat src/screenshots/latest.jpg -``` - -- If `send_immediately` is stuck `true` for old metadata, restart both processes so simclient consumes and clears it. -- If `latest.jpg` timestamp does not move while new `screenshot_*.jpg` files appear, update to latest code (fix for periodic `latest.jpg` update path) and restart display_manager. - -**Verify simclient is reading screenshots:** -```bash -tail -f logs/simclient.log | grep -i screenshot -# Should show: "Dashboard 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 (1–4) still work for direct cec-client testing. - -To test CEC end-to-end, temporarily set `ENV=production` in `.env` and restart the Display Manager, or use the manual commands in the test script. - -### Quick Setup - -```bash -# Install CEC utilities -sudo apt-get install cec-utils - -# Test CEC connection -echo "scan" | cec-client -s -d 1 - -# Configure in .env -CEC_ENABLED=true -CEC_DEVICE=0 # Use 0 for best performance -CEC_TURN_OFF_DELAY=30 -CEC_POWER_ON_WAIT=5 # Adjust if TV is slow to boot -CEC_POWER_OFF_WAIT=2 -``` - -### Features - -- **Auto Power On**: TV turns on when event starts -- **Auto Power Off**: TV turns off after configurable delay when events end -- **Smart Switching**: TV stays on when switching between events -- **Configurable Delay**: Prevent rapid on/off cycles - -### Testing - -```bash -echo "on 0" | cec-client -s -d 1 # Turn on -echo "standby 0" | cec-client -s -d 1 # Turn off -echo "pow 0" | cec-client -s -d 1 # Check status -``` - -## 🤝 Contributing - -1. Test changes with `./scripts/test-display-manager.sh` -2. Verify MQTT communication with `./scripts/test-mqtt.sh` -3. Update documentation -4. Submit pull request - -## 📄 License +## License [Add your license here] - -## 🆘 Support - -For issues or questions: -1. Check logs in `logs/` directory -2. Review troubleshooting section -3. Test individual components with test scripts -4. Check MQTT broker connectivity - ---- - -**Last Updated:** March 2026 -**Status:** ✅ Production Ready -**Tested On:** Raspberry Pi 5, Raspberry Pi OS (Bookworm) - -## Recent Changes - -### November 2025 - -- Screenshot pipeline implemented with a two-process model (`display_manager.py` capture, `simclient.py` transmission). -- Wayland/X11 screenshot tool fallback chains added. -- Dashboard payload format extended with screenshot and system metadata. -- Scheduler event type support extended (`presentation`, `webuntis`, `webpage`, `website`). -- Website autoscroll support added (CDP injection + extension fallback). - -### March 2026 - -- Event-trigger screenshots (`event_start`, `event_stop`) hardened against periodic overwrite races. -- `latest.jpg` and `meta.json` synchronization improved for reliable dashboard updates. -- Stale/invalid pending trigger metadata now self-heals instead of blocking periodic updates. -- Display environment fallbacks (`DISPLAY=:0`, `XAUTHORITY`) improved for non-interactive starts. -- Development mode allows periodic idle captures to keep dashboard previews fresh when no event is active. -- 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. diff --git a/TV_POWER_COORDINATION_TASKLIST.md b/TV_POWER_COORDINATION_TASKLIST.md new file mode 100644 index 0000000..7f64037 --- /dev/null +++ b/TV_POWER_COORDINATION_TASKLIST.md @@ -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. diff --git a/TV_POWER_HANDOFF_CLIENT.md b/TV_POWER_HANDOFF_CLIENT.md new file mode 100644 index 0000000..88a70cd --- /dev/null +++ b/TV_POWER_HANDOFF_CLIENT.md @@ -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. diff --git a/TV_POWER_HANDOFF_SERVER.md b/TV_POWER_HANDOFF_SERVER.md new file mode 100644 index 0000000..9b7fc9c --- /dev/null +++ b/TV_POWER_HANDOFF_SERVER.md @@ -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. diff --git a/TV_POWER_INTENT_SERVER_CONTRACT_V1.md b/TV_POWER_INTENT_SERVER_CONTRACT_V1.md new file mode 100644 index 0000000..c775d13 --- /dev/null +++ b/TV_POWER_INTENT_SERVER_CONTRACT_V1.md @@ -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": "", + "group_id": , + "desired_state": "on" | "off", + "reason": "active_event" | "no_active_event", + "issued_at": "", + "expires_at": "", + "poll_interval_sec": , + "active_event_ids": [, ...], + "event_window_start": "" | null, + "event_window_end": "" | 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 diff --git a/TV_POWER_RUNBOOK.md b/TV_POWER_RUNBOOK.md new file mode 100644 index 0000000..e6e9b36 --- /dev/null +++ b/TV_POWER_RUNBOOK.md @@ -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//power/intent +[INFO] Power control mode: hybrid +``` + +### Valid ON Intent + +Expected sequence: + +```text +[INFO] Power intent accepted: id= desired_state=on reason=active_event ... +[INFO] Applying MQTT power intent ON id= 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= desired_state=off reason=no_active_event ... +[INFO] Applying MQTT power intent OFF id= 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. diff --git a/scripts/test-hdmi-cec.sh b/scripts/test-hdmi-cec.sh index 7130c93..7426d0a 100755 --- a/scripts/test-hdmi-cec.sh +++ b/scripts/test-hdmi-cec.sh @@ -65,6 +65,8 @@ while true; do echo " 4) Scan for devices" echo " 5) Test Display Manager CEC integration" echo " 6) View CEC logs from Display Manager" + echo " 7) Show power intent/state runtime files" + echo " 8) Clear power intent/state runtime files" echo " q) Quit" echo "" read -p "Enter choice: " choice @@ -249,6 +251,35 @@ PYTEST echo "" read -p "Press Enter to continue..." ;; + 7) + echo -e "${YELLOW}Showing power intent/state runtime files...${NC}" + echo "" + INTENT_FILE="$PROJECT_ROOT/src/power_intent_state.json" + STATE_FILE="$PROJECT_ROOT/src/power_state.json" + + if [ -f "$INTENT_FILE" ]; then + echo "power_intent_state.json:" + echo "-------------------------" + cat "$INTENT_FILE" + else + echo "power_intent_state.json not found" + fi + echo "" + if [ -f "$STATE_FILE" ]; then + echo "power_state.json:" + echo "-----------------" + cat "$STATE_FILE" + else + echo "power_state.json not found" + fi + echo "" + read -p "Press Enter to continue..." + ;; + 8) + echo -e "${YELLOW}Clearing power intent/state runtime files...${NC}" + rm -f "$PROJECT_ROOT/src/power_intent_state.json" "$PROJECT_ROOT/src/power_state.json" + echo -e "${GREEN}Removed runtime power files (if present).${NC}" + ;; q|Q) echo "Exiting..." exit 0 diff --git a/scripts/test-power-intent.sh b/scripts/test-power-intent.sh new file mode 100755 index 0000000..3ab2f5d --- /dev/null +++ b/scripts/test-power-intent.sh @@ -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:-}" +echo " Client : ${CLIENT_UUID:-}" +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 + +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 </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 diff --git a/src/README.md b/src/README.md index 89c2aa4..3cd94ba 100644 --- a/src/README.md +++ b/src/README.md @@ -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 -- 📥 Automatic file downloads (presentations, videos) -- 🖥️ **Automated display management** with dedicated Display Manager -- 🎯 Event-driven content switching (presentations, videos, web pages) -- ⏰ Time-based event scheduling with automatic start/stop -- 🔄 Graceful application transitions (LibreOffice, Chromium, VLC) -- 📸 Screenshot capture for dashboard monitoring -- 👥 Group-based content management -- 💖 Heartbeat monitoring +## Architecture -## Quick Setup +The client is split into two cooperating processes: -### 1. Flash Raspberry Pi OS -- Use **Raspberry Pi OS (64-bit) with Desktop** -- Enable SSH and configure WiFi in Pi Imager -- Boot Pi and connect to network +- `simclient.py`: MQTT communication, discovery, group assignment, event intake, heartbeat, dashboard publishing, power-intent intake. +- `display_manager.py`: event polling, display orchestration, HDMI-CEC, screenshots, local process health state. -### 2. Install Development Environment -```bash -# Run on your Raspberry Pi: -curl -sSL https://raw.githubusercontent.com/RobbStarkAustria/infoscreen_client_2025/main/pi-dev-setup.sh | bash -``` +Primary runtime flow: + +1. `simclient.py` receives group and event messages over MQTT. +2. It writes the active event into `current_event.json`. +3. `display_manager.py` polls that file and starts or stops the display process. +4. `display_manager.py` writes health, screenshot, and power telemetry files. +5. `simclient.py` publishes dashboard, health, and power-state messages. + +## Key Files + +- `display_manager.py`: display lifecycle, HDMI-CEC, screenshots, local fallback logic. +- `simclient.py`: MQTT callbacks, event persistence, dashboard publishing, power-intent validation. +- `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 cd ~/infoscreen-dev -nano .env -# Update MQTT_BROKER=your-server-ip -``` +source venv/bin/activate -### 4. Test Setup -```bash -./scripts/test-mqtt.sh # Test MQTT connection -./scripts/test-screenshot.sh # Test screenshot capture -./scripts/test-presentation.sh # Test presentation tools -``` +# Terminal 1 +./scripts/start-dev.sh -### 5. Start Development -```bash -# Terminal 1: Start MQTT client (receives events) -./scripts/start-dev.sh - -# Terminal 2: Start Display Manager (controls screen) +# Terminal 2 ./scripts/start-display-manager.sh - -# Or use interactive menu: -./dev-workflow.sh ``` -**Important**: You need **both** processes running: -- `simclient.py` - Handles MQTT communication and writes events -- `display_manager.py` - Reads events and controls display software +Useful helpers: -See [DISPLAY_MANAGER.md](DISPLAY_MANAGER.md) for detailed documentation. - -## Development Workflow - -### Daily Development -```bash -cd ~/infoscreen-dev -./dev-workflow.sh # Interactive menu with all options -``` - -**Menu Options:** -1. Start development client (MQTT) -2. Start Display Manager -3. View live logs -4. Test Display Manager -5. Test screenshot capture -6. Test MQTT connection -7. Test presentation tools -8. Git status and sync -9. Restart systemd services -10. Monitor system resources -11. Open tmux session - -### Remote Development (Recommended) -```bash -# From your main computer: -# Add to ~/.ssh/config -Host pi-dev - HostName YOUR_PI_IP - User pi - -# Connect with VS Code -code --remote ssh-remote+pi-dev ~/infoscreen-dev -``` - -## File Structure - -``` -~/infoscreen-dev/ -├── .env # Configuration -├── src/ # Source code (this repository) -│ ├── simclient.py # MQTT client (event receiver) -│ ├── display_manager.py # Display controller (NEW!) -│ ├── current_event.json # Current active event -│ ├── DISPLAY_MANAGER.md # Display Manager documentation -│ └── config/ # Client UUID and group ID -├── venv/ # Python virtual environment -├── presentation/ # Downloaded presentation files -├── screenshots/ # Screenshot captures -├── logs/ # Application logs -│ ├── simclient.log # MQTT client logs -│ └── display_manager.log # Display Manager logs -└── scripts/ # Development helper scripts - ├── start-dev.sh # Start MQTT client - ├── start-display-manager.sh # Start Display Manager (NEW!) - ├── test-display-manager.sh # Test display events (NEW!) - ├── test-mqtt.sh # Test MQTT connection - ├── test-screenshot.sh # Test screenshot capture - └── test-presentation.sh # Test presentation tools -``` - -## Configuration - -### Environment Variables (.env) -```bash -# Development settings -ENV=development -DEBUG_MODE=1 -LOG_LEVEL=DEBUG - -# MQTT Configuration -MQTT_BROKER=192.168.1.100 # Your MQTT server IP -MQTT_PORT=1883 - -# Intervals (seconds) -HEARTBEAT_INTERVAL=10 # Heartbeat frequency -SCREENSHOT_INTERVAL=30 # Screenshot capture frequency -DISPLAY_CHECK_INTERVAL=5 # Display Manager event check frequency -``` +- `./dev-workflow.sh` +- `./scripts/test-display-manager.sh` +- `./scripts/test-mqtt.sh` +- `./scripts/test-screenshot.sh` +- `./scripts/test-power-intent.sh` ## MQTT Topics ### Client → Server -- `infoscreen/discovery` - Client registration -- `infoscreen/{client_id}/heartbeat` - Regular heartbeat -- `infoscreen/{client_id}/dashboard` - Screenshot + status + +- `infoscreen/discovery` +- `infoscreen/{client_id}/heartbeat` +- `infoscreen/{client_id}/dashboard` +- `infoscreen/{client_id}/health` +- `infoscreen/{client_id}/power/state` ### 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:** -```json -{ - "id": 1, - "title": "Company Overview", - "start": "2025-10-01 08:00:00", - "end": "2025-10-01 18:00:00", - "presentation": { - "files": [ - { - "url": "https://server/presentations/slide.pptx", - "name": "slide.pptx" - } - ], - "slide_interval": 10, - "auto_advance": true - } -} -``` +Supported runtime content categories: -**Web Page Event:** -```json -{ - "id": 2, - "title": "Dashboard", - "start": "2025-10-01 08:00:00", - "end": "2025-10-01 18:00:00", - "web": { - "url": "https://dashboard.example.com" - } -} -``` +- presentation +- video +- web / webpage / website / webuntis -**Video Event:** -```json -{ - "id": 3, - "title": "Promo Video", - "start": "2025-10-01 08:00:00", - "end": "2025-10-01 18:00:00", - "video": { - "url": "https://server/videos/promo.mp4", - "loop": true - } -} -``` +Presentation behavior is documented in [../IMPRESSIVE_INTEGRATION.md](../IMPRESSIVE_INTEGRATION.md). -See [DISPLAY_MANAGER.md](DISPLAY_MANAGER.md) for complete event documentation. +TV power coordination references: + +- [../TV_POWER_INTENT_SERVER_CONTRACT_V1.md](../TV_POWER_INTENT_SERVER_CONTRACT_V1.md) +- [../TV_POWER_RUNBOOK.md](../TV_POWER_RUNBOOK.md) ## Debugging -### View Logs +### Logs + ```bash -tail -f ~/infoscreen-dev/logs/simclient.log +tail -f ~/infoscreen-dev/logs/display_manager.log ~/infoscreen-dev/src/simclient.log ``` -### MQTT Debugging -```bash -# Subscribe to all infoscreen topics -mosquitto_sub -h YOUR_BROKER_IP -t "infoscreen/+/+" +### Runtime Files -# Publish test event -mosquitto_pub -h YOUR_BROKER_IP -t "infoscreen/events/test-group" -m '{"web":{"url":"https://google.com"}}' +```bash +cat ~/infoscreen-dev/src/current_event.json +cat ~/infoscreen-dev/src/current_process_health.json +cat ~/infoscreen-dev/src/power_intent_state.json +cat ~/infoscreen-dev/src/power_state.json ``` -### System Service (Optional) -```bash -# Enable automatic startup -sudo systemctl enable infoscreen-dev -sudo systemctl start infoscreen-dev +### MQTT Inspection -# View service logs -sudo journalctl -u infoscreen-dev -f +```bash +mosquitto_sub -h YOUR_BROKER_IP -t 'infoscreen/#' ``` -## Hardware Requirements +### Screenshots -- **Raspberry Pi 4 or 5** (recommended Pi 5 for best performance) -- **SSD storage** (much faster than SD card) -- **Display** connected via HDMI -- **Network connection** (WiFi or Ethernet) - -## Troubleshooting - -### Display Issues ```bash -export DISPLAY=:0 -echo $DISPLAY +ls -lh ~/infoscreen-dev/src/screenshots/ +cat ~/infoscreen-dev/src/screenshots/meta.json ``` -### Screenshot Issues -```bash -# Test screenshot manually -scrot ~/test.png -# Check permissions -sudo usermod -a -G video pi -``` +## Environment Notes -### MQTT Connection Issues -```bash -# Test broker connectivity -telnet YOUR_BROKER_IP 1883 -# Check firewall -sudo ufw status -``` +- `ENV=development` disables HDMI-CEC in the display manager. +- `POWER_CONTROL_MODE` controls local vs hybrid vs mqtt power behavior. +- File download host rewriting is handled in `simclient.py` using `FILE_SERVER_*` settings. -## Development vs Production +## Related Documents -This setup is optimized for **development**: -- ✅ Fast iteration (edit → save → restart) -- ✅ Native debugging and logging -- ✅ Direct hardware access -- ✅ Remote development friendly - -For **production deployment** with multiple clients, consider containerization for easier updates and management. - -## License - -This project is part of the infoscreen presentation system for educational/research purposes. \ No newline at end of file +- [../README.md](../README.md) +- [DISPLAY_MANAGER.md](DISPLAY_MANAGER.md) +- [IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md) +- [../CLIENT_MONITORING_SETUP.md](../CLIENT_MONITORING_SETUP.md) +- [../SCREENSHOT_MQTT_FIX.md](../SCREENSHOT_MQTT_FIX.md) diff --git a/src/display_manager.py b/src/display_manager.py index d960249..5e69f1f 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -79,6 +79,9 @@ CEC_DEVICE = os.getenv("CEC_DEVICE", "TV") # Target device name (TV, 0, etc.) CEC_TURN_OFF_DELAY = int(os.getenv("CEC_TURN_OFF_DELAY", "30")) # seconds after last event ends CEC_POWER_ON_WAIT = int(os.getenv("CEC_POWER_ON_WAIT", "3")) # seconds to wait after turning TV on CEC_POWER_OFF_WAIT = int(os.getenv("CEC_POWER_OFF_WAIT", "2")) # seconds to wait after turning TV off +POWER_CONTROL_MODE = os.getenv("POWER_CONTROL_MODE", "local").strip().lower() +POWER_INTENT_STATE_FILE = os.path.join(os.path.dirname(__file__), "power_intent_state.json") +POWER_STATE_FILE = os.path.join(os.path.dirname(__file__), "power_state.json") # Setup logging LOG_PATH = os.path.join(os.path.dirname(__file__), "..", "logs", "display_manager.log") @@ -131,6 +134,11 @@ class ProcessHealthState: self.restart_count = 0 self.max_restarts = 3 self.last_update = datetime.now(timezone.utc).isoformat() + self.power_control_mode = None + self.power_source = None + self.last_intent_id = None + self.last_power_action = None + self.last_power_at = None def to_dict(self) -> Dict: return { @@ -140,7 +148,14 @@ class ProcessHealthState: "process_pid": self.process_pid, "process_status": self.status, "restart_count": self.restart_count, - "timestamp": datetime.now(timezone.utc).isoformat() + "timestamp": datetime.now(timezone.utc).isoformat(), + "power_control": { + "mode": self.power_control_mode, + "source": self.power_source, + "last_intent_id": self.last_intent_id, + "last_action": self.last_power_action, + "last_power_at": self.last_power_at, + }, } def save(self): @@ -188,6 +203,14 @@ class ProcessHealthState: self.save() monitoring_logger.info("Process stopped (event ended or no active event)") + def update_power_action(self, action: str, source: str, intent_id: Optional[str] = None): + """Record the last power action for dashboard observability.""" + self.last_power_action = action + self.power_source = source + self.last_intent_id = intent_id + self.last_power_at = datetime.now(timezone.utc).isoformat() + self.save() + class HDMICECController: """Controls HDMI-CEC to turn TV on/off automatically @@ -213,6 +236,7 @@ class HDMICECController: self.power_off_wait = power_off_wait self.tv_state = None # None = unknown, True = on, False = off self.turn_off_timer = None + self.turn_off_guard = None if not self.enabled: logging.info("HDMI-CEC control disabled") @@ -391,6 +415,16 @@ class HDMICECController: def _turn_off_now(self) -> bool: """Internal method to turn TV off immediately""" + self.turn_off_timer = None + + if callable(self.turn_off_guard): + try: + if not self.turn_off_guard(): + logging.info("Skipping TV OFF due to runtime guard condition") + return True + except Exception as e: + logging.warning(f"Turn-off guard failed, continuing with OFF command: {e}") + # Skip if TV is already off if self.tv_state is False: logging.debug("TV already off, skipping CEC command") @@ -421,6 +455,10 @@ class HDMICECController: self.turn_off_timer = None logging.debug("Cancelled TV turn-off timer") + def set_turn_off_guard(self, guard_fn): + """Set callback that must return True before delayed turn-off executes.""" + self.turn_off_guard = guard_fn + class DisplayProcess: """Manages a running display application process""" @@ -598,9 +636,15 @@ class DisplayManager: self.client_settings_mtime: Optional[float] = None self.client_volume_multiplier = 1.0 self._video_duration_cache: Dict[str, float] = {} + self.power_control_mode = POWER_CONTROL_MODE if POWER_CONTROL_MODE in ("local", "hybrid", "mqtt") else "local" + self.last_applied_intent_id: Optional[str] = None + self.last_seen_intent_id: Optional[str] = None + self.latest_valid_intent: Optional[Dict] = None + self.mqtt_mode_safe_off_armed = False # Initialize health state tracking for process monitoring self.health = ProcessHealthState() + self.health.power_control_mode = self.power_control_mode # Initialize HDMI-CEC controller self.cec = HDMICECController( @@ -610,6 +654,8 @@ class DisplayManager: power_on_wait=CEC_POWER_ON_WAIT, power_off_wait=CEC_POWER_OFF_WAIT ) + self.cec.set_turn_off_guard(self._allow_turn_off_now) + logging.info(f"Power control mode: {self.power_control_mode}") # Setup signal handlers for graceful shutdown signal.signal(signal.SIGTERM, self._signal_handler) @@ -810,6 +856,152 @@ class DisplayManager: except Exception as e: logging.error(f"Error reading event file: {e}") return None + + def _parse_utc_iso(self, value: str) -> datetime: + if not isinstance(value, str) or not value.strip(): + raise ValueError("timestamp must be non-empty string") + normalized = value.strip() + if normalized.endswith('Z'): + normalized = normalized[:-1] + '+00:00' + dt = datetime.fromisoformat(normalized) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + def _get_power_intent_state(self) -> Optional[Dict]: + """Read latest validated intent written by simclient.""" + try: + if not os.path.exists(POWER_INTENT_STATE_FILE): + self.latest_valid_intent = None + return None + with open(POWER_INTENT_STATE_FILE, 'r', encoding='utf-8') as f: + state = json.load(f) + if not isinstance(state, dict): + self.latest_valid_intent = None + return None + if not state.get('valid'): + self.latest_valid_intent = None + return None + payload = state.get('payload') + if not isinstance(payload, dict): + self.latest_valid_intent = None + return None + + expires_at = self._parse_utc_iso(payload.get('expires_at')) + now_utc = datetime.now(timezone.utc) + if now_utc > expires_at: + logging.warning( + "Ignoring stale power intent id=%s expires_at=%s", + payload.get('intent_id'), + payload.get('expires_at') + ) + self.latest_valid_intent = None + return None + + self.latest_valid_intent = payload + return payload + except Exception as e: + logging.warning(f"Could not read power intent state: {e}") + self.latest_valid_intent = None + return None + + def _write_power_state(self, applied_state: str, source: str, result: str, detail: str = "", intent_id: Optional[str] = None): + """Write last power control action for simclient telemetry publishing.""" + try: + payload = { + "schema_version": "1.0", + "reported_at": datetime.now(timezone.utc).isoformat(), + "power": { + "applied_state": applied_state, + "source": source, + "result": result, + "detail": detail, + } + } + if intent_id: + payload["intent_id"] = intent_id + + tmp_path = POWER_STATE_FILE + ".tmp" + with open(tmp_path, 'w', encoding='utf-8') as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + os.replace(tmp_path, POWER_STATE_FILE) + except Exception as e: + logging.debug(f"Could not write power state telemetry: {e}") + + def _has_any_active_event_now(self) -> bool: + """Evaluate active event state directly from current_event.json.""" + try: + if not os.path.exists(EVENT_FILE): + return False + with open(EVENT_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + events = data if isinstance(data, list) else [data] + for item in events: + if isinstance(item, dict) and self.is_event_active(item): + return True + return False + except Exception: + return False + + def _allow_turn_off_now(self) -> bool: + """Prevent delayed OFF while an active event or fresh ON intent is present.""" + if self._has_any_active_event_now(): + return False + intent = self._get_power_intent_state() + if intent and intent.get('desired_state') == 'on': + return False + return True + + def _should_use_local_power_control(self, intent: Optional[Dict]) -> bool: + if self.power_control_mode == 'local': + return True + if self.power_control_mode == 'hybrid': + return intent is None + # mqtt mode + return False + + def _apply_mqtt_power_intent(self, intent: Optional[Dict]): + if self.power_control_mode not in ('hybrid', 'mqtt'): + return + + if intent is None: + if self.power_control_mode == 'mqtt' and not self.mqtt_mode_safe_off_armed: + logging.warning("No valid MQTT power intent in mqtt mode - scheduling safe delayed OFF") + self.cec.turn_off(delayed=True) + self._write_power_state("off", "mqtt_intent", "ok", "mqtt_mode_no_valid_intent_safe_off") + self.health.update_power_action("off", "mqtt_intent") + self.mqtt_mode_safe_off_armed = True + return + + intent_id = str(intent.get('intent_id', '')) + desired_state = intent.get('desired_state') + reason = intent.get('reason') + duplicate = intent_id and intent_id == self.last_applied_intent_id + + self.last_seen_intent_id = intent_id or self.last_seen_intent_id + + if duplicate: + self._write_power_state(desired_state or "unknown", "mqtt_intent", "skipped", "duplicate_intent_id", intent_id=intent_id) + return + + if desired_state == 'on': + logging.info("Applying MQTT power intent ON id=%s reason=%s", intent_id, reason) + self.cec.cancel_turn_off() + success = self.cec.turn_on() + self._write_power_state("on", "mqtt_intent", "ok" if success else "error", reason or "", intent_id=intent_id) + self.health.update_power_action("on", "mqtt_intent", intent_id) + self.last_applied_intent_id = intent_id + self.mqtt_mode_safe_off_armed = False + return + + if desired_state == 'off': + logging.info("Applying MQTT power intent OFF id=%s reason=%s", intent_id, reason) + self.cec.turn_off(delayed=True) + self._write_power_state("off", "mqtt_intent", "ok", reason or "", intent_id=intent_id) + self.health.update_power_action("off", "mqtt_intent", intent_id) + self.last_applied_intent_id = intent_id + self.mqtt_mode_safe_off_armed = True + return def is_event_active(self, event: Dict) -> bool: """Check if event should be displayed based on start/end times @@ -922,6 +1114,7 @@ class DisplayManager: # Turn off TV when display stops (with configurable delay) if turn_off_tv: self.cec.turn_off(delayed=True) + self.health.update_power_action("off", "local_fallback") def start_presentation(self, event: Dict) -> Optional[DisplayProcess]: """Start presentation display (PDF/PowerPoint/LibreOffice) using Impressive @@ -1658,6 +1851,9 @@ class DisplayManager: def process_events(self): """Main processing loop - check for event changes and manage display""" + power_intent = self._get_power_intent_state() + self._apply_mqtt_power_intent(power_intent) + local_power_control = self._should_use_local_power_control(power_intent) event_data = self.read_event_file() @@ -1665,7 +1861,7 @@ class DisplayManager: if not event_data: if self.current_process: logging.info("No active event - stopping current display") - self.stop_current_display() + self.stop_current_display(turn_off_tv=local_power_control) return # Handle event arrays (take first event) @@ -1674,7 +1870,7 @@ class DisplayManager: if not events_to_process: if self.current_process: logging.info("Empty event list - stopping current display") - self.stop_current_display() + self.stop_current_display(turn_off_tv=local_power_control) return # Process first active event @@ -1687,7 +1883,7 @@ class DisplayManager: if not active_event: if self.current_process: logging.info("No active events in time window - stopping current display") - self.stop_current_display() + self.stop_current_display(turn_off_tv=local_power_control) return # Get event identifier @@ -1755,7 +1951,8 @@ class DisplayManager: else: # Everything is fine, continue # Cancel any pending TV turn-off since event is still active - self.cec.cancel_turn_off() + if local_power_control: + self.cec.cancel_turn_off() self._apply_runtime_video_settings(active_event) return else: @@ -1773,7 +1970,9 @@ class DisplayManager: logging.info(f" Event end time (UTC): {active_event['end']}") # Turn on TV before starting display - self.cec.turn_on() + if local_power_control: + self.cec.turn_on() + self.health.update_power_action("on", "local_fallback") new_process = self.start_display_for_event(active_event) diff --git a/src/simclient.py b/src/simclient.py index 577d796..04119f8 100644 --- a/src/simclient.py +++ b/src/simclient.py @@ -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_DIR = os.path.join(os.path.dirname(__file__), "screenshots") 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 @@ -237,6 +240,127 @@ def is_empty_event(event_data): 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): global discovered logging.info(f"Received: {msg.topic} {msg.payload.decode()}") @@ -563,6 +687,64 @@ def read_health_state(): 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): """Persist dashboard-managed client settings for the display manager.""" try: @@ -822,6 +1004,9 @@ def main(): 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) 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. client_kwargs = {"protocol": mqtt.MQTTv311} @@ -880,6 +1065,25 @@ def main(): if group_changed: current_group_id = 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 def on_connect(client, userdata, flags, rc, properties=None): @@ -926,6 +1130,9 @@ def main(): nonlocal event_topic event_topic = None # force re-subscribe regardless of previous state 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 if is_reconnect: @@ -1020,11 +1227,95 @@ def main(): logging.info(f"group_id unchanged: {new_group_id}, ensuring event topic is subscribed") # Always call subscribe_event_topic to ensure subscription subscribe_event_topic(new_group_id) + subscribe_power_intent_topic(new_group_id) else: logging.warning("Empty group_id received!") 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'}") + 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" def on_config_message(client, userdata, msg, properties=None): payload = msg.payload.decode().strip() @@ -1047,6 +1338,21 @@ def main(): 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 # The loop is already started, just wait and send discovery messages discovery_attempts = 0 @@ -1079,6 +1385,14 @@ def main(): screenshot_thread.start() 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 last_heartbeat = 0 logging.info("Entering heartbeat loop (network loop already running in background thread)") diff --git a/tests/test_power_intent.py b/tests/test_power_intent.py new file mode 100644 index 0000000..11e8c25 --- /dev/null +++ b/tests/test_power_intent.py @@ -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()