# 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
