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
164 lines
5.3 KiB
Markdown
164 lines
5.3 KiB
Markdown
# 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
|