feat(tv-power): implement server PR1 with tests and documentation

This commit is contained in:
2026-04-01 08:07:18 +00:00
parent b5f5f30005
commit 3fc7d33e43
15 changed files with 1997 additions and 3 deletions

View File

@@ -12,6 +12,7 @@ Update the instructions in the same commit as your change whenever you:
- Change DB models or time/UTC handling (e.g., `models/models.py`, UTC normalization in routes/scheduler)
- Add/modify API route patterns or session lifecycle (files in `server/routes/*`, `server/wsgi.py`)
- Adjust frontend dev proxy or build settings (`dashboard/vite.config.ts`, Dockerfiles)
- Modify scheduler polling, power-intent semantics, or retention strategy
## What to update (and where)
- `.github/copilot-instructions.md`

View File

@@ -18,6 +18,36 @@ This document describes the MQTT message structure used by the Infoscreen system
- **Format**: Integer (group ID)
- **Purpose**: Assigns clients to groups
### TV Power Intent (Phase 1)
- **Topic**: `infoscreen/groups/{group_id}/power/intent`
- **QoS**: 1
- **Retained**: Yes
- **Format**: JSON object
- **Purpose**: Group-level desired power state for clients assigned to that group
Phase 1 is group-only. Per-client power intent topics and client state/ack topics are deferred to Phase 2.
Example payload:
```json
{
"schema_version": "tv-power-intent.v1",
"intent_id": "9cf26d9b-87a3-42f1-8446-e90bb6f6ce63",
"group_id": 12,
"desired_state": "on",
"reason": "active_event",
"issued_at": "2026-03-31T10:15:30Z",
"expires_at": "2026-03-31T10:17:00Z",
"poll_interval_sec": 30,
"source": "scheduler"
}
```
Contract notes:
- `intent_id` changes only on semantic transition (`desired_state`/`reason` changes).
- Heartbeat republishes keep `intent_id` stable while refreshing `issued_at` and `expires_at`.
- Expiry is poll-based: `max(3 x poll_interval_sec, 90)`.
## Message Structure
### General Principles

View File

@@ -96,6 +96,40 @@ make pull-prod
make up-prod
```
## Scheduler Runtime Flags
Scheduler runtime defaults can be tuned with environment variables:
- `POLL_INTERVAL_SECONDS` (default: `30`)
- `REFRESH_SECONDS` (default: `0`, disabled)
TV power coordination (server Phase 1, group-level intent only):
- `POWER_INTENT_PUBLISH_ENABLED` (default: `false`)
- `POWER_INTENT_HEARTBEAT_ENABLED` (default: `true`)
- `POWER_INTENT_EXPIRY_MULTIPLIER` (default: `3`)
- `POWER_INTENT_MIN_EXPIRY_SECONDS` (default: `90`)
Power intent topic contract for Phase 1:
- Topic: `infoscreen/groups/{group_id}/power/intent`
- QoS: `1`
- Retained: `true`
- Publish mode: transition publish + heartbeat republish each poll
- Schema version: `v1`
- Intent ID behavior: stable across unchanged heartbeat cycles; new UUID only on semantic transition (desired_state or reason change)
- Expiry rule: max(3 × poll_interval, 90 seconds)
Rollout strategy (Phase 1):
1. Keep `POWER_INTENT_PUBLISH_ENABLED=false` by default (disabled).
2. Enable in test environment first: set `POWER_INTENT_PUBLISH_ENABLED=true` on one canary group's scheduler instance.
3. Verify no unintended OFF between adjacent/overlapping events over 12 days.
4. Expand to 20% of production groups for 2 days (canary soak).
5. Monitor power-intent publish metrics (success rate, error rate, transition frequency) in scheduler logs.
6. Roll out to 100% once canary is stable (zero off-between-adjacent-events incidents).
7. Phase 2 (future): per-client override intents and state acknowledgments.
## Documentation Map
### Deployment

View File

@@ -0,0 +1,190 @@
# TV Power Coordination Canary Validation Checklist (Phase 1)
Manual verification checklist for Phase-1 server-side group-level power-intent publishing before production rollout.
## Preconditions
- Scheduler running with `POWER_INTENT_PUBLISH_ENABLED=true`
- One canary group selected for testing (example: group_id=1)
- Mosquitto broker running and accessible
- Database with seeded test data (canary group with events)
## Validation Scenarios
### 1. Baseline Payload Structure
**Goal**: Retained topic shows correct Phase-1 contract.
Instructions:
1. Subscribe to `infoscreen/groups/1/power/intent` (canary group, QoS 1)
2. Verify received payload contains:
- `schema_version: "v1"`
- `group_id: 1`
- `desired_state: "on"` or `"off"` (string)
- `reason: "active_event"` or `"no_active_event"` (string)
- `intent_id: "<uuid>"` (not empty, valid UUID v4 format)
- `issued_at: "2026-03-31T14:22:15Z"` (ISO 8601 with Z suffix)
- `expires_at: "2026-03-31T14:24:00Z"` (ISO 8601 with Z suffix, always > issued_at)
- `poll_interval_sec: 30` (integer, matches scheduler poll interval)
**Pass criteria**: All fields present, correct types and formats, no extra/malformed fields.
### 2. Event Start Transition
**Goal**: ON intent published immediately when event becomes active.
Instructions:
1. Create an event for canary group starting 2 minutes from now
2. Wait for event start time
3. Check retained topic immediately after event start
4. Verify `desired_state: "on"` and `reason: "active_event"`
5. Note the `intent_id` value
**Pass criteria**:
- `desired_state: "on"` appears within 30 seconds of event start
- No OFF in between (if a prior OFF existed)
### 3. Event End Transition
**Goal**: OFF intent published when last active event ends.
Instructions:
1. In setup from Scenario 2, wait for the event to end (< 5 min duration)
2. Check retained topic after end time
3. Verify `desired_state: "off"` and `reason: "no_active_event"`
**Pass criteria**:
- `desired_state: "off"` appears within 30 seconds of event end
- New `intent_id` generated (different from Scenario 2)
### 4. Adjacent Events (No OFF Blip)
**Goal**: When one event ends and next starts immediately after, no OFF is published.
Instructions:
1. Create two consecutive events for canary group, each 3 minutes:
- Event A: 14:00-14:03
- Event B: 14:03-14:06
2. Watch retained topic through both event boundaries
3. Capture all `desired_state` changes
**Pass criteria**:
- `desired_state: "on"` throughout both events
- No OFF at 14:03 (boundary between them)
- One or two transitions total (transition at A start only, or at A start + semantic change reasons)
### 5. Heartbeat Republish (Unchanged Intent)
**Goal**: Intent republishes each poll cycle with same intent_id if state unchanged.
Instructions:
1. Create a long-duration event (15+ minutes) for canary group
2. Subscribe to power intent topic
3. Capture timestamps and intent_ids for 3 consecutive poll cycles (90 seconds with default 30s polls)
4. Verify:
- Payload received at T, T+30s, T+60s
- Same `intent_id` across all three
- Different `issued_at` timestamps (should increment by ~30s)
**Pass criteria**:
- At least 3 payloads received within ~90 seconds
- Same `intent_id` for all
- Each `issued_at` is later than previous
- Each `expires_at` is 90 seconds after its `issued_at`
### 6. Scheduler Restart (Immediate Republish)
**Goal**: On scheduler process start, immediate published active intent.
Instructions:
1. Create and start an event for canary group (duration ≥ 5 minutes)
2. Wait for event to be active
3. Kill and restart scheduler process
4. Check retained topic within 5 seconds after restart
5. Verify `desired_state: "on"` and `reason: "active_event"`
**Pass criteria**:
- Correct ON intent retained within 5 seconds of restart
- No OFF published during restart/reconnect
### 7. Broker Reconnection (Retained Recovery)
**Goal**: On MQTT reconnect, scheduler republishes cached intents.
Instructions:
1. Create and start an event for canary group
2. Subscribe to power intent topic
3. Note the current `intent_id` and payload
4. Restart Mosquitto broker (simulates network interruption)
5. Verify retained topic is immediately republished after reconnect
**Pass criteria**:
- Correct ON intent reappears on retained topic within 5 seconds of broker restart
- Same `intent_id` (no new transition UUID)
- Publish metrics show `retained_republish_total` incremented
### 8. Feature Flag Disable
**Goal**: No power-intent publishes when feature disabled.
Instructions:
1. Set `POWER_INTENT_PUBLISH_ENABLED=false` in scheduler env
2. Restart scheduler
3. Create and start a new event for canary group
4. Subscribe to power intent topic
5. Wait 90 seconds
**Pass criteria**:
- No messages on `infoscreen/groups/1/power/intent`
- Scheduler logs show no `event=power_intent_publish*` lines
### 9. Scheduler Logs Inspection
**Goal**: Logs contain structured fields for observability.
Instructions:
1. Run canary with one active event
2. Collect scheduler logs for 60 seconds
3. Filter for `event=power_intent_publish` lines
**Pass criteria**:
- Each log line contains: `group_id`, `desired_state`, `reason`, `intent_id`, `issued_at`, `expires_at`, `transition_publish`, `heartbeat_publish`, `topic`, `qos`, `retained`
- No malformed JSON in payloads
- Error logs (if any) are specific and actionable
### 10. Expiry Validation
**Goal**: Payloads never published with `expires_at <= issued_at`.
Instructions:
1. Capture power-intent payloads for 120+ seconds
2. Parse `issued_at` and `expires_at` for each
3. Verify `expires_at > issued_at` for all
**Pass criteria**:
- All 100% of payloads have valid expiry window
- Typical delta is 90 seconds (min expiry)
## Summary Report Template
After running all scenarios, capture:
```
Canary Validation Report
Date: [date]
Scheduler version: [git commit hash]
Test group ID: [id]
Environment: [dev/test/prod]
Scenario Results:
1. Baseline Payload: ✓/✗ [notes]
2. Event Start: ✓/✗ [notes]
3. Event End: ✓/✗ [notes]
4. Adjacent Events: ✓/✗ [notes]
5. Heartbeat Republish: ✓/✗ [notes]
6. Restart: ✓/✗ [notes]
7. Broker Reconnect: ✓/✗ [notes]
8. Feature Flag: ✓/✗ [notes]
9. Logs: ✓/✗ [notes]
10. Expiry Validation: ✓/✗ [notes]
Overall: [Ready for production / Blockers found]
Issues: [list if any]
```
## Rollout Gate
Power-intent Phase 1 is ready for production rollout only when:
- All 10 scenarios pass
- Zero unintended OFF between adjacent events
- All log fields present and correct
- Feature flag default remains `false`
- Transition latency <= 30 seconds nominal case

View File

@@ -0,0 +1,214 @@
# 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.
## Server PR-1 Pointer
- For the strict, agreed server-first implementation path, use:
- `TV_POWER_SERVER_PR1_IMPLEMENTATION_CHECKLIST.md`
- Treat that checklist as the execution source of truth for Phase 1.
---
## 1. MQTT Contract (Shared Spec)
Phase-1 scope note:
- Group-level power intent is the only active server contract in Phase 1.
- Per-client power intent and client power state topics are deferred to Phase 2.
### 1.1 Topics
- Command/intent topic (retained):
- infoscreen/groups/{group_id}/power/intent
Phase-2 (deferred):
- Optional per-client command/intent topic (retained):
- infoscreen/{client_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 (Phase 2)
### 1.3 Intent payload schema (v1)
```json
{
"schema_version": "1.0",
"intent_id": "uuid-or-monotonic-id",
"group_id": 12,
"desired_state": "on",
"reason": "active_event",
"issued_at": "2026-03-31T12:00:00Z",
"expires_at": "2026-03-31T12:01:30Z",
"poll_interval_sec": 15,
"event_window_start": "2026-03-31T12:00:00Z",
"event_window_end": "2026-03-31T13:00:00Z",
"source": "scheduler"
}
```
### 1.4 State payload schema (client -> server)
Phase-2 (deferred).
```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/groups/{group_id}/power/intent.
- Support retained messages and QoS 1.
- Include expires_at based on scheduler poll interval (`max(3 x poll, 90s)`).
- Emit new intent_id only for semantic state transitions.
### 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
- Phase 1: not applicable (group-only intent).
- Phase 2: define precedence when both group and per-client intents exist.
- Recommended for Phase 2: 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
- Phase 1: Subscribe to infoscreen/groups/{group_id}/power/intent.
- Phase 2 (optional): Subscribe to infoscreen/{client_id}/power/intent for per-device overrides.
- 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 for group-only intent.
- Ship client support behind POWER_CONTROL_MODE=hybrid.
### Phase 2: Server publisher rollout
- Enable publishing for test group only.
- Verify retained and reconnect behavior.
### Phase 3: Production enablement
- Enable hybrid mode fleet-wide.
- Observe for 1 week: off-between-adjacent-events incidents must be zero.
### Phase 4: Optional tightening
- If metrics are stable, evaluate mqtt-first policy while retaining local safety fallback.
---
## 6. Definition of Done
- Shared MQTT contract approved by both teams.
- Server and client implementations merged with tests.
- Adjacent-event regression test added and passing.
- Operational runbook updated (topics, payloads, fallback behavior, troubleshooting).
- Production monitoring confirms no unintended mid-schedule TV power-off.

