shrink root README into a landing page with a docs map and focused contributor guidance add TV_POWER_RUNBOOK as the canonical TV power rollout and canary runbook add CHANGELOG and move project history out of README-style docs refactor src README into a developer-focused guide (architecture, runtime files, MQTT, debugging) prune redundant older HDMI docs and keep a canonical HDMI_CEC_SETUP path update copilot instructions to a high-signal policy format with strict anti-shadow-README design rules align references across docs to current files, scripts, and TV power behavior
5.3 KiB
5.3 KiB
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.
{
"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)
{
"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)
{
"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)
- Subscribe to
infoscreen/groups/{own_group_id}/power/intentat QoS 1 on connect. - Re-subscribe on reconnect — broker retained message will deliver last known intent immediately.
- Parse
desired_stateand apply TV power action (on→ power on /off→ power off). - Deduplicate using
intent_id— if sameintent_idreceived again, skip re-applying power command. - Check expiry — if
now > expires_at, treat as stale and fall back tooffuntil renewed. - Ignore unknown fields — for forward compatibility with Phase 2 additions.
- 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