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.
245 lines
9.2 KiB
Markdown
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
|