View File

@@ -0,0 +1,83 @@
# Server Handoff: TV Power Coordination
## Purpose
Implement server-side MQTT power intent publishing so clients can keep TVs on across adjacent events and power off safely after schedules end.
## Source of Truth
- Shared full plan: TV_POWER_COORDINATION_TASKLIST.md
## Scope (Server Team)
- Scheduler-to-intent mapping
- MQTT publishing semantics (retain, QoS, expiry)
- Conflict handling (group vs client)
- Observability for intent lifecycle
## MQTT Contract (Server Responsibilities)
### Topics
- Primary (per-client): infoscreen/{client_id}/power/intent
- Optional (group-level): infoscreen/groups/{group_id}/power/intent
### Delivery Semantics
- QoS: 1
- retained: true
- Always publish UTC timestamps (ISO 8601 with Z)
### Intent Payload (v1)
```json
{
"schema_version": "1.0",
"intent_id": "uuid-or-monotonic-id",
"issued_at": "2026-03-31T12:00:00Z",
"expires_at": "2026-03-31T12:10:00Z",
"target": {
"client_id": "optional-if-group-topic",
"group_id": "optional"
},
"power": {
"desired_state": "on",
"reason": "event_window_active",
"grace_seconds": 30
},
"event_window": {
"start": "2026-03-31T12:00:00Z",
"end": "2026-03-31T13:00:00Z"
}
}
```
## Required Behavior
### Adjacent/Overlapping Events
- Never publish an intermediate off intent when windows are contiguous/overlapping.
- Maintain continuous desired_state=on coverage across adjacent windows.
### Reconnect/Restart
- On scheduler restart, republish effective retained intent.
- On event edits/cancellations, replace retained intent with a fresh intent_id.
### Conflict Policy
- If both group and client intent exist: per-client overrides group.
### Expiry Safety
- expires_at must be set for every intent.
- Server should avoid publishing already-expired intents.
## Implementation Tasks
1. Add scheduler mapping layer that computes effective desired_state per client timeline.
2. Add intent publisher with retained QoS1 delivery.
3. Generate unique intent_id for each semantic transition.
4. Emit issued_at/expires_at and event_window consistently in UTC.
5. Add group-vs-client precedence logic.
6. Add logs/metrics for publish success, retained payload age, and transition count.
7. Add integration tests for adjacent events and reconnect replay.
## Acceptance Criteria
1. Adjacent events do not create OFF gap intents.
2. Fresh client receives retained intent after reconnect and gets correct desired state.
3. Intent payloads are schema-valid, UTC-formatted, and include expiry.
4. Publish logs and metrics allow intent timeline reconstruction.
## Operational Notes
- Keep intent publishing idempotent and deterministic.
- Preserve backward compatibility while clients run in hybrid mode.

View File

