Files
Ekklesia f6c3bac732 Parent Call coordinator
Sits between the classrooms, Companion and ProPresenter, and owns the
truth about a parent call: validation, ids, the queue, expiry,
duplicates, mode, approval, delivery, and every status a classroom is
told. Companion is an operator surface and holds no state.

Built after querying the church's own ProPresenter rather than working
from the assumed contract, which turned out to be wrong in two ways: the
message is called Parent Caller, not Parent Call, and its tokens are
Name and Room rather than child and room.

The finding that shaped the design is recorded in
docs/propresenter-api.md. A trigger carrying a token name the message
does not have still returns 204, and still puts the message on screen
showing the previous call's values. So a 204 is not evidence on its own:
the adapter reads the live message and refuses to trigger unless the
configured tokens exist in it. Without that, a typo in .env would call
the wrong child's parents and report success.

Nothing claims more than it knows. A successful API call is reported as
sent to sanctuary, never as displayed.

The state machine is pure and synchronous, with time passed in and
effects returned, so the awkward cases are testable properly: two rooms
calling at once, an approval for the wrong id, an approval arriving
after the request went stale, and a clear timer firing after the next
call has gone up. The queue is in memory and dies with the process,
because a parent call surfacing ten minutes late would be worse than one
that was lost.

81 tests, then the whole thing end to end on Control: a request through
the real broker and the real coordinator to the real ProPresenter, with
the correct child and room on the sanctuary screen, the coordinator's
own clear timer taking it down again, mode off sending nothing, and a
duplicate id not sending twice.
2026-09-06 15:15:11 -05:00

245 lines
9.2 KiB
Markdown

# 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/<request-id>` | 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": "<the id from pending>"}` 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/<its own request id>` before publishing
- show the `message` field as written; it is already the wording a
volunteer should read
- stop listening once the status is terminal