merge: feat/tv-power-server-pr1 into main

This commit is contained in:
2026-04-01 08:07:37 +00:00
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) - 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`) - 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) - 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) ## What to update (and where)
- `.github/copilot-instructions.md` - `.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) - **Format**: Integer (group ID)
- **Purpose**: Assigns clients to groups - **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 ## Message Structure
### General Principles ### General Principles

View File

@@ -96,6 +96,40 @@ make pull-prod
make up-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 ## Documentation Map
### Deployment ### 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 './index.css';
import App from './App.tsx'; import App from './App.tsx';
import { AuthProvider } from './useAuth'; 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-base/styles/material3.css';
import '@syncfusion/ej2-navigations/styles/material3.css'; import '@syncfusion/ej2-navigations/styles/material3.css';
import '@syncfusion/ej2-buttons/styles/material3.css'; import '@syncfusion/ej2-buttons/styles/material3.css';
@@ -28,6 +28,51 @@ registerLicense(
'ORg4AjUWIQA/Gnt3VVhhQlJDfV5AQmBIYVp/TGpJfl96cVxMZVVBJAtUQF1hTH5VdENiXX1dcHxUQWNVWkd2' '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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<AuthProvider> <AuthProvider>

View File

@@ -169,7 +169,13 @@ services:
environment: environment:
# HINZUGEFÜGT: Datenbank-Verbindungsstring # HINZUGEFÜGT: Datenbank-Verbindungsstring
- DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME} - DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}
- MQTT_BROKER_URL=mqtt
- MQTT_PORT=1883 - 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: networks:
- infoscreen-net - infoscreen-net

View File

@@ -2,6 +2,8 @@
from dotenv import load_dotenv from dotenv import load_dotenv
import os import os
from datetime import datetime from datetime import datetime
import hashlib
import json
import logging import logging
from sqlalchemy.orm import sessionmaker, joinedload from sqlalchemy.orm import sessionmaker, joinedload
from sqlalchemy import create_engine, or_, and_, text 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() 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): def format_event_with_media(event):
"""Transform Event + EventMedia into client-expected format""" """Transform Event + EventMedia into client-expected format"""
event_dict = { event_dict = {

View File

@@ -2,11 +2,200 @@
import os import os
import logging 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 paho.mqtt.client as mqtt
import json import json
import datetime import datetime
import time 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 # Logging-Konfiguration
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
@@ -35,7 +224,7 @@ def main():
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
client.reconnect_delay_set(min_delay=1, max_delay=30) 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 # 0 = aus; z.B. 600 für alle 10 Min
# initial value from DB or fallback to env # initial value from DB or fallback to env
try: 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")) REFRESH_SECONDS = int(db_val) if db_val is not None else int(os.getenv("REFRESH_SECONDS", "0"))
except Exception: except Exception:
REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0")) 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) # Konfigurierbares Zeitfenster in Tagen (Standard: 7)
WINDOW_DAYS = int(os.getenv("EVENTS_WINDOW_DAYS", "7")) WINDOW_DAYS = int(os.getenv("EVENTS_WINDOW_DAYS", "7"))
last_payloads = {} # group_id -> payload last_payloads = {} # group_id -> payload
last_published_at = {} # group_id -> epoch seconds 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 # Beim (Re-)Connect alle bekannten retained Payloads erneut senden
def on_connect(client, userdata, flags, reasonCode, properties=None): def on_connect(client, userdata, flags, reasonCode, properties=None):
@@ -56,6 +270,9 @@ def main():
topic = f"infoscreen/events/{gid}" topic = f"infoscreen/events/{gid}"
client.publish(topic, payload, retain=True) 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.on_connect = on_connect
client.connect("mqtt", 1883) client.connect("mqtt", 1883)
@@ -150,6 +367,29 @@ def main():
del last_payloads[gid] del last_payloads[gid]
last_published_at.pop(gid, None) 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) 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())