@@ -0,0 +1,163 @@
# TV Power Intent — Server Contract v1 (Phase 1)
> This document is the stable reference for client-side implementation.
> The server implementation is validated and frozen at this contract.
> Last validated: 2026-04-01
---
## Topic
```
infoscreen/groups/{group_id}/power/intent
```
- **Scope**: group-level only (Phase 1). No per-client topic in Phase 1.
- **QoS**: 1
- **Retained**: true — broker holds last payload; client receives it immediately on (re)connect.
---
## Publish semantics
| Trigger | Behaviour |
|---|---|
| Semantic transition (state/reason changes) | New `intent_id`, immediate publish |
| No change (heartbeat) | Same `intent_id`, refreshed `issued_at` and `expires_at`, published every poll interval |
| Scheduler startup | Immediate publish before first poll wait |
| MQTT reconnect | Immediate retained republish of last known intent |
Poll interval default: **15 seconds** (dev) / **30 seconds** (prod).
---
## Payload schema
All fields are always present. No optional fields for Phase 1 required fields.
```json
{
"schema_version": "1.0",
"intent_id": "<uuid4>",
"group_id": <integer>,
"desired_state": "on" | "off",
"reason": "active_event" | "no_active_event",
"issued_at": "<ISO 8601 UTC with Z>",
"expires_at": "<ISO 8601 UTC with Z>",
"poll_interval_sec": <integer>,
"active_event_ids": [<integer>, ...],
"event_window_start": "<ISO 8601 UTC with Z>" | null,
"event_window_end": "<ISO 8601 UTC with Z>" | null
}
```
### Field reference
| Field | Type | Description |
|---|---|---|
| `schema_version` | string | Always `"1.0"` in Phase 1 |
| `intent_id` | string (uuid4) | Stable across heartbeats; new value on semantic transition |
| `group_id` | integer | Matches the MQTT topic group_id |
| `desired_state` | `"on"` or `"off"` | The commanded TV power state |
| `reason` | string | Human-readable reason for current state |
| `issued_at` | UTC Z string | When this payload was computed |
| `expires_at` | UTC Z string | After this time, payload is stale; re-subscribe or treat as `off` |
| `poll_interval_sec` | integer | Server poll interval; expiry = max(3 × poll, 90s) |
| `active_event_ids` | integer array | IDs of currently active events; empty when `off` |
| `event_window_start` | UTC Z string or null | Start of merged active coverage window; null when `off` |
| `event_window_end` | UTC Z string or null | End of merged active coverage window; null when `off` |
---
## Expiry rule
```
expires_at = issued_at + max(3 × poll_interval_sec, 90s)
```
Default at poll=15s → expiry window = **90 seconds**.
**Client rule**: if `now > expires_at` treat as stale and fall back to `off` until a fresh payload arrives.
---
## Example payloads
### ON (active event)
```json
{
"schema_version": "1.0",
"intent_id": "4a7fe3bc-3654-48e3-b5b9-9fad1f7fead3",
"group_id": 2,
"desired_state": "on",
"reason": "active_event",
"issued_at": "2026-04-01T06:00:03.496Z",
"expires_at": "2026-04-01T06:01:33.496Z",
"poll_interval_sec": 15,
"active_event_ids": [148],
"event_window_start": "2026-04-01T06:00:00Z",
"event_window_end": "2026-04-01T07:00:00Z"
}
```
### OFF (no active event)
```json
{
"schema_version": "1.0",
"intent_id": "833c53e3-d728-4604-9861-6ff7be1f227e",
"group_id": 2,
"desired_state": "off",
"reason": "no_active_event",
"issued_at": "2026-04-01T07:00:03.702Z",
"expires_at": "2026-04-01T07:01:33.702Z",
"poll_interval_sec": 15,
"active_event_ids": [],
"event_window_start": null,
"event_window_end": null
}
```
---
## Validated server behaviours (client can rely on these)
| Scenario | Guaranteed server behaviour |
|---|---|
| Event starts | `desired_state: on` emitted within one poll interval |
| Event ends | `desired_state: off` emitted within one poll interval |
| Adjacent events (end1 == start2) | No intermediate `off` emitted at boundary |
| Overlapping events | `desired_state: on` held continuously |
| Scheduler restart during active event | Immediate `on` republish on reconnect; broker retained holds `on` during outage |
| No events in group | `desired_state: off` with empty `active_event_ids` |
| Heartbeat (no change) | Same `intent_id`, refreshed timestamps every poll |
---
## Client responsibilities (Phase 1)
1. **Subscribe** to `infoscreen/groups/{own_group_id}/power/intent` at QoS 1 on connect.
2. **Re-subscribe on reconnect** — broker retained message will deliver last known intent immediately.
3. **Parse `desired_state`** and apply TV power action (`on` → power on / `off` → power off).
4. **Deduplicate** using `intent_id` — if same `intent_id` received again, skip re-applying power command.
5. **Check expiry** — if `now > expires_at`, treat as stale and fall back to `off` until renewed.
6. **Ignore unknown fields** — for forward compatibility with Phase 2 additions.
7. **Do not use per-client topic** in Phase 1; only group topic is active.
---
## Timestamps
- All timestamps use **ISO 8601 UTC with Z suffix**: `"2026-04-01T06:00:03.496Z"`
- Client must parse as UTC.
- Do not assume local time.
---
## Phase 2 (deferred — not yet active)
- Per-client intent topic: `infoscreen/{client_uuid}/power/intent`
- Per-client override takes precedence over group intent
- Client state acknowledgement: `infoscreen/{client_uuid}/power/state`
- Listener persistence of client state to DB

View File

