# Parent Call coordinator A teacher in a classroom types a child's name and presses a button. This service decides what happens next, and tells the classroom the truth about it. It sits between three things: ``` classroom (MQTT) -> coordinator -> ProPresenter (sanctuary screen) ^ | Companion (operator) ``` The coordinator owns the truth: validation, ids, the queue, expiry, duplicates, mode, approval, delivery, and every status a classroom is told. Companion is an operator surface, nothing more — it never holds queue state. ## The rule this is built around **A successful ProPresenter API call means "sent to sanctuary". It never means "displayed."** Nobody here has proof that pixels reached a wall, so nothing claims it. See `docs/propresenter-api.md` for the discovery that made this more than a wording preference. ## MQTT contract Root is `TOPIC_ROOT`, default `classroom/parent-call`. No product name appears in a topic. | Topic | Retained | Who publishes | What | | --- | --- | --- | --- | | `…/mode` | **yes** | coordinator | `auto` \| `approval` \| `off`, as plain text | | `…/mode/set` | no | operator | asks for a mode; the coordinator makes it retained truth | | `…/request` | no | classroom | a parent call | | `…/status/` | no | coordinator | what happened to that request | | `…/pending` | **yes** | coordinator | head of the queue, or an empty state | | `…/approve` | no | operator | `{"id": "…"}` | | `…/reject` | no | operator | `{"id": "…"}` | | `…/coordinator` | **yes** | coordinator | `{"online": true}`, with a last will behind it | Retained is used only for **state** — mode, the pending head, whether the coordinator is up. Requests and statuses are **events** and are never retained, so nothing from last Sunday can replay into this one. ### Request ```json { "id": "nursery-8f21c3", "room": "nursery", "child": "Emma", "created_at": 1780000000 } ``` Those four fields are the entire vocabulary a classroom has. Anything else in the payload is discarded. A classroom cannot supply ProPresenter text, a message name, a theme, or any other instruction — the coordinator builds the ProPresenter payload itself from values that passed validation. ### Status ```json { "id": "nursery-8f21c3", "room": "nursery", "status": "waiting_approval", "message": "Waiting for approval", "updated_at": 1780000000 } ``` | status | message | means | | --- | --- | --- | | `submitted` | Sending | accepted, being routed | | `waiting_approval` | Waiting for approval | queued for the operator | | `sending` | Sending to sanctuary | going to ProPresenter now | | `sent_to_sanctuary` | Sent to sanctuary | ProPresenter accepted it | | `rejected` | Not approved | the operator said no | | `expired` | Expired before it was sent | too old to deliver | | `unavailable` | Parent Call is unavailable | mode is off | | `error` | Could not reach the sanctuary | it did not go out | ### Pending, for the operator ```json {"id": "nursery-8f21c3", "room": "nursery", "child": "Emma", "created_at": 1780000000, "waiting": 2, "updated_at": 1780000000} ``` and when the queue is empty, the same shape with nulls and `"waiting": 0` — a full object rather than an empty payload, so a Companion variable bound to `child` clears instead of keeping the last name on the operator's screen. ## State machine ``` off ──────────────► unavailable │ request ─► validate ──┤ auto ──────────► sending ─► sent_to_sanctuary │ │ │ │ └─► error └─► (clear timer) │ └ approval ─► waiting_approval ─┬─ approve ─► sending ├─ reject ──► rejected └─ age ─────► expired ``` - Every request needs a unique id. A repeat of an id already seen is answered with the outcome it already had and is **never delivered twice**. - The queue is FIFO, and approval is **always by id**. Two rooms calling at the same moment cannot approve each other's child. - A request older than `REQUEST_TTL` is never delivered — checked when it arrives, again on a timer while it waits, and again at the moment of approval, so an operator returning after ten minutes cannot put a stale call on the screen. - Turning the mode off empties the queue and tells each waiting classroom, rather than holding requests nobody will approve. - **The queue never survives a restart.** It is in memory, nothing is written to disk, and a reconnect does not resurrect anything. A parent call surfacing ten minutes late is worse than one that was lost. ### Delivery One attempt, no automatic retry. That is the whole policy, chosen so that nothing stale can ever arrive late — a teacher who sees an error can press the button again, which is honest and immediate. On success the coordinator schedules its own clear after `PROPRESENTER_CLEAR_AFTER` seconds; each delivery carries a serial, and a clear only fires if it is still the newest one, so an older timer cannot wipe a newer call. ## Companion contract Companion needs four things and owns none of them. **Show the mode** — subscribe to `…/mode`, a plain string. **Change the mode** — publish `auto`, `approval` or `off` to `…/mode/set`. To cycle, read the current value and publish the next. **Show that someone is waiting** — subscribe to `…/pending` and bind `child`, `room` and `waiting`. `waiting > 0` is the "someone is waiting" lamp; the button can show `$(pending:child)`. **Approve or reject** — publish `{"id": ""}` to `…/approve` or `…/reject`. A bare id as plain text is accepted too. The id is required on purpose. "Approve whatever is showing" would be a race the moment two rooms call together, and the thing at stake is which family gets called out of a service. ## ProPresenter Verified against the church's own machine — see `docs/propresenter-api.md` for the full record. In short: - ProPresenter 21.3 at `10.30.0.2:1025`, api `v1` - the message is called **`Parent Caller`**, not "Parent Call" - its tokens are **`Name`** and **`Room`**, capitalised - `POST /v1/message/{uuid}/trigger` returns `204` - `GET /v1/message/{uuid}/clear` returns `204` **An unknown token name also returns `204`** and puts the message on screen showing the previous call's values. So the adapter reads the live message and refuses to trigger unless the configured tokens really exist in it. A misconfiguration is reported as an error, never as a call that went out. Without that check, a typo in `.env` would call the wrong child's parents and tell the teacher it worked. Check the sanctuary end at any time: ```bash docker compose exec parent-call python -m parent_call.probe docker compose exec parent-call python -m parent_call.probe --trigger ``` ## Running it ```bash cd /home/media/docker/parent-call cp .env.example .env # already points at this church's equipment docker compose up -d --build docker compose logs -f curl -s localhost:8099/health | python3 -m json.tool ``` Parent Call arrives **off**. Turn it on when the classroom side is ready: ```bash mosquitto_pub -h 10.40.0.2 -t classroom/parent-call/mode/set -m approval ``` ### Health `/health` returns 200 whenever the coordinator itself is working, and reports the broker and last delivery in the body. It deliberately does **not** fail when the broker or ProPresenter are down: Docker would restart the container, which fixes neither and throws away the queue on the way past. ## Failure behaviour | What breaks | What happens | | --- | --- | | Broker down at startup | Coordinator starts, retries 2→60s, logs the loss once | | Broker disappears | Requests keep ageing and expiring; nothing is replayed on reconnect | | ProPresenter asleep | `error` to the classroom, never `sent_to_sanctuary` | | ProPresenter renamed the message | Refused, reported as an error | | Token names wrong | Refused before triggering | | Coordinator restarts | Mode recovered from the retained topic; queue starts empty | | Malformed payload | Logged and dropped; the service keeps running | ## Tests ```bash python3 -m venv .venv && .venv/bin/pip install -r requirements.txt pytest .venv/bin/python -m pytest tests -q ``` 81 tests: the state machine on its own, validation, the ProPresenter adapter against a mock that reproduces the real 204-on-unknown-token behaviour, and the whole coordinator against a real broker on a throwaway topic root. ## What the classroom still needs The contract above is frozen. A classroom canary needs to: - read `…/mode` (retained) and offer Parent Call only when it is not `off` - read `…/coordinator` (retained) to know the sanctuary end is up - publish a request with a unique id — room is the hostname, so the teacher never picks it - subscribe to `…/status/` before publishing - show the `message` field as written; it is already the wording a volunteer should read - stop listening once the status is terminal