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.
9.2 KiB
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
{
"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
{
"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
{"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_TTLis 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, apiv1 - the message is called
Parent Caller, not "Parent Call" - its tokens are
NameandRoom, capitalised POST /v1/message/{uuid}/triggerreturns204GET /v1/message/{uuid}/clearreturns204
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:
docker compose exec parent-call python -m parent_call.probe
docker compose exec parent-call python -m parent_call.probe --trigger
Running it
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:
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
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 notoff - 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
messagefield as written; it is already the wording a volunteer should read - stop listening once the status is terminal