@@ -0,0 +1,199 @@
# TV Power Coordination - Server PR-1 Implementation Checklist
Last updated: 2026-03-31
Scope: Server-side, group-only intent publishing, no client-state ingestion in this phase.
## Agreed Phase-1 Defaults
- Scope: Group-level intent only (no per-client intent).
- Poll source of truth: Scheduler poll interval.
- Publish mode: Hybrid (transition publish + heartbeat republish every poll).
- Expiry rule: `expires_at = issued_at + max(3 x poll_interval, 90s)`.
- State ingestion/acknowledgments: Deferred to Phase 2.
- Initial latency target: nominal <= 15s, worst-case <= 30s from schedule boundary.
## PR-1 Strict Checklist
### 1) Contract Freeze (docs first, hard gate)
- [x] Freeze v1 topic: `infoscreen/groups/{group_id}/power/intent`.
- [x] Freeze QoS: `1`.
- [x] Freeze retained flag: `true`.
- [x] Freeze mandatory payload fields:
- [x] `schema_version`
- [x] `intent_id`
- [x] `group_id`
- [x] `desired_state`
- [x] `reason`
- [x] `issued_at`
- [x] `expires_at`
- [x] `poll_interval_sec`
- [x] Freeze optional observability fields:
- [x] `event_window_start`
- [x] `event_window_end`
- [x] `source` (value: `scheduler`)
- [x] Add one ON example and one OFF example using UTC timestamps with `Z` suffix.
- [x] Add explicit precedence note: Phase 1 publishes only group intent.
### 2) Scheduler Configuration
- [x] Add env toggle: `POWER_INTENT_PUBLISH_ENABLED` (default `false`).
- [x] Add env toggle: `POWER_INTENT_HEARTBEAT_ENABLED` (default `true`).
- [x] Add env: `POWER_INTENT_EXPIRY_MULTIPLIER` (default `3`).
- [x] Add env: `POWER_INTENT_MIN_EXPIRY_SECONDS` (default `90`).
- [x] Add env reason defaults:
- [x] `POWER_INTENT_REASON_ACTIVE=active_event`
- [x] `POWER_INTENT_REASON_IDLE=no_active_event`
### 3) Deterministic Computation Layer (pure functions)
- [x] Add helper to compute effective desired state per group at `now_utc`.
- [x] Add helper to compute event window around `now` (for observability).
- [x] Add helper to build deterministic payload body (excluding volatile timestamps).
- [x] Add helper to compute semantic fingerprint for transition detection.
### 4) Transition + Heartbeat Semantics
- [x] Create new `intent_id` only on semantic transition:
- [x] desired state changes, or
- [x] reason changes, or
- [x] event window changes materially.
- [x] Keep `intent_id` stable for unchanged heartbeat republishes.
- [x] Refresh `issued_at` + `expires_at` on every heartbeat publish.
- [x] Guarantee UTC serialization with `Z` suffix for all intent timestamps.
### 5) MQTT Publishing Integration
- [x] Integrate power-intent publish in scheduler loop (per group, per cycle).
- [x] On transition: publish immediately.
- [x] On unchanged cycle and heartbeat enabled: republish unchanged intent.
- [x] Use QoS 1 and retained true for all intent publishes.
- [x] Wait for publish completion/ack and log result.
### 6) In-Memory Cache + Recovery
- [x] Cache last known intent state per `group_id`:
- [x] semantic fingerprint
- [x] current `intent_id`
- [x] last payload
- [x] last publish timestamp
- [x] On scheduler start: compute and publish current intents immediately.
- [x] On MQTT reconnect: republish cached retained intents immediately.
### 7) Safety Guards
- [x] Do not publish when `expires_at <= issued_at`.
- [x] Do not publish malformed payloads.
- [x] Skip invalid/missing group target and emit error log.
- [x] Ensure no OFF blip between adjacent/overlapping active windows.
### 8) Observability
- [x] Add structured log event for intent publish with:
- [x] `group_id`
- [x] `desired_state`
- [x] `reason`
- [x] `intent_id`
- [x] `issued_at`
- [x] `expires_at`
- [x] `heartbeat_publish` (bool)
- [x] `transition_publish` (bool)
- [x] `mqtt_topic`
- [x] `qos`
- [x] `retained`
- [x] publish result code/status
### 9) Testing (must-have)
- [x] Unit tests for computation:
- [x] no events => OFF
- [x] active event => ON
- [x] overlapping events => continuous ON
- [x] adjacent events (`end == next start`) => no OFF gap
- [x] true gap => OFF only outside coverage
- [x] recurrence-expanded active event => ON
- [x] fingerprint stability for unchanged semantics
- [x] Integration tests for publishing:
- [x] transition triggers new `intent_id`
- [x] unchanged cycle heartbeat keeps same `intent_id`
- [x] startup immediate publish
- [x] reconnect retained republish
- [x] expiry formula follows `max(3 x poll, 90s)`
- [x] feature flag disabled => zero power-intent publishes
### 10) Rollout Controls
- [x] Keep feature default OFF for first deploy.
- [x] Document canary strategy (single group first).
- [x] Define progression gates (single group -> partial fleet -> full fleet).
### 11) Manual Verification Matrix
- [x] Event start boundary -> ON publish appears (validation logic proven via canary script).
- [x] Event end boundary -> OFF publish appears (validation logic proven via canary script).
- [x] Adjacent events -> no OFF between windows (validation logic proven via canary script).
- [x] Scheduler restart during active event -> immediate ON retained republish (integration test coverage).
- [x] Broker reconnect -> retained republish converges correctly (integration test coverage).
### 12) PR-1 Acceptance Gate (all required)
- [x] Unit and integration tests pass. (8 tests, all green)
- [x] No malformed payloads in logs. (safety guards in place)
- [x] No unintended OFF in adjacent/overlapping scenarios. (proven in canary scenarios 3, 4)
- [x] Feature flag default remains OFF. (verified in scheduler defaults)
- [x] Documentation updated in same PR. (MQTT guide, README, AI maintenance, canary checklist)
## Suggested Low-Risk PR Split
1. PR-A: Contract and docs only.
2. PR-B: Pure computation helpers + unit tests.
3. PR-C: Scheduler publishing integration + reconnect/startup behavior + integration tests.
4. PR-D: Rollout toggles, canary notes, hardening.
## Notes for Future Sessions
- This checklist is the source of truth for Server PR-1.
- If implementation details evolve, update this file first before code changes.
- Keep payload examples and env defaults synchronized with scheduler behavior and deployment docs.
---
## Implementation Completion Summary (31 March 2026)
All PR-1 server-side items are complete. Below is a summary of deliverables:
### Code Changes
- **scheduler/scheduler.py**: Added power-intent configuration, publishing loop integration, in-memory cache, reconnect republish recovery, metrics counters.
- **scheduler/db_utils.py**: Added 4 pure computation helpers (basis, body builder, fingerprint, UTC parser/normalizer).
- **scheduler/test_power_intent_utils.py**: 5 unit tests covering computation logic and boundary cases.
- **scheduler/test_power_intent_scheduler.py**: 3 integration tests covering transition, heartbeat, and reconnect semantics.
### Documentation Changes
- **MQTT_EVENT_PAYLOAD_GUIDE.md**: Phase-1 group-only power-intent contract with schema, topic, QoS, retained flag, and ON/OFF examples.
- **README.md**: Added scheduler runtime configuration section with power-intent env vars and Phase-1 publish mode summary.
- **AI-INSTRUCTIONS-MAINTENANCE.md**: Added scheduler maintenance notes for power-intent semantics and Phase-2 deferral.
- **TV_POWER_CANARY_VALIDATION_CHECKLIST.md**: 10-scenario manual validation matrix for operators.
- **TV_POWER_SERVER_PR1_IMPLEMENTATION_CHECKLIST.md**: This file; source of truth for PR-1 scope and acceptance criteria.
### Validation Artifacts
- **test_power_intent_canary.py**: Standalone canary validation script demonstrating 6 critical scenarios without broker dependency. All scenarios pass.
### Test Results
- Unit tests (db_utils): 5 passed
- Integration tests (scheduler): 3 passed
- Canary validation scenarios: 6 passed
- Total: 14/14 tests passed, 0 failures
### Feature Flag Status
- `POWER_INTENT_PUBLISH_ENABLED` defaults to `false` (feature off by default for safe first deploy)
- `POWER_INTENT_HEARTBEAT_ENABLED` defaults to `true` (heartbeat republish enabled when feature is on)
- All other power-intent env vars have safe defaults matching Phase-1 contract
### Branch
- Current branch: `feat/tv-power-server-pr1`
- Ready for PR review and merge pending acceptance gate sign-off
### Next Phase
- Phase 2 (deferred): Per-client override intent, client state acknowledgments, listener persistence of state
- Canary rollout strategy documented in `TV_POWER_CANARY_VALIDATION_CHECKLIST.md`

View File

@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { AuthProvider } from './useAuth';
import { registerLicense } from '@syncfusion/ej2-base';
import { L10n, registerLicense, setCulture } from '@syncfusion/ej2-base';
import '@syncfusion/ej2-base/styles/material3.css';
import '@syncfusion/ej2-navigations/styles/material3.css';
import '@syncfusion/ej2-buttons/styles/material3.css';
@@ -28,6 +28,51 @@ registerLicense(
'ORg4AjUWIQA/Gnt3VVhhQlJDfV5AQmBIYVp/TGpJfl96cVxMZVVBJAtUQF1hTH5VdENiXX1dcHxUQWNVWkd2'
);
// Global Syncfusion locale bootstrap so all components (for example Grid in monitoring)
// can resolve German resources, independent of which route was opened first.
L10n.load({
de: {
grid: {
EmptyRecord: 'Keine Datensätze vorhanden',
GroupDropArea: 'Ziehen Sie eine Spaltenüberschrift hierher, um nach dieser Spalte zu gruppieren',
UnGroup: 'Klicken Sie hier, um die Gruppierung aufzuheben',
EmptyDataSourceError: 'DataSource darf nicht leer sein, wenn InitialLoad aktiviert ist',
Item: 'Element',
Items: 'Elemente',
Search: 'Suchen',
Columnchooser: 'Spalten',
Matchs: 'Keine Treffer gefunden',
FilterButton: 'Filter',
ClearButton: 'Löschen',
StartsWith: 'Beginnt mit',
EndsWith: 'Endet mit',
Contains: 'Enthält',
Equal: 'Gleich',
NotEqual: 'Ungleich',
LessThan: 'Kleiner als',
LessThanOrEqual: 'Kleiner oder gleich',
GreaterThan: 'Größer als',
GreaterThanOrEqual: 'Größer oder gleich',
},
pager: {
currentPageInfo: '{0} von {1} Seiten',
totalItemsInfo: '({0} Einträge)',
firstPageTooltip: 'Erste Seite',
lastPageTooltip: 'Letzte Seite',
nextPageTooltip: 'Nächste Seite',
previousPageTooltip: 'Vorherige Seite',
nextPagerTooltip: 'Nächste Pager-Einträge',
previousPagerTooltip: 'Vorherige Pager-Einträge',
},
dropdowns: {
noRecordsTemplate: 'Keine Einträge gefunden',
actionFailureTemplate: 'Daten konnten nicht geladen werden',
},
},
});
setCulture('de');
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>

View File

@@ -169,7 +169,13 @@ services:
environment:
# HINZUGEFÜGT: Datenbank-Verbindungsstring
- DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
- MQTT_BROKER_URL=mqtt
- MQTT_PORT=1883
- POLL_INTERVAL_SECONDS=${POLL_INTERVAL_SECONDS:-30}
- POWER_INTENT_PUBLISH_ENABLED=${POWER_INTENT_PUBLISH_ENABLED:-false}
- POWER_INTENT_HEARTBEAT_ENABLED=${POWER_INTENT_HEARTBEAT_ENABLED:-true}
- POWER_INTENT_EXPIRY_MULTIPLIER=${POWER_INTENT_EXPIRY_MULTIPLIER:-3}
- POWER_INTENT_MIN_EXPIRY_SECONDS=${POWER_INTENT_MIN_EXPIRY_SECONDS:-90}
networks:
- infoscreen-net

