Files
Ekkleksia_Parent_Call/README.md
T
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

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_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:

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 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