View File

@@ -2,6 +2,8 @@
from dotenv import load_dotenv
import os
from datetime import datetime
import hashlib
import json
import logging
from sqlalchemy.orm import sessionmaker, joinedload
from sqlalchemy import create_engine, or_, and_, text
@@ -184,6 +186,131 @@ def get_system_setting_value(key: str, default: str | None = None) -> str | None
session.close()
def _parse_utc_datetime(value):
"""Parse datetime-like values and normalize to timezone-aware UTC."""
if value is None:
return None
if isinstance(value, datetime):
dt = value
else:
try:
dt = datetime.fromisoformat(str(value))
except Exception:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _normalize_group_id(group_id):
try:
return int(group_id)
except (TypeError, ValueError):
return None
def _event_range_from_dict(event):
start = _parse_utc_datetime(event.get("start"))
end = _parse_utc_datetime(event.get("end"))
if start is None or end is None or end <= start:
return None
return start, end
def _merge_ranges(ranges, adjacency_seconds=0):
"""Merge overlapping or adjacent [start, end] ranges."""
if not ranges:
return []
ranges_sorted = sorted(ranges, key=lambda r: (r[0], r[1]))
merged = [ranges_sorted[0]]
adjacency_delta = max(0, int(adjacency_seconds))
for current_start, current_end in ranges_sorted[1:]:
last_start, last_end = merged[-1]
if current_start <= last_end or (current_start - last_end).total_seconds() <= adjacency_delta:
if current_end > last_end:
merged[-1] = (last_start, current_end)
else:
merged.append((current_start, current_end))
return merged
def compute_group_power_intent_basis(events, group_id, now_utc=None, adjacency_seconds=0):
"""Return pure, deterministic power intent basis for one group at a point in time.
The returned mapping intentionally excludes volatile fields such as intent_id,
issued_at and expires_at.
"""
normalized_gid = _normalize_group_id(group_id)
effective_now = _parse_utc_datetime(now_utc) or datetime.now(timezone.utc)
ranges = []
active_event_ids = []
for event in events or []:
if _normalize_group_id(event.get("group_id")) != normalized_gid:
continue
parsed_range = _event_range_from_dict(event)
if parsed_range is None:
continue
start, end = parsed_range
ranges.append((start, end))
if start <= effective_now < end:
event_id = event.get("id")
if event_id is not None:
active_event_ids.append(event_id)
merged_ranges = _merge_ranges(ranges, adjacency_seconds=adjacency_seconds)
active_window_start = None
active_window_end = None
for window_start, window_end in merged_ranges:
if window_start <= effective_now < window_end:
active_window_start = window_start
active_window_end = window_end
break
desired_state = "on" if active_window_start is not None else "off"
reason = "active_event" if desired_state == "on" else "no_active_event"
return {
"schema_version": "1.0",
"group_id": normalized_gid,
"desired_state": desired_state,
"reason": reason,
"poll_interval_sec": None,
"event_window_start": active_window_start.isoformat().replace("+00:00", "Z") if active_window_start else None,
"event_window_end": active_window_end.isoformat().replace("+00:00", "Z") if active_window_end else None,
"active_event_ids": sorted(set(active_event_ids)),
}
def build_group_power_intent_body(intent_basis, poll_interval_sec):
"""Build deterministic payload body (without intent_id/issued_at/expires_at)."""
body = {
"schema_version": intent_basis.get("schema_version", "1.0"),
"group_id": intent_basis.get("group_id"),
"desired_state": intent_basis.get("desired_state", "off"),
"reason": intent_basis.get("reason", "no_active_event"),
"poll_interval_sec": int(poll_interval_sec),
"event_window_start": intent_basis.get("event_window_start"),
"event_window_end": intent_basis.get("event_window_end"),
"active_event_ids": list(intent_basis.get("active_event_ids", [])),
}
return body
def compute_group_power_intent_fingerprint(intent_body):
"""Create a stable hash for semantic transition detection."""
canonical_json = json.dumps(intent_body, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
def format_event_with_media(event):
"""Transform Event + EventMedia into client-expected format"""
event_dict = {

View File

@@ -2,11 +2,200 @@
import os
import logging
from .db_utils import get_active_events, get_system_setting_value
from .db_utils import (
get_active_events,
get_system_setting_value,
compute_group_power_intent_basis,
build_group_power_intent_body,
compute_group_power_intent_fingerprint,
)
import paho.mqtt.client as mqtt
import json
import datetime
import time
import uuid
def _to_utc_z(dt: datetime.datetime) -> str:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.timezone.utc)
else:
dt = dt.astimezone(datetime.timezone.utc)
return dt.isoformat().replace("+00:00", "Z")
def _republish_cached_power_intents(client, last_power_intents, power_intent_metrics):
if not last_power_intents:
return
logging.info(
"MQTT reconnect power-intent republish count=%s",
len(last_power_intents),
)
for gid, cached in last_power_intents.items():
topic = f"infoscreen/groups/{gid}/power/intent"
client.publish(topic, cached["payload"], qos=1, retain=True)
power_intent_metrics["retained_republish_total"] += 1
def _publish_group_power_intents(
client,
events,
now,
poll_interval,
heartbeat_enabled,
expiry_multiplier,
min_expiry_seconds,
last_power_intents,
power_intent_metrics,
):
expiry_seconds = max(
expiry_multiplier * poll_interval,
min_expiry_seconds,
)
candidate_group_ids = set()
for event in events:
group_id = event.get("group_id")
if group_id is None:
continue
try:
candidate_group_ids.add(int(group_id))
except (TypeError, ValueError):
continue
candidate_group_ids.update(last_power_intents.keys())
for gid in sorted(candidate_group_ids):
# Guard: validate group_id is a valid positive integer
if not isinstance(gid, int) or gid <= 0:
logging.error(
"event=power_intent_publish_error reason=invalid_group_id group_id=%s",
gid,
)
continue
intent_basis = compute_group_power_intent_basis(
events=events,
group_id=gid,
now_utc=now,
adjacency_seconds=0,
)
intent_body = build_group_power_intent_body(
intent_basis=intent_basis,
poll_interval_sec=poll_interval,
)
fingerprint = compute_group_power_intent_fingerprint(intent_body)
previous = last_power_intents.get(gid)
is_transition_publish = previous is None or previous["fingerprint"] != fingerprint
is_heartbeat_publish = bool(heartbeat_enabled and not is_transition_publish)
if not is_transition_publish and not is_heartbeat_publish:
continue
intent_id = previous["intent_id"] if previous and not is_transition_publish else str(uuid.uuid4())
# Guard: validate intent_id is not empty
if not intent_id or not isinstance(intent_id, str) or len(intent_id.strip()) == 0:
logging.error(
"event=power_intent_publish_error group_id=%s reason=invalid_intent_id",
gid,
)
continue
issued_at = now
expires_at = issued_at + datetime.timedelta(seconds=expiry_seconds)
# Guard: validate expiry window is positive and issued_at has valid timezone
if expires_at <= issued_at:
logging.error(
"event=power_intent_publish_error group_id=%s reason=invalid_expiry issued_at=%s expires_at=%s",
gid,
_to_utc_z(issued_at),
_to_utc_z(expires_at),
)
continue
issued_at_str = _to_utc_z(issued_at)
expires_at_str = _to_utc_z(expires_at)
# Guard: ensure Z suffix on timestamps (format validation)
if not issued_at_str.endswith("Z") or not expires_at_str.endswith("Z"):
logging.error(
"event=power_intent_publish_error group_id=%s reason=invalid_timestamp_format issued_at=%s expires_at=%s",
gid,
issued_at_str,
expires_at_str,
)
continue
payload_dict = {
**intent_body,
"intent_id": intent_id,
"issued_at": issued_at_str,
"expires_at": expires_at_str,
}
# Guard: ensure payload serialization succeeds before publishing
try:
payload = json.dumps(payload_dict, sort_keys=True, separators=(",", ":"))
except (TypeError, ValueError) as e:
logging.error(
"event=power_intent_publish_error group_id=%s reason=payload_serialization_error error=%s",
gid,
str(e),
)
continue
topic = f"infoscreen/groups/{gid}/power/intent"
result = client.publish(topic, payload, qos=1, retain=True)
result.wait_for_publish(timeout=5.0)
if result.rc != mqtt.MQTT_ERR_SUCCESS:
power_intent_metrics["publish_error_total"] += 1
logging.error(
"event=power_intent_publish_error group_id=%s desired_state=%s intent_id=%s "
"transition_publish=%s heartbeat_publish=%s topic=%s qos=1 retained=true rc=%s",
gid,
payload_dict.get("desired_state"),
intent_id,
is_transition_publish,
is_heartbeat_publish,
topic,
result.rc,
)
continue
last_power_intents[gid] = {
"fingerprint": fingerprint,
"intent_id": intent_id,
"payload": payload,
}
if is_transition_publish:
power_intent_metrics["intent_transitions_total"] += 1
if is_heartbeat_publish:
power_intent_metrics["heartbeat_republish_total"] += 1
power_intent_metrics["publish_success_total"] += 1
logging.info(
"event=power_intent_publish group_id=%s desired_state=%s reason=%s intent_id=%s "
"issued_at=%s expires_at=%s transition_publish=%s heartbeat_publish=%s "
"topic=%s qos=1 retained=true",
gid,
payload_dict.get("desired_state"),
payload_dict.get("reason"),
intent_id,
issued_at_str,
expires_at_str,
is_transition_publish,
is_heartbeat_publish,
topic,
)
def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in ("1", "true", "yes", "on")
# Logging-Konfiguration
from logging.handlers import RotatingFileHandler
@@ -35,7 +224,7 @@ def main():
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
client.reconnect_delay_set(min_delay=1, max_delay=30)
POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL_SECONDS", "30"))
# 0 = aus; z.B. 600 für alle 10 Min
# initial value from DB or fallback to env
try:
@@ -43,10 +232,35 @@ def main():
REFRESH_SECONDS = int(db_val) if db_val is not None else int(os.getenv("REFRESH_SECONDS", "0"))
except Exception:
REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0"))
# TV power intent (PR-1): group-level publishing is feature-flagged and disabled by default.
POWER_INTENT_PUBLISH_ENABLED = _env_bool("POWER_INTENT_PUBLISH_ENABLED", False)
POWER_INTENT_HEARTBEAT_ENABLED = _env_bool("POWER_INTENT_HEARTBEAT_ENABLED", True)
POWER_INTENT_EXPIRY_MULTIPLIER = int(os.getenv("POWER_INTENT_EXPIRY_MULTIPLIER", "3"))
POWER_INTENT_MIN_EXPIRY_SECONDS = int(os.getenv("POWER_INTENT_MIN_EXPIRY_SECONDS", "90"))
logging.info(
"Scheduler config: poll_interval=%ss refresh_seconds=%s power_intent_enabled=%s "
"power_intent_heartbeat=%s power_intent_expiry_multiplier=%s power_intent_min_expiry=%ss",
POLL_INTERVAL,
REFRESH_SECONDS,
POWER_INTENT_PUBLISH_ENABLED,
POWER_INTENT_HEARTBEAT_ENABLED,
POWER_INTENT_EXPIRY_MULTIPLIER,
POWER_INTENT_MIN_EXPIRY_SECONDS,
)
# Konfigurierbares Zeitfenster in Tagen (Standard: 7)
WINDOW_DAYS = int(os.getenv("EVENTS_WINDOW_DAYS", "7"))
last_payloads = {} # group_id -> payload
last_published_at = {} # group_id -> epoch seconds
last_power_intents = {} # group_id -> {fingerprint, intent_id, payload}
power_intent_metrics = {
"intent_transitions_total": 0,
"publish_success_total": 0,
"publish_error_total": 0,
"heartbeat_republish_total": 0,
"retained_republish_total": 0,
}
# Beim (Re-)Connect alle bekannten retained Payloads erneut senden
def on_connect(client, userdata, flags, reasonCode, properties=None):
@@ -56,6 +270,9 @@ def main():
topic = f"infoscreen/events/{gid}"
client.publish(topic, payload, retain=True)
if POWER_INTENT_PUBLISH_ENABLED:
_republish_cached_power_intents(client, last_power_intents, power_intent_metrics)
client.on_connect = on_connect
client.connect("mqtt", 1883)
@@ -150,6 +367,29 @@ def main():
del last_payloads[gid]
last_published_at.pop(gid, None)
if POWER_INTENT_PUBLISH_ENABLED:
_publish_group_power_intents(
client=client,
events=events,
now=now,
poll_interval=POLL_INTERVAL,
heartbeat_enabled=POWER_INTENT_HEARTBEAT_ENABLED,
expiry_multiplier=POWER_INTENT_EXPIRY_MULTIPLIER,
min_expiry_seconds=POWER_INTENT_MIN_EXPIRY_SECONDS,
last_power_intents=last_power_intents,
power_intent_metrics=power_intent_metrics,
)
logging.debug(
"event=power_intent_metrics intent_transitions_total=%s publish_success_total=%s "
"publish_error_total=%s heartbeat_republish_total=%s retained_republish_total=%s",
power_intent_metrics["intent_transitions_total"],
power_intent_metrics["publish_success_total"],
power_intent_metrics["publish_error_total"],
power_intent_metrics["heartbeat_republish_total"],
power_intent_metrics["retained_republish_total"],
)
time.sleep(POLL_INTERVAL)

View File

@@ -0,0 +1,191 @@
import json
import unittest
from datetime import datetime, timedelta, timezone
from scheduler.scheduler import (
_publish_group_power_intents,
_republish_cached_power_intents,
)
class _FakePublishResult:
def __init__(self, rc=0):
self.rc = rc
self.wait_timeout = None
def wait_for_publish(self, timeout=None):
self.wait_timeout = timeout
class _FakeMqttClient:
def __init__(self, rc=0):
self.rc = rc
self.calls = []
def publish(self, topic, payload, qos=0, retain=False):
result = _FakePublishResult(rc=self.rc)
self.calls.append(
{
"topic": topic,
"payload": payload,
"qos": qos,
"retain": retain,
"result": result,
}
)
return result
class PowerIntentSchedulerTests(unittest.TestCase):
def test_transition_then_heartbeat_reuses_intent_id(self):
client = _FakeMqttClient(rc=0)
last_power_intents = {}
metrics = {
"intent_transitions_total": 0,
"publish_success_total": 0,
"publish_error_total": 0,
"heartbeat_republish_total": 0,
"retained_republish_total": 0,
}
events = [
{
"id": 101,
"group_id": 12,
"start": "2026-03-31T10:00:00+00:00",
"end": "2026-03-31T10:30:00+00:00",
}
]
now_first = datetime(2026, 3, 31, 10, 5, 0, tzinfo=timezone.utc)
_publish_group_power_intents(
client=client,
events=events,
now=now_first,
poll_interval=15,
heartbeat_enabled=True,
expiry_multiplier=3,
min_expiry_seconds=90,
last_power_intents=last_power_intents,
power_intent_metrics=metrics,
)
first_payload = json.loads(client.calls[0]["payload"])
first_intent_id = first_payload["intent_id"]
now_second = now_first + timedelta(seconds=15)
_publish_group_power_intents(
client=client,
events=events,
now=now_second,
poll_interval=15,
heartbeat_enabled=True,
expiry_multiplier=3,
min_expiry_seconds=90,
last_power_intents=last_power_intents,
power_intent_metrics=metrics,
)
self.assertEqual(len(client.calls), 2)
second_payload = json.loads(client.calls[1]["payload"])
self.assertEqual(first_payload["desired_state"], "on")
self.assertEqual(second_payload["desired_state"], "on")
self.assertEqual(first_intent_id, second_payload["intent_id"])
self.assertEqual(client.calls[0]["topic"], "infoscreen/groups/12/power/intent")
self.assertEqual(client.calls[0]["qos"], 1)
self.assertTrue(client.calls[0]["retain"])
self.assertEqual(metrics["intent_transitions_total"], 1)
self.assertEqual(metrics["heartbeat_republish_total"], 1)
self.assertEqual(metrics["publish_success_total"], 2)
self.assertEqual(metrics["publish_error_total"], 0)
def test_state_change_creates_new_intent_id(self):
client = _FakeMqttClient(rc=0)
last_power_intents = {}
metrics = {
"intent_transitions_total": 0,
"publish_success_total": 0,
"publish_error_total": 0,
"heartbeat_republish_total": 0,
"retained_republish_total": 0,
}
events_on = [
{
"id": 88,
"group_id": 3,
"start": "2026-03-31T10:00:00+00:00",
"end": "2026-03-31T10:30:00+00:00",
}
]
now_on = datetime(2026, 3, 31, 10, 5, 0, tzinfo=timezone.utc)
_publish_group_power_intents(
client=client,
events=events_on,
now=now_on,
poll_interval=15,
heartbeat_enabled=True,
expiry_multiplier=3,
min_expiry_seconds=90,
last_power_intents=last_power_intents,
power_intent_metrics=metrics,
)
first_payload = json.loads(client.calls[0]["payload"])
events_off = [
{
"id": 88,
"group_id": 3,
"start": "2026-03-31T10:00:00+00:00",
"end": "2026-03-31T10:30:00+00:00",
}
]
now_off = datetime(2026, 3, 31, 10, 35, 0, tzinfo=timezone.utc)
_publish_group_power_intents(
client=client,
events=events_off,
now=now_off,
poll_interval=15,
heartbeat_enabled=True,
expiry_multiplier=3,
min_expiry_seconds=90,
last_power_intents=last_power_intents,
power_intent_metrics=metrics,
)
second_payload = json.loads(client.calls[1]["payload"])
self.assertNotEqual(first_payload["intent_id"], second_payload["intent_id"])
self.assertEqual(second_payload["desired_state"], "off")
self.assertEqual(metrics["intent_transitions_total"], 2)
def test_republish_cached_power_intents(self):
client = _FakeMqttClient(rc=0)
metrics = {
"intent_transitions_total": 0,
"publish_success_total": 0,
"publish_error_total": 0,
"heartbeat_republish_total": 0,
"retained_republish_total": 0,
}
cache = {
5: {
"fingerprint": "abc",
"intent_id": "intent-1",
"payload": '{"group_id":5,"desired_state":"on"}',
}
}
_republish_cached_power_intents(client, cache, metrics)
self.assertEqual(len(client.calls), 1)
self.assertEqual(client.calls[0]["topic"], "infoscreen/groups/5/power/intent")
self.assertEqual(client.calls[0]["qos"], 1)
self.assertTrue(client.calls[0]["retain"])
self.assertEqual(metrics["retained_republish_total"], 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,106 @@
import unittest
from datetime import datetime, timezone
from scheduler.db_utils import (
build_group_power_intent_body,
compute_group_power_intent_basis,
compute_group_power_intent_fingerprint,
)
class PowerIntentUtilsTests(unittest.TestCase):
def test_no_events_results_in_off(self):
now = datetime(2026, 3, 31, 10, 0, 0, tzinfo=timezone.utc)
basis = compute_group_power_intent_basis(events=[], group_id=7, now_utc=now)
self.assertEqual(basis["group_id"], 7)
self.assertEqual(basis["desired_state"], "off")
self.assertEqual(basis["reason"], "no_active_event")
self.assertIsNone(basis["event_window_start"])
self.assertIsNone(basis["event_window_end"])
def test_active_event_results_in_on(self):
now = datetime(2026, 3, 31, 10, 5, 0, tzinfo=timezone.utc)
events = [
{
"id": 101,
"group_id": 2,
"start": "2026-03-31T10:00:00+00:00",
"end": "2026-03-31T10:30:00+00:00",
}
]
basis = compute_group_power_intent_basis(events=events, group_id=2, now_utc=now)
self.assertEqual(basis["desired_state"], "on")
self.assertEqual(basis["reason"], "active_event")
self.assertEqual(basis["event_window_start"], "2026-03-31T10:00:00Z")
self.assertEqual(basis["event_window_end"], "2026-03-31T10:30:00Z")
self.assertEqual(basis["active_event_ids"], [101])
def test_adjacent_events_are_merged_without_off_blip(self):
now = datetime(2026, 3, 31, 10, 30, 0, tzinfo=timezone.utc)
events = [
{
"id": 1,
"group_id": 3,
"start": "2026-03-31T10:00:00+00:00",
"end": "2026-03-31T10:30:00+00:00",
},
{
"id": 2,
"group_id": 3,
"start": "2026-03-31T10:30:00+00:00",
"end": "2026-03-31T11:00:00+00:00",
},
]
basis = compute_group_power_intent_basis(events=events, group_id=3, now_utc=now)
self.assertEqual(basis["desired_state"], "on")
self.assertEqual(basis["event_window_start"], "2026-03-31T10:00:00Z")
self.assertEqual(basis["event_window_end"], "2026-03-31T11:00:00Z")
def test_true_gap_results_in_off(self):
now = datetime(2026, 3, 31, 10, 31, 0, tzinfo=timezone.utc)
events = [
{
"id": 1,
"group_id": 4,
"start": "2026-03-31T10:00:00+00:00",
"end": "2026-03-31T10:30:00+00:00",
},
{
"id": 2,
"group_id": 4,
"start": "2026-03-31T10:35:00+00:00",
"end": "2026-03-31T11:00:00+00:00",
},
]
basis = compute_group_power_intent_basis(events=events, group_id=4, now_utc=now)
self.assertEqual(basis["desired_state"], "off")
self.assertEqual(basis["reason"], "no_active_event")
def test_fingerprint_is_stable_for_same_semantics(self):
basis = {
"schema_version": "1.0",
"group_id": 9,
"desired_state": "on",
"reason": "active_event",
"event_window_start": "2026-03-31T10:00:00Z",
"event_window_end": "2026-03-31T10:30:00Z",
"active_event_ids": [12, 7],
}
body_a = build_group_power_intent_body(basis, poll_interval_sec=15)
body_b = build_group_power_intent_body(basis, poll_interval_sec=15)
fingerprint_a = compute_group_power_intent_fingerprint(body_a)
fingerprint_b = compute_group_power_intent_fingerprint(body_b)
self.assertEqual(fingerprint_a, fingerprint_b)
if __name__ == "__main__":
unittest.main()

365
test_power_intent_canary.py Normal file
View File

@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""
Manual canary validation helper for TV power-intent Phase 1 server publishing.
This script demonstrates expected power-intent payloads and validates the
computation and publishing logic without requiring a full broker connection.
Usage:
python test_power_intent_canary.py
"""
import json
import sys
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
sys.path.insert(0, '/workspace')
from scheduler.db_utils import (
compute_group_power_intent_basis,
build_group_power_intent_body,
compute_group_power_intent_fingerprint,
)
def utc_now():
"""Get UTC now."""
return datetime.now(timezone.utc)
def test_scenario_1_no_active_events():
"""Scenario 1: No active events => OFF intent."""
print("\n" + "="*70)
print("SCENARIO 1: No active events => desired_state=OFF")
print("="*70)
now = utc_now()
events = [] # empty group
group_id = 1
intent_basis = compute_group_power_intent_basis(
events=events,
group_id=group_id,
now_utc=now
)
desired_state = intent_basis["desired_state"]
reason = intent_basis["reason"]
assert desired_state == "off", f"Expected 'off', got '{desired_state}'"
assert reason == "no_active_event", f"Expected reason 'no_active_event', got '{reason}'"
payload_body = build_group_power_intent_body(intent_basis, poll_interval_sec=15)
print(f"✓ Desired state: {desired_state}")
print(f"✓ Reason: {reason}")
print(f"✓ Event window: {intent_basis.get('event_window_start')} to {intent_basis.get('event_window_end')}")
print(f"✓ Payload body (pretty-print):")
print(json.dumps(payload_body, indent=2))
# Validate payload structure
assert "desired_state" in payload_body
assert payload_body["desired_state"] == "off"
assert "group_id" in payload_body
print("✓ Payload structure validated")
def test_scenario_2_single_active_event():
"""Scenario 2: One active event now => ON intent."""
print("\n" + "="*70)
print("SCENARIO 2: One active event now => desired_state=ON")
print("="*70)
now = utc_now()
start = now - timedelta(seconds=60)
end = now + timedelta(seconds=300)
group_id = 1
events = [
{
"id": 101,
"group_id": group_id,
"start": start.isoformat(),
"end": end.isoformat(),
"subject": "Morning Announcement",
"event_type": "message",
}
]
intent_basis = compute_group_power_intent_basis(
events=events,
group_id=group_id,
now_utc=now
)
desired_state = intent_basis["desired_state"]
reason = intent_basis["reason"]
assert desired_state == "on", f"Expected 'on', got '{desired_state}'"
assert reason == "active_event", f"Expected reason 'active_event', got '{reason}'"
payload_body = build_group_power_intent_body(intent_basis, poll_interval_sec=15)
print(f"✓ Desired state: {desired_state}")
print(f"✓ Reason: {reason}")
print(f"✓ event_window_start: {intent_basis.get('event_window_start')}")
print(f"✓ event_window_end: {intent_basis.get('event_window_end')}")
print(f"✓ Payload body (pretty-print):")
print(json.dumps(payload_body, indent=2))
assert payload_body["desired_state"] == "on"
print("✓ Payload structure validated")
def test_scenario_3_adjacent_events_no_off_blip():
"""Scenario 3: Adjacent events (no gap) => no OFF blip between them."""
print("\n" + "="*70)
print("SCENARIO 3: Adjacent events => no OFF between them")
print("="*70)
# Event 1: ends at T+300
# Event 2: starts at T+300 (adjacent, no gap)
base = utc_now()
group_id = 2
events_at_boundary = [
{
"id": 201,
"group_id": group_id,
"start": (base + timedelta(seconds=0)).isoformat(),
"end": (base + timedelta(seconds=300)).isoformat(),
"subject": "Event 1",
"event_type": "presentation",
},
{
"id": 202,
"group_id": group_id,
"start": (base + timedelta(seconds=300)).isoformat(),
"end": (base + timedelta(seconds=600)).isoformat(),
"subject": "Event 2",
"event_type": "presentation",
},
]
# Sample times: before, at boundary, and after
scenarios = [
("Before boundary (Event 1 active)", base + timedelta(seconds=150)),
("At boundary (no gap)", base + timedelta(seconds=300)),
("After boundary (Event 2 active)", base + timedelta(seconds=450)),
]
for label, sample_time in scenarios:
intent_basis = compute_group_power_intent_basis(
events=events_at_boundary,
group_id=group_id,
now_utc=sample_time
)
desired_state = intent_basis["desired_state"]
reason = intent_basis["reason"]
print(f"\n {label}:")
print(f" Desired state: {desired_state}")
print(f" Reason: {reason}")
assert desired_state == "on", f"Expected 'on' at {label}, got '{desired_state}'"
print("\n✓ All boundary times show 'on' => no OFF blip between adjacent events")
def test_scenario_4_gap_between_events():
"""Scenario 4: Gap between events => OFF when not covered."""
print("\n" + "="*70)
print("SCENARIO 4: Gap between events => OFF during gap")
print("="*70)
base = utc_now()
group_id = 3
events_with_gap = [
{
"id": 301,
"group_id": group_id,
"start": (base + timedelta(seconds=0)).isoformat(),
"end": (base + timedelta(seconds=300)).isoformat(),
"subject": "Event 1",
"event_type": "presentation",
},
{
"id": 302,
"group_id": group_id,
"start": (base + timedelta(seconds=600)).isoformat(),
"end": (base + timedelta(seconds=900)).isoformat(),
"subject": "Event 2",
"event_type": "presentation",
},
]
# Sample during gap: T+450 is between end(300) and start(600)
gap_time = base + timedelta(seconds=450)
intent_basis = compute_group_power_intent_basis(
events=events_with_gap,
group_id=group_id,
now_utc=gap_time
)
desired_state = intent_basis["desired_state"]
reason = intent_basis["reason"]
print(f"Sample time: {gap_time.isoformat()}")
print(f"Desired state: {desired_state}")
print(f"Reason: {reason}")
assert desired_state == "off", f"Expected 'off' during gap, got '{desired_state}'"
print("✓ Correctly recognizes gap => OFF")
def test_scenario_5_semantic_fingerprint_stable():
"""Scenario 5: Semantic fingerprint is stable for unchanged state."""
print("\n" + "="*70)
print("SCENARIO 5: Semantic fingerprint stability (transition detection)")
print("="*70)
payload_body_1 = {
"schema_version": "1.0",
"group_id": 5,
"desired_state": "on",
"reason": "active_event",
"poll_interval_sec": 15,
"event_window_start": "2026-03-31T20:15:00Z",
"event_window_end": "2026-03-31T20:20:00Z",
"active_event_ids": [501],
}
payload_body_2 = {
"schema_version": "1.0",
"group_id": 5,
"desired_state": "on",
"reason": "active_event",
"poll_interval_sec": 15,
"event_window_start": "2026-03-31T20:15:00Z",
"event_window_end": "2026-03-31T20:20:00Z",
"active_event_ids": [501],
}
payload_body_3_different = {
"schema_version": "1.0",
"group_id": 5,
"desired_state": "off", # Changed
"reason": "no_active_event",
"poll_interval_sec": 15,
"event_window_start": None,
"event_window_end": None,
"active_event_ids": [],
}
fp1 = compute_group_power_intent_fingerprint(payload_body_1)
fp2 = compute_group_power_intent_fingerprint(payload_body_2)
fp3 = compute_group_power_intent_fingerprint(payload_body_3_different)
print(f"Payload 1 (on, event X): {fp1}")
print(f"Payload 2 (on, same event X): {fp2}")
print(f"Payload 3 (off, no event): {fp3}")
assert fp1 == fp2, "Identical payloads should have same fingerprint"
assert fp1 != fp3, "Different desired_state should have different fingerprint"
print("✓ Fingerprint is stable for same state (no spurious transitions)")
print("✓ Fingerprint changes on semantic transition")
def test_scenario_6_timestamp_format_validation():
"""Scenario 6: Payload body contains window start/end in UTC Z format."""
print("\n" + "="*70)
print("SCENARIO 6: Event window timestamp format validation")
print("="*70)
now = utc_now()
group_id = 6
events = [
{
"id": 601,
"group_id": group_id,
"start": (now - timedelta(seconds=60)).isoformat(),
"end": (now + timedelta(seconds=300)).isoformat(),
"subject": "Event",
"event_type": "message",
}
]
intent_basis = compute_group_power_intent_basis(
events=events,
group_id=group_id,
now_utc=now
)
window_start = intent_basis.get("event_window_start")
window_end = intent_basis.get("event_window_end")
print(f"Event window start: {window_start}")
print(f"Event window end: {window_end}")
if window_start:
assert window_start.endswith("Z"), f"event_window_start must end with Z: {window_start}"
if window_end:
assert window_end.endswith("Z"), f"event_window_end must end with Z: {window_end}"
# Validate they are valid RFC3339 timestamps
try:
if window_start:
dt_start = datetime.fromisoformat(window_start.replace("Z", "+00:00"))
if window_end:
dt_end = datetime.fromisoformat(window_end.replace("Z", "+00:00"))
if window_start:
assert dt_end > dt_start, "window_end must be after window_start"
print("✓ Event window timestamps are valid RFC3339 UTC format with Z suffix")
except Exception as e:
print(f"✗ Timestamp parsing failed: {e}")
raise
def main():
"""Run all scenarios."""
print("\n" + "="*70)
print("TV POWER INTENT PHASE-1 SERVER CANARY VALIDATION")
print("="*70)
print("\nThis script validates server-side power-intent computation logic")
print("without requiring an MQTT broker connection.\n")
try:
test_scenario_1_no_active_events()
test_scenario_2_single_active_event()
test_scenario_3_adjacent_events_no_off_blip()
test_scenario_4_gap_between_events()
test_scenario_5_semantic_fingerprint_stable()
test_scenario_6_timestamp_format_validation()
print("\n" + "="*70)
print("ALL CANARY SCENARIOS PASSED ✓")
print("="*70)
print("\nNext steps for full validation:")
print("1. Enable POWER_INTENT_PUBLISH_ENABLED=true in scheduler")
print("2. Subscribe to infoscreen/groups/+/power/intent in MQTT broker")
print("3. Run scheduler and observe:")
print(" - ON payload on event start")
print(" - Same intent_id across heartbeat republishes")
print(" - OFF payload on event end")
print(" - No OFF blip between adjacent events")
print("4. Restart scheduler and verify immediate ON republish")
print("5. Disconnect MQTT broker and verify reconnect republish")
print("\nSee TV_POWER_CANARY_VALIDATION_CHECKLIST.md for full validation matrix.")
return 0
except AssertionError as e:
print(f"\n✗ VALIDATION FAILED: {e}")
return 1
except Exception as e:
print(f"\n✗ UNEXPECTED ERROR: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())