From f6c3bac732f961704e8b651340cacbb67824334f Mon Sep 17 00:00:00 2001 From: Ekklesia Date: Sun, 6 Sep 2026 15:15:11 -0500 Subject: [PATCH] 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. --- .env.example | 53 +++++ .gitignore | 5 + Dockerfile | 23 ++ README.md | 244 +++++++++++++++++++++ compose.yml | 24 +++ docs/propresenter-api.md | 89 ++++++++ requirements.txt | 1 + src/parent_call/__init__.py | 0 src/parent_call/config.py | 110 ++++++++++ src/parent_call/contract.py | 142 ++++++++++++ src/parent_call/coordinator.py | 360 +++++++++++++++++++++++++++++++ src/parent_call/health.py | 43 ++++ src/parent_call/main.py | 71 ++++++ src/parent_call/probe.py | 102 +++++++++ src/parent_call/propresenter.py | 184 ++++++++++++++++ src/parent_call/state.py | 230 ++++++++++++++++++++ src/parent_call/validation.py | 140 ++++++++++++ tests/conftest.py | 4 + tests/test_coordinator.py | 370 ++++++++++++++++++++++++++++++++ tests/test_propresenter.py | 180 ++++++++++++++++ tests/test_state.py | 307 ++++++++++++++++++++++++++ tests/test_validation.py | 132 ++++++++++++ 22 files changed, 2814 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yml create mode 100644 docs/propresenter-api.md create mode 100644 requirements.txt create mode 100644 src/parent_call/__init__.py create mode 100644 src/parent_call/config.py create mode 100644 src/parent_call/contract.py create mode 100644 src/parent_call/coordinator.py create mode 100644 src/parent_call/health.py create mode 100644 src/parent_call/main.py create mode 100644 src/parent_call/probe.py create mode 100644 src/parent_call/propresenter.py create mode 100644 src/parent_call/state.py create mode 100644 src/parent_call/validation.py create mode 100644 tests/conftest.py create mode 100644 tests/test_coordinator.py create mode 100644 tests/test_propresenter.py create mode 100644 tests/test_state.py create mode 100644 tests/test_validation.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd7a5d0 --- /dev/null +++ b/.env.example @@ -0,0 +1,53 @@ +# Parent Call coordinator — copy to .env and edit. +# Do not commit .env. + +# --- MQTT ------------------------------------------------------------- +MQTT_HOST=10.40.0.2 +MQTT_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= +MQTT_CLIENT_ID=parent-call-coordinator + +# Topic root. No product name: the broker is the church's. +TOPIC_ROOT=classroom/parent-call + +# --- ProPresenter ----------------------------------------------------- +# The Mac running ProPresenter, and the port from its Network preferences. +PROPRESENTER_BASE_URL=http://10.30.0.2:1025 + +# The Message in ProPresenter this triggers, and the token names inside it. +# Verified on this church's ProPresenter 21.3 on 2026-09-06: +# the message is called "Parent Caller" and its tokens are +# capitalised. See docs/propresenter-api.md. +PROPRESENTER_MESSAGE=Parent Caller +PROPRESENTER_CHILD_TOKEN=Name +PROPRESENTER_ROOM_TOKEN=Room + +# How long to wait on ProPresenter before calling it unreachable. +PROPRESENTER_TIMEOUT=4 + +# Clear the message automatically after this many seconds. 0 disables, +# and leaves clearing to the operator. +PROPRESENTER_CLEAR_AFTER=20 + +# How each room reads on the sanctuary screen. The message says +# "...please go to the {Room}", so these want to be room names as a +# person would say them. Unlisted rooms are title-cased. +ROOM_NAMES=nursery=Nursery,k2=K-2,older=Older Kids + +# --- Behaviour -------------------------------------------------------- +# A request older than this is never delivered. +REQUEST_TTL=180 + +# Longest child name and room name accepted from a classroom. +MAX_CHILD_LENGTH=40 +MAX_ROOM_LENGTH=32 + +# How far ahead of us a classroom clock may be before we distrust it. +MAX_CLOCK_SKEW=120 + +# Mode when no retained mode exists on the broker yet. +DEFAULT_MODE=approval + +# --- Health ----------------------------------------------------------- +HEALTH_PORT=8099 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f5cea2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +.coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..502b020 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +# curl only so the container can answer its own healthcheck. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ + +ENV PYTHONPATH=/app/src \ + PYTHONUNBUFFERED=1 + +# Nothing is written to disk. The queue lives in memory on purpose: if +# Control loses power, the parent calls must die with it. +RUN useradd --system --no-create-home coordinator +USER coordinator + +CMD ["python", "-m", "parent_call.main"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..75e0e28 --- /dev/null +++ b/README.md @@ -0,0 +1,244 @@ +# 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 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..d58d777 --- /dev/null +++ b/compose.yml @@ -0,0 +1,24 @@ +services: + parent-call: + build: . + container_name: parent-call + restart: unless-stopped + env_file: .env + network_mode: host + + # Asks only whether the coordinator itself is working. A broker or a + # sanctuary Mac that is down is reported in the body, but must not + # restart the container: restarting would fix neither and would throw + # away the queue on the way past. + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${HEALTH_PORT:-8099}/health"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/docs/propresenter-api.md b/docs/propresenter-api.md new file mode 100644 index 0000000..edc3a59 --- /dev/null +++ b/docs/propresenter-api.md @@ -0,0 +1,89 @@ +# ProPresenter API, as actually observed + +Recorded against the church's own machine on 2026-09-06, not from +documentation. Re-run `parent-call-probe` to refresh this. + +``` +Host 10.30.0.2:1025 (the MacBook, "MacBook-Pro") +ProPresenter 21.3 +Platform mac, macOS 26.6.2 +api_version v1 +``` + +## Endpoints that exist + +| Request | Result | +| --- | --- | +| `GET /version` | `200` + JSON. Note: **not** `/v1/version`, which 404s | +| `GET /v1/messages` | `200` + array of messages | +| `GET /v1/message/{id}` | `200` + one message | +| `POST /v1/message/{id}/trigger` | **`204`**, ~34ms | +| `GET /v1/message/{id}/clear` | **`204`**, ~7ms | +| `POST /v1/message/{unknown-uuid}/trigger` | `404` | + +`{id}` accepts either the uuid or the URL-encoded name — both returned +`204`. The coordinator uses the uuid, resolved from the name, so that +renaming the message in ProPresenter is noticed rather than silently +changing which message is triggered. + +## The message that already exists + +The spec assumed a message called `Parent Call` with tokens `child` and +`room`. The real one is different, which is why this was checked: + +``` +name Parent Caller +uuid 7D044452-B5A1-49F5-AC9C-1FD53A9A7353 +index 2 +text {Name}'s parent please go to the {Room} +tokens Name, Room <- capitalised + visible_on_network: true +``` + +Nothing was created or renamed. The coordinator is configured to match +what is there. + +Because the template reads "...please go to the {Room}", the room token +wants a human room name — "Nursery", not the `nursery` slug the topic +uses. The coordinator maps slug to display name, configurably. + +## The finding that shaped the design + +**An unknown token name is silently accepted.** + +``` +POST /v1/message/{uuid}/trigger +[{"name":"child","text":{"text":"SHOULD NOT APPEAR"}}] + +-> HTTP 204 +-> message goes on screen showing the PREVIOUS token values +``` + +Verified deliberately, with a known baseline set and cleared first, and +a 2 second settle before reading state. + +Two things follow, and both are in the adapter: + +1. **A 204 does not mean the right thing was displayed.** If the token + names were misconfigured, every parent call would return 204 — so the + teacher would be told "Sent to sanctuary" — while the sanctuary + screen showed *the previous child's name and room*. In a church that + is the worst failure this system could have: the wrong family walks + out. So the coordinator reads the live message definition and refuses + to trigger unless the configured token names actually exist in it. A + mismatch is reported as an error and never as success. + +2. **Token values persist between triggers.** Every trigger therefore + sends both tokens, always, so a call can never inherit half of the + one before it. + +## Timing + +The API returns before its own state settles: reading `/v1/messages` +immediately after a `204` can still show the previous values, and the +new ones appear within about a second. Nothing in the coordinator reads +back state to confirm a trigger, so this only matters to anyone +debugging by hand — do not believe a read taken in the same second. + +`is_active` goes `true` on trigger and `false` on clear, and token +values survive a clear. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..96a2716 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +paho-mqtt==2.1.0 diff --git a/src/parent_call/__init__.py b/src/parent_call/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/parent_call/config.py b/src/parent_call/config.py new file mode 100644 index 0000000..9c1641e --- /dev/null +++ b/src/parent_call/config.py @@ -0,0 +1,110 @@ +"""Settings, from the environment. + +Everything the coordinator needs to talk to this church's equipment is +here. Nothing about the church is hardcoded anywhere else. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +def _int(name, default): + try: + return int(os.environ.get(name, "") or default) + except (TypeError, ValueError): + return default + + +def _float(name, default): + try: + return float(os.environ.get(name, "") or default) + except (TypeError, ValueError): + return default + + +def _str(name, default=""): + return (os.environ.get(name) or default).strip() + + +def _room_names(raw): + """nursery=Nursery,k2=K-2 -> {"nursery": "Nursery", "k2": "K-2"} + + The ProPresenter message reads "...please go to the {Room}", so the + token wants a room as a person would say it, not the topic slug. + """ + mapping = {} + + for pair in (raw or "").split(","): + slug, sep, display = pair.partition("=") + + if not sep: + continue + + slug = slug.strip().lower() + display = display.strip() + + if slug and display: + mapping[slug] = display + + return mapping + + +@dataclass +class Settings: + mqtt_host: str = "127.0.0.1" + mqtt_port: int = 1883 + mqtt_username: str = "" + mqtt_password: str = "" + mqtt_client_id: str = "parent-call-coordinator" + topic_root: str = "classroom/parent-call" + + propresenter_base_url: str = "" + propresenter_message: str = "Parent Caller" + propresenter_child_token: str = "Name" + propresenter_room_token: str = "Room" + propresenter_timeout: float = 4.0 + propresenter_clear_after: float = 20.0 + + request_ttl: float = 180.0 + max_child_length: int = 40 + max_room_length: int = 32 + max_clock_skew: float = 120.0 + default_mode: str = "approval" + + room_names: dict = field(default_factory=dict) + + health_port: int = 8099 + + def room_display(self, room): + """How a room should read on the sanctuary screen.""" + if room in self.room_names: + return self.room_names[room] + + # nursery -> Nursery, older-kids -> Older Kids + return " ".join(part.capitalize() for part in room.replace("_", "-").split("-")) + + +def from_environment(): + return Settings( + mqtt_host=_str("MQTT_HOST", "127.0.0.1"), + mqtt_port=_int("MQTT_PORT", 1883), + mqtt_username=_str("MQTT_USERNAME"), + mqtt_password=os.environ.get("MQTT_PASSWORD", ""), + mqtt_client_id=_str("MQTT_CLIENT_ID", "parent-call-coordinator"), + topic_root=_str("TOPIC_ROOT", "classroom/parent-call"), + propresenter_base_url=_str("PROPRESENTER_BASE_URL").rstrip("/"), + propresenter_message=_str("PROPRESENTER_MESSAGE", "Parent Caller"), + propresenter_child_token=_str("PROPRESENTER_CHILD_TOKEN", "Name"), + propresenter_room_token=_str("PROPRESENTER_ROOM_TOKEN", "Room"), + propresenter_timeout=_float("PROPRESENTER_TIMEOUT", 4.0), + propresenter_clear_after=_float("PROPRESENTER_CLEAR_AFTER", 20.0), + request_ttl=_float("REQUEST_TTL", 180.0), + max_child_length=_int("MAX_CHILD_LENGTH", 40), + max_room_length=_int("MAX_ROOM_LENGTH", 32), + max_clock_skew=_float("MAX_CLOCK_SKEW", 120.0), + default_mode=_str("DEFAULT_MODE", "approval"), + room_names=_room_names(_str("ROOM_NAMES")), + health_port=_int("HEALTH_PORT", 8099), + ) diff --git a/src/parent_call/contract.py b/src/parent_call/contract.py new file mode 100644 index 0000000..ba74453 --- /dev/null +++ b/src/parent_call/contract.py @@ -0,0 +1,142 @@ +"""The MQTT contract. + +Topic names and payload shapes, in one place, so the coordinator, the +tests and the classroom are all describing the same thing. + +No product name appears in a topic. The broker is the church's and these +are just rooms. +""" + +from __future__ import annotations + +# Statuses a classroom can be told. The wording matters: these end up in +# front of a volunteer, and none of them may claim more than we know. +SUBMITTED = "submitted" +WAITING_APPROVAL = "waiting_approval" +SENDING = "sending" +SENT_TO_SANCTUARY = "sent_to_sanctuary" +REJECTED = "rejected" +EXPIRED = "expired" +UNAVAILABLE = "unavailable" +ERROR = "error" + +STATUSES = ( + SUBMITTED, + WAITING_APPROVAL, + SENDING, + SENT_TO_SANCTUARY, + REJECTED, + EXPIRED, + UNAVAILABLE, + ERROR, +) + +# What the teacher actually reads. "Sent to sanctuary" is the strongest +# claim we are entitled to make: a ProPresenter API call that succeeded +# tells us the message was accepted, not that anyone saw it. +WORDING = { + SUBMITTED: "Sending", + WAITING_APPROVAL: "Waiting for approval", + SENDING: "Sending to sanctuary", + SENT_TO_SANCTUARY: "Sent to sanctuary", + REJECTED: "Not approved", + EXPIRED: "Expired before it was sent", + UNAVAILABLE: "Parent Call is unavailable", + ERROR: "Could not reach the sanctuary", +} + +MODE_AUTO = "auto" +MODE_APPROVAL = "approval" +MODE_OFF = "off" +MODES = (MODE_AUTO, MODE_APPROVAL, MODE_OFF) + + +class Topics: + """Every topic the coordinator uses, derived from one root.""" + + def __init__(self, root: str = "classroom/parent-call"): + self.root = root.rstrip("/") + + @property + def mode(self) -> str: + """Retained. auto | approval | off. Plain text, so Companion can + bind it to a variable without parsing anything.""" + return f"{self.root}/mode" + + @property + def mode_set(self) -> str: + """Where an operator asks for a different mode.""" + return f"{self.root}/mode/set" + + @property + def request(self) -> str: + """Never retained. A request is an event.""" + return f"{self.root}/request" + + @property + def pending(self) -> str: + """Retained coordinator-owned state: the head of the queue, or an + explicitly empty state. Retained so Companion shows the right + thing the moment it connects.""" + return f"{self.root}/pending" + + @property + def approve(self) -> str: + return f"{self.root}/approve" + + @property + def reject(self) -> str: + return f"{self.root}/reject" + + @property + def coordinator(self) -> str: + """Retained availability, with the broker's last will behind it.""" + return f"{self.root}/coordinator" + + def status(self, request_id: str) -> str: + """Never retained. A status is an event about one request.""" + return f"{self.root}/status/{request_id}" + + def subscriptions(self) -> tuple[str, ...]: + return (self.request, self.approve, self.reject, self.mode, self.mode_set) + + +def status_payload(request_id, room, status, now, detail=None): + body = { + "id": request_id, + "room": room, + "status": status, + "message": WORDING.get(status, status), + "updated_at": int(now), + } + + if detail: + body["detail"] = detail + + return body + + +def pending_payload(request, waiting, now): + """The head of the queue, or an explicitly empty state. + + Always a full object rather than an empty payload: Companion binds + variables to these fields, and a zero-length message would leave the + last request's name on the operator's screen. + """ + if request is None: + return { + "id": None, + "room": None, + "child": None, + "waiting": 0, + "updated_at": int(now), + } + + return { + "id": request.id, + "room": request.room, + "child": request.child, + "created_at": int(request.created_at), + "waiting": waiting, + "updated_at": int(now), + } diff --git a/src/parent_call/coordinator.py b/src/parent_call/coordinator.py new file mode 100644 index 0000000..2565e11 --- /dev/null +++ b/src/parent_call/coordinator.py @@ -0,0 +1,360 @@ +"""Wiring: the broker on one side, ProPresenter on the other. + +The state machine decides everything; this only carries messages to it +and performs the effects it returns. Anything slow — an HTTP call to +ProPresenter — happens on a worker thread, so a sanctuary machine that +has gone to sleep cannot stall the broker connection or the queue. +""" + +from __future__ import annotations + +import json +import logging +import queue +import threading +import time + +import paho.mqtt.client as mqtt + +from . import contract +from .propresenter import ProPresenterError +from .state import ClearNow, Deliver, ParentCall, PublishPending, PublishStatus, ScheduleClear +from .validation import Invalid, parse + +log = logging.getLogger("parent-call") + +TICK_SECONDS = 5 +RECONNECT_MIN = 2 +RECONNECT_MAX = 60 + + +class Coordinator: + def __init__(self, settings, propresenter): + self.settings = settings + self.propresenter = propresenter + self.topics = contract.Topics(settings.topic_root) + + self.state = ParentCall(settings) + self.lock = threading.RLock() + self.work = queue.Queue() + + self.connected = False + self.last_delivery = None + self.stopping = threading.Event() + + self.client = self._build_client() + + # -- broker -------------------------------------------------------- + + def _build_client(self): + client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=self.settings.mqtt_client_id, + protocol=mqtt.MQTTv311, + # No persistent session: a parent call that was in flight + # when the broker went away must not be delivered later. + clean_session=True, + ) + + if self.settings.mqtt_username: + client.username_pw_set( + self.settings.mqtt_username, self.settings.mqtt_password + ) + + client.will_set( + self.topics.coordinator, + json.dumps({"online": False}), + qos=1, + retain=True, + ) + + client.on_connect = self._on_connect + client.on_disconnect = self._on_disconnect + client.on_message = self._on_message + client.reconnect_delay_set(min_delay=RECONNECT_MIN, max_delay=RECONNECT_MAX) + + return client + + def _on_connect(self, client, userdata, flags, reason_code, properties=None): + if reason_code != 0: + log.warning("broker refused the connection: %s", reason_code) + return + + self.connected = True + + for topic in self.topics.subscriptions(): + client.subscribe(topic, qos=1) + + client.publish( + self.topics.coordinator, + json.dumps({"online": True, "since": int(time.time())}), + qos=1, + retain=True, + ) + + # The queue does not survive a reconnect, so say so plainly: + # whatever an operator was looking at before is gone. + with self.lock: + self._run([self.state._pending()]) + + # Not the mode: the retained one arrives a moment later and + # may not be this one. _on_mode says so when it lands. + log.info("connected to the broker") + + def _on_disconnect(self, client, userdata, flags, reason_code=None, properties=None): + was_connected = self.connected + self.connected = False + + if was_connected and not self.stopping.is_set(): + log.warning("lost the broker; retrying quietly until it returns") + + def _on_message(self, client, userdata, message): + try: + self._handle(message) + except Exception: + # A bad message is never worth taking the coordinator down. + log.exception("dropped a message on %s", message.topic) + + # -- routing ------------------------------------------------------- + + def _handle(self, message): + topic = message.topic + now = time.time() + + if topic == self.topics.request: + self._on_request(message.payload, now) + elif topic == self.topics.approve: + self._on_decision(message.payload, now, approve=True) + elif topic == self.topics.reject: + self._on_decision(message.payload, now, approve=False) + elif topic in (self.topics.mode, self.topics.mode_set): + self._on_mode(message.payload, now, echo=topic == self.topics.mode_set) + + def _on_request(self, payload, now): + try: + request = parse(payload, now, self.settings) + except Invalid as invalid: + # Without a usable id there is no topic to answer on, so the + # only thing to do is say so in the log. + if invalid.request_id: + log.info("rejected %s: %s", invalid.request_id, invalid.reason) + with self.lock: + self._run([ + PublishStatus( + invalid.request_id, + invalid.room or "", + contract.ERROR, + invalid.reason, + ) + ]) + else: + log.info("ignored an unusable request: %s", invalid.reason) + return + + log.info("request %s from %s for %r", request.id, request.room, request.child) + + with self.lock: + self._run(self.state.submit(request, now)) + + def _on_decision(self, payload, now, approve): + request_id = self._decision_id(payload) + + if not request_id: + log.info("ignored an %s with no id", "approve" if approve else "reject") + return + + with self.lock: + if approve: + effects = self.state.approve(request_id, now) + else: + effects = self.state.reject(request_id, now) + + if not effects: + log.info("nothing queued with id %s", request_id) + return + + log.info("%s %s", "approved" if approve else "rejected", request_id) + self._run(effects) + + @staticmethod + def _decision_id(payload): + """Accept {"id": "..."} or a bare id, whichever Companion sends.""" + if isinstance(payload, (bytes, bytearray)): + try: + payload = payload.decode("utf-8") + except UnicodeDecodeError: + return None + + payload = (payload or "").strip() + + if not payload: + return None + + if payload.startswith("{"): + try: + data = json.loads(payload) + except ValueError: + return None + + value = data.get("id") if isinstance(data, dict) else None + return value.strip() if isinstance(value, str) else None + + return payload + + def _on_mode(self, payload, now, echo): + if isinstance(payload, (bytes, bytearray)): + payload = payload.decode("utf-8", "replace") + + mode = (payload or "").strip().strip('"').lower() + + if mode not in contract.MODES: + log.info("ignored an unknown mode %r", mode) + return + + with self.lock: + changed = mode != self.state.mode + effects = self.state.set_mode(mode, now) + + if changed: + log.info("mode is %s", mode) + + self._run(effects) + + if echo: + # The operator asked; make it the retained truth. + self.client.publish(self.topics.mode, mode, qos=1, retain=True) + + # -- effects ------------------------------------------------------- + + def _run(self, effects): + for effect in effects: + if isinstance(effect, PublishStatus): + self._publish_status(effect) + elif isinstance(effect, PublishPending): + self._publish_pending(effect) + elif isinstance(effect, Deliver): + self.work.put(("deliver", effect)) + elif isinstance(effect, ScheduleClear): + self._schedule_clear(effect) + elif isinstance(effect, ClearNow): + self.work.put(("clear", effect)) + + def _publish_status(self, effect): + body = contract.status_payload( + effect.request_id, effect.room, effect.status, time.time(), effect.detail + ) + # Never retained: a status is an event about one request, and a + # retained one would tell the next reader about a call that is + # long finished. + self.client.publish( + self.topics.status(effect.request_id), + json.dumps(body), + qos=1, + retain=False, + ) + log.info("%s -> %s", effect.request_id, effect.status) + + def _publish_pending(self, effect): + body = contract.pending_payload(effect.request, effect.waiting, time.time()) + self.client.publish( + self.topics.pending, json.dumps(body), qos=1, retain=True + ) + + def _schedule_clear(self, effect): + timer = threading.Timer( + effect.after, lambda: self._clear_due(effect.serial) + ) + timer.daemon = True + timer.start() + + def _clear_due(self, serial): + with self.lock: + self._run(self.state.clear_due(serial)) + + # -- the slow half ------------------------------------------------- + + def worker(self): + while not self.stopping.is_set(): + try: + kind, effect = self.work.get(timeout=0.5) + except queue.Empty: + continue + + try: + if kind == "deliver": + self._deliver(effect) + elif kind == "clear": + self._clear(effect) + except Exception: + log.exception("work item failed") + finally: + self.work.task_done() + + def _deliver(self, effect): + request = effect.request + display = self.settings.room_display(request.room) + + ok = True + detail = None + + try: + self.propresenter.trigger(request.child, display) + except ProPresenterError as error: + ok = False + detail = str(error) + log.warning("could not deliver %s: %s", request.id, error) + + self.last_delivery = { + "id": request.id, + "ok": ok, + "detail": detail, + "at": int(time.time()), + } + + with self.lock: + self._run( + self.state.delivered(request, effect.serial, ok, detail, time.time()) + ) + + def _clear(self, effect): + try: + self.propresenter.clear() + log.info("cleared the sanctuary message") + except ProPresenterError as error: + log.warning("could not clear the message: %s", error) + + # -- ageing -------------------------------------------------------- + + def ticker(self): + while not self.stopping.wait(TICK_SECONDS): + with self.lock: + self._run(self.state.tick(time.time())) + + # -- lifecycle ----------------------------------------------------- + + def health(self): + with self.lock: + waiting = len(self.state.queue) + mode = self.state.mode + + return { + "alive": True, + "broker_connected": self.connected, + "mode": mode, + "waiting": waiting, + "last_delivery": self.last_delivery, + } + + def start(self): + threading.Thread(target=self.worker, daemon=True).start() + threading.Thread(target=self.ticker, daemon=True).start() + + self.client.connect_async( + self.settings.mqtt_host, self.settings.mqtt_port, keepalive=45 + ) + self.client.loop_start() + + def stop(self): + self.stopping.set() + self.client.loop_stop() + self.client.disconnect() diff --git a/src/parent_call/health.py b/src/parent_call/health.py new file mode 100644 index 0000000..decc80b --- /dev/null +++ b/src/parent_call/health.py @@ -0,0 +1,43 @@ +"""A health endpoint that answers the question Docker is actually asking. + +Docker wants to know whether to restart this container. The answer is +yes only if the coordinator itself is broken — not if the broker or +ProPresenter are having a bad morning. Restarting the coordinator would +not fix either, and would throw away the queue while doing it. + +So this returns 200 whenever the process is alive and its threads are +running, and reports the state of everything else in the body for a +human to read. +""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + + +def serve(coordinator, port): + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + if self.path not in ("/health", "/"): + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + + body = json.dumps(coordinator.health()).encode() + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + httpd = HTTPServer(("0.0.0.0", port), Handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + + return httpd diff --git a/src/parent_call/main.py b/src/parent_call/main.py new file mode 100644 index 0000000..61cbbe0 --- /dev/null +++ b/src/parent_call/main.py @@ -0,0 +1,71 @@ +"""Run the coordinator.""" + +from __future__ import annotations + +import logging +import signal +import sys +import threading + +from . import config, health +from .coordinator import Coordinator +from .propresenter import ProPresenter, ProPresenterError + +log = logging.getLogger("parent-call") + + +def main(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", + ) + + settings = config.from_environment() + + propresenter = ProPresenter( + settings.propresenter_base_url, + settings.propresenter_message, + settings.propresenter_child_token, + settings.propresenter_room_token, + settings.propresenter_timeout, + ) + + coordinator = Coordinator(settings, propresenter) + + log.info( + "broker %s:%s, root %s", + settings.mqtt_host, settings.mqtt_port, settings.topic_root, + ) + + # Say at startup whether the sanctuary end is right, because the + # alternative is finding out during a service. Not fatal: the broker + # half is still worth running, and it will be retried per call. + try: + version = propresenter.version().get("host_description", "?") + message = propresenter.check() + log.info( + "ProPresenter %s, message %r with tokens %s", + version, message.name, ", ".join(message.tokens), + ) + except ProPresenterError as error: + log.warning("ProPresenter is not ready: %s", error) + + coordinator.start() + health.serve(coordinator, settings.health_port) + + stop = threading.Event() + + for name in (signal.SIGTERM, signal.SIGINT): + signal.signal(name, lambda *_: stop.set()) + + stop.wait() + + log.info("stopping") + coordinator.stop() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/parent_call/probe.py b/src/parent_call/probe.py new file mode 100644 index 0000000..9cb20c6 --- /dev/null +++ b/src/parent_call/probe.py @@ -0,0 +1,102 @@ +"""Check this church's ProPresenter, without guessing. + + python -m parent_call.probe # look, change nothing + python -m parent_call.probe --trigger # also put test text on screen + +Reads the settings the coordinator would use, so it answers the question +that matters: would a parent call work right now, with this config? +""" + +from __future__ import annotations + +import sys +import time + +from . import config +from .propresenter import ProPresenter, ProPresenterError + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + settings = config.from_environment() + + pp = ProPresenter( + settings.propresenter_base_url, + settings.propresenter_message, + settings.propresenter_child_token, + settings.propresenter_room_token, + settings.propresenter_timeout, + ) + + print(f"ProPresenter at {settings.propresenter_base_url}") + + try: + version = pp.version() + except ProPresenterError as error: + print(f" UNREACHABLE: {error}") + print("\n Is the Mac awake, and is ProPresenter running with its") + print(" network interface enabled?") + return 1 + + print(f" {version.get('host_description')} on {version.get('name')}" + f" ({version.get('platform')} {version.get('os_version')})," + f" api {version.get('api_version')}") + + try: + message = pp.find_message(refresh=True) + except ProPresenterError as error: + print(f"\n NO USABLE MESSAGE: {error}") + return 1 + + print(f"\nMessage {message.name!r}") + print(f" uuid {message.uuid}") + print(f" tokens {', '.join(message.tokens) or 'none'}") + + wanted = (settings.propresenter_child_token, settings.propresenter_room_token) + missing = [token for token in wanted if token not in message.tokens] + + print(f"\nConfigured tokens: child={wanted[0]!r} room={wanted[1]!r}") + + if missing: + print(f" MISMATCH: this message has no {', '.join(missing)}") + print(" A parent call would be refused rather than sent, which is") + print(" correct — ProPresenter accepts an unknown token with a 204") + print(" and shows the previous call instead. Fix the token names in") + print(" .env, or rename the tokens in ProPresenter.") + return 1 + + print(" both present") + + if "--trigger" not in argv: + print("\nLooks right. Add --trigger to put test text on the screen.") + return 0 + + child = "API TEST - please ignore" + room = settings.room_display("nursery") + + print(f"\nTriggering with {child!r} / {room!r}") + + try: + pp.trigger(child, room) + except ProPresenterError as error: + print(f" TRIGGER FAILED: {error}") + return 1 + + print(" accepted (204). It is on screen now.") + + time.sleep(3) + + try: + pp.clear() + print(" cleared") + except ProPresenterError as error: + print(f" CLEAR FAILED: {error}") + return 1 + + print("\nParent Call would work with this configuration.") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/parent_call/propresenter.py b/src/parent_call/propresenter.py new file mode 100644 index 0000000..80ab85a --- /dev/null +++ b/src/parent_call/propresenter.py @@ -0,0 +1,184 @@ +"""Talking to ProPresenter. + +Deliberately small, and deliberately suspicious of its own success. See +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 whatever the previous call left in it. + +So a 204 on its own is not evidence. Before triggering, the adapter +reads the live message and checks that the tokens it is about to send +actually exist. If they do not, that is a configuration error reported +as an error, never as a call that went out. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass + + +class ProPresenterError(Exception): + """Something that means the call did not go out.""" + + +@dataclass +class Message: + uuid: str + name: str + tokens: tuple + + +class ProPresenter: + def __init__(self, base_url, message_name, child_token, room_token, timeout=4.0): + self.base_url = (base_url or "").rstrip("/") + self.message_name = message_name + self.child_token = child_token + self.room_token = room_token + self.timeout = timeout + + # Resolved once and reused, but dropped whenever anything looks + # wrong, so renaming the message in ProPresenter is noticed. + self._message = None + + # -- plumbing ------------------------------------------------------ + + def _request(self, method, path, body=None): + if not self.base_url: + raise ProPresenterError("no ProPresenter address configured") + + url = f"{self.base_url}{path}" + data = None + headers = {} + + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = urllib.request.Request(url, data=data, headers=headers, method=method) + + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + payload = response.read() + return response.status, payload + except urllib.error.HTTPError as error: + return error.code, error.read() + except (urllib.error.URLError, OSError) as error: + raise ProPresenterError(f"cannot reach ProPresenter: {error}") + + # -- the message --------------------------------------------------- + + def version(self): + status, payload = self._request("GET", "/version") + + if status != 200: + raise ProPresenterError(f"/version returned {status}") + + return json.loads(payload) + + def find_message(self, refresh=False): + """The configured message, with the token names it really has.""" + if self._message is not None and not refresh: + return self._message + + status, payload = self._request("GET", "/v1/messages") + + if status != 200: + raise ProPresenterError(f"/v1/messages returned {status}") + + try: + messages = json.loads(payload) + except ValueError: + raise ProPresenterError("/v1/messages did not return json") + + wanted = self.message_name.strip().casefold() + + for message in messages: + identity = message.get("id") or {} + name = str(identity.get("name", "")) + + if name.strip().casefold() != wanted: + continue + + self._message = Message( + uuid=str(identity.get("uuid", "")), + name=name, + tokens=tuple( + str(token.get("name", "")) for token in message.get("tokens", []) + ), + ) + + return self._message + + available = ", ".join( + str((m.get("id") or {}).get("name", "?")) for m in messages + ) + + raise ProPresenterError( + f"no message named {self.message_name!r} in ProPresenter " + f"(it has: {available or 'none'})" + ) + + def check(self): + """Everything that must be true before a call can be delivered.""" + message = self.find_message(refresh=True) + + missing = [ + token + for token in (self.child_token, self.room_token) + if token not in message.tokens + ] + + if missing: + raise ProPresenterError( + f"message {message.name!r} has no token(s) {', '.join(missing)} " + f"(it has: {', '.join(message.tokens) or 'none'})" + ) + + return message + + # -- doing the thing ----------------------------------------------- + + def trigger(self, child, room_display): + """Put a parent call on the sanctuary screen. + + Both tokens are always sent. ProPresenter keeps the previous + values of any token that is not included, so a partial trigger + would show half of the call before it. + """ + try: + message = self.check() + except ProPresenterError: + # One retry with a fresh read, in case the message was edited + # while we were holding a stale copy of it. + self._message = None + message = self.check() + + body = [ + {"name": self.child_token, "text": {"text": child}}, + {"name": self.room_token, "text": {"text": room_display}}, + ] + + status, payload = self._request( + "POST", f"/v1/message/{urllib.parse.quote(message.uuid)}/trigger", body + ) + + if status // 100 != 2: + self._message = None + raise ProPresenterError(f"trigger returned {status}") + + return status + + def clear(self): + message = self.find_message() + + status, _ = self._request( + "GET", f"/v1/message/{urllib.parse.quote(message.uuid)}/clear" + ) + + if status // 100 != 2: + raise ProPresenterError(f"clear returned {status}") + + return status diff --git a/src/parent_call/state.py b/src/parent_call/state.py new file mode 100644 index 0000000..92f6a86 --- /dev/null +++ b/src/parent_call/state.py @@ -0,0 +1,230 @@ +"""Who owns the truth about a parent call. + +Pure and synchronous: every input is a method call carrying the current +time, and every output is an Effect for someone else to perform. No +broker, no HTTP, no clock of its own — which is what makes the awkward +cases (two rooms calling at once, an approval arriving after expiry, a +clear timer firing after the next call) testable properly. + +Deliberately in memory only. If Control loses power mid-service, the +queue must die with it: a parent call that surfaces ten minutes late is +worse than one that was lost. +""" + +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass + +from . import contract +from .validation import Request + + +# -- effects ----------------------------------------------------------- + +@dataclass +class PublishStatus: + request_id: str + room: str + status: str + detail: str = None + + +@dataclass +class PublishPending: + request: Request + waiting: int + + +@dataclass +class Deliver: + """Send this to ProPresenter. serial identifies this delivery.""" + request: Request + serial: int + + +@dataclass +class ScheduleClear: + serial: int + after: float + + +@dataclass +class ClearNow: + serial: int + + +class ParentCall: + def __init__(self, settings, mode=None): + self.settings = settings + self.mode = mode if mode in contract.MODES else settings.default_mode + self.queue = [] + self.serial = 0 + + # What each id ended up as, so a classroom that retries the same + # request gets the same answer instead of a second call going out. + self.outcomes = OrderedDict() + self.remember = 512 + + # -- helpers ------------------------------------------------------- + + def _record(self, request_id, room, status): + self.outcomes[request_id] = (room, status) + self.outcomes.move_to_end(request_id) + + while len(self.outcomes) > self.remember: + self.outcomes.popitem(last=False) + + def _status(self, request_id, room, status, detail=None): + self._record(request_id, room, status) + return PublishStatus(request_id, room, status, detail) + + def _pending(self): + head = self.queue[0] if self.queue else None + return PublishPending(head, len(self.queue)) + + def _start_delivery(self, request): + self.serial += 1 + return [ + self._status(request.id, request.room, contract.SENDING), + Deliver(request, self.serial), + ] + + def _too_old(self, request, now): + return (now - request.created_at) > self.settings.request_ttl + + # -- inputs -------------------------------------------------------- + + def set_mode(self, mode, now): + """Modes are operator state and survive a restart, because they + are retained on the broker rather than held here.""" + if mode not in contract.MODES or mode == self.mode: + return [] + + self.mode = mode + + # Turning Parent Call off empties the queue rather than holding + # requests that no one is going to approve. + if mode != contract.MODE_APPROVAL and self.queue: + effects = [ + self._status( + held.id, held.room, contract.UNAVAILABLE, + "Parent Call was switched off", + ) + for held in self.queue + ] + self.queue = [] + return effects + [self._pending()] + + return [] + + def submit(self, request, now): + # A repeat of an id we have already answered gets the same answer + # again and is never delivered twice. Classrooms retry. + if request.id in self.outcomes: + room, status = self.outcomes[request.id] + return [PublishStatus(request.id, room, status, "already received")] + + if self._too_old(request, now): + return [self._status(request.id, request.room, contract.EXPIRED)] + + if self.mode == contract.MODE_OFF: + return [self._status(request.id, request.room, contract.UNAVAILABLE)] + + received = self._status(request.id, request.room, contract.SUBMITTED) + + if self.mode == contract.MODE_AUTO: + return [received] + self._start_delivery(request) + + self.queue.append(request) + + return [ + received, + self._status(request.id, request.room, contract.WAITING_APPROVAL), + self._pending(), + ] + + def approve(self, request_id, now): + """Approve one identified request. + + By id, never "whatever is showing", so that two rooms calling at + the same moment cannot approve each other's child. + """ + request = self._take(request_id) + + if request is None: + return [] + + # An approval that arrives after the request went stale must not + # put a ten-minute-old call on the screen. + if self._too_old(request, now): + return [ + self._status(request.id, request.room, contract.EXPIRED), + self._pending(), + ] + + return self._start_delivery(request) + [self._pending()] + + def reject(self, request_id, now): + request = self._take(request_id) + + if request is None: + return [] + + return [ + self._status(request.id, request.room, contract.REJECTED), + self._pending(), + ] + + def _take(self, request_id): + for index, queued in enumerate(self.queue): + if queued.id == request_id: + return self.queue.pop(index) + + return None + + def tick(self, now): + """Age the queue. Runs on its own timer, so requests keep expiring + while the broker is away.""" + stale = [held for held in self.queue if self._too_old(held, now)] + + if not stale: + return [] + + self.queue = [held for held in self.queue if not self._too_old(held, now)] + + effects = [ + self._status(held.id, held.room, contract.EXPIRED) for held in stale + ] + + return effects + [self._pending()] + + def delivered(self, request, serial, ok, detail, now): + """What ProPresenter said. + + A successful API call means the sanctuary accepted the message. + It does not mean anybody saw it, so the strongest thing we say is + "sent to sanctuary". + """ + if not ok: + return [self._status(request.id, request.room, contract.ERROR, detail)] + + effects = [self._status(request.id, request.room, contract.SENT_TO_SANCTUARY)] + + if self.settings.propresenter_clear_after > 0: + effects.append( + ScheduleClear(serial, self.settings.propresenter_clear_after) + ) + + return effects + + def clear_due(self, serial): + """A clear timer fired. + + Only the newest delivery may clear the screen: an older timer + firing after a second call has gone up must not wipe it. + """ + if serial != self.serial: + return [] + + return [ClearNow(serial)] diff --git a/src/parent_call/validation.py b/src/parent_call/validation.py new file mode 100644 index 0000000..da2f4cc --- /dev/null +++ b/src/parent_call/validation.py @@ -0,0 +1,140 @@ +"""The boundary where a classroom stops being trusted. + +A classroom supplies four small fields and nothing else. Everything that +reaches ProPresenter is built by the coordinator from values that got +through here, so this is the only place classroom input is ever read. +""" + +from __future__ import annotations + +import json +import re +import unicodedata +from dataclasses import dataclass + +# Unicode categories that must never survive into a name on a sanctuary +# screen: control and format characters (which covers zero-width joiners +# and bidi overrides), unassigned, surrogate and private-use, and every +# kind of space and line separator. +INVISIBLE = frozenset(("Cc", "Cf", "Cn", "Co", "Cs", "Zl", "Zp", "Zs")) + +# A room is a topic segment and a ProPresenter token value both. +ROOM_SHAPE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") + +# An id ends up in a topic, so it may not contain topic syntax. +ID_SHAPE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") + + +class Invalid(Exception): + """A request that will never be delivered, and why.""" + + def __init__(self, reason, request_id=None, room=None): + super().__init__(reason) + self.reason = reason + self.request_id = request_id + self.room = room + + +@dataclass(frozen=True) +class Request: + id: str + room: str + child: str + created_at: float + + +def visible(value): + """The same text with everything invisible turned into plain spaces. + + Turned into spaces rather than deleted, so a name with a newline in + it reads as two words instead of one welded together, and then the + runs are collapsed so spacing cannot be used to push text around a + screen. + """ + swapped = "".join( + " " if unicodedata.category(char) in INVISIBLE else char + for char in value + ) + + return " ".join(swapped.split()) + + +def _text(value, field, limit): + if not isinstance(value, str): + raise Invalid(f"{field} is not text") + + # Normalise first: some clients send decomposed accents, and the + # length limit should mean the same thing for everyone. + value = visible(unicodedata.normalize("NFC", value)) + + if not value: + raise Invalid(f"{field} is empty") + + if len(value) > limit: + raise Invalid(f"{field} is longer than {limit} characters") + + return value + + +def parse(payload, now, settings): + """Turn a raw MQTT payload into a Request, or raise Invalid. + + Nothing here trusts a field it has not checked, and nothing is + carried through that the coordinator did not ask for: a payload with + extra keys loses them. + """ + if isinstance(payload, (bytes, bytearray)): + try: + payload = payload.decode("utf-8") + except UnicodeDecodeError: + raise Invalid("payload is not utf-8") + + try: + data = json.loads(payload) + except (TypeError, ValueError): + raise Invalid("payload is not json") + + if not isinstance(data, dict): + raise Invalid("payload is not an object") + + # The id comes first: without a usable one there is no topic to + # answer on, so it is the single failure a classroom cannot be told + # about. + raw_id = data.get("id") + + if not isinstance(raw_id, str): + raise Invalid("id is missing") + + request_id = visible(raw_id).replace(" ", "") + + if not request_id or len(request_id) > 96 or not ID_SHAPE.match(request_id): + raise Invalid("id is not a usable identifier") + + # From here on a failure can be reported back, so carry the id along. + try: + room = _text(data.get("room"), "room", settings.max_room_length).lower() + + if not ROOM_SHAPE.match(room): + raise Invalid("room is not a room name") + + child = _text(data.get("child"), "child", settings.max_child_length) + + created_at = data.get("created_at") + + if isinstance(created_at, bool) or not isinstance(created_at, (int, float)): + raise Invalid("created_at is missing") + + # How old is too old is policy, and the state machine owns it — + # it has to make the same judgement again when an approval + # arrives late. All that matters here is that the timestamp is + # not obvious nonsense. + if (now - created_at) < -settings.max_clock_skew: + raise Invalid("created_at is in the future") + + except Invalid as invalid: + invalid.request_id = request_id + raw_room = data.get("room") + invalid.room = visible(raw_room).lower() if isinstance(raw_room, str) else None + raise + + return Request(id=request_id, room=room, child=child, created_at=float(created_at)) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..99f1af1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..e42a993 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,370 @@ +"""The whole coordinator, against a real broker and a mock ProPresenter. + +Uses Control's own Mosquitto, on a throwaway topic root so nothing here +can touch the real classroom/parent-call topics. +""" + +import json +import os +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, HTTPServer + +import paho.mqtt.client as mqtt +import pytest + +from parent_call import contract +from parent_call.config import Settings +from parent_call.coordinator import Coordinator +from parent_call.propresenter import ProPresenter + +BROKER = os.environ.get("TEST_MQTT_HOST", "127.0.0.1") +PORT = int(os.environ.get("TEST_MQTT_PORT", "1883")) +UUID = "7D044452-B5A1-49F5-AC9C-1FD53A9A7353" + + +def broker_available(): + import socket + try: + with socket.create_connection((BROKER, PORT), timeout=2): + return True + except OSError: + return False + + +pytestmark = pytest.mark.skipif( + not broker_available(), reason=f"no broker at {BROKER}:{PORT}" +) + + +class FakePP(BaseHTTPRequestHandler): + triggers = [] + clears = [] + status = 204 + tokens = ["Name", "Room"] + + def log_message(self, *args): + pass + + def _send(self, code, body=None): + self.send_response(code) + if body is None: + self.send_header("Content-Length", "0") + self.end_headers() + return + raw = json.dumps(body).encode() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + if self.path == "/v1/messages": + return self._send(200, [{ + "id": {"name": "Parent Caller", "uuid": UUID}, + "tokens": [{"name": t, "text": {"text": ""}} for t in FakePP.tokens], + }]) + if self.path.endswith("/clear"): + FakePP.clears.append(time.time()) + return self._send(204) + return self._send(404) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + FakePP.triggers.append(json.loads(self.rfile.read(length) or b"[]")) + return self._send(FakePP.status) + + +class Listener: + """A test classroom/operator: publishes and collects what comes back.""" + + def __init__(self, root): + self.root = root + self.seen = [] + self.lock = threading.Lock() + self.client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=f"test-{uuid.uuid4().hex[:8]}", + clean_session=True, + ) + self.client.on_message = self._on_message + self.client.connect(BROKER, PORT, 30) + self.client.subscribe(f"{root}/#", qos=1) + self.client.loop_start() + + def _on_message(self, client, userdata, message): + with self.lock: + self.seen.append((message.topic, message.payload.decode(), message.retain)) + + def publish(self, topic, payload, retain=False): + self.client.publish(topic, payload, qos=1, retain=retain).wait_for_publish(5) + + def statuses(self, request_id): + prefix = f"{self.root}/status/{request_id}" + with self.lock: + return [json.loads(p)["status"] for t, p, _ in self.seen if t == prefix] + + def wait_for(self, predicate, timeout=8): + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.05) + return False + + def latest(self, topic): + with self.lock: + for t, p, _ in reversed(self.seen): + if t == topic: + return json.loads(p) if p.startswith("{") else p + return None + + def stop(self): + self.client.loop_stop() + self.client.disconnect() + + +@pytest.fixture +def rig(): + root = f"test-parent-call/{uuid.uuid4().hex[:8]}" + + FakePP.triggers = [] + FakePP.clears = [] + FakePP.status = 204 + FakePP.tokens = ["Name", "Room"] + + httpd = HTTPServer(("127.0.0.1", 0), FakePP) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{httpd.server_port}" + + settings = Settings( + mqtt_host=BROKER, mqtt_port=PORT, + mqtt_client_id=f"coord-{uuid.uuid4().hex[:8]}", + topic_root=root, + propresenter_base_url=base, + propresenter_clear_after=0, + request_ttl=180, + default_mode="approval", + room_names={"nursery": "Nursery"}, + ) + + listener = Listener(root) + coordinator = Coordinator( + settings, + ProPresenter(base, "Parent Caller", "Name", "Room", timeout=3), + ) + coordinator.start() + + assert listener.wait_for(lambda: coordinator.connected), "coordinator never connected" + + yield coordinator, listener, settings, contract.Topics(root) + + coordinator.stop() + listener.stop() + httpd.shutdown() + + +def call(topics, listener, request_id="nursery-1", room="nursery", child="Emma", age=0): + listener.publish(topics.request, json.dumps({ + "id": request_id, "room": room, "child": child, + "created_at": time.time() - age, + })) + + +def test_auto_mode_reaches_propresenter(rig): + coordinator, listener, settings, topics = rig + listener.publish(topics.mode_set, "auto") + assert listener.wait_for(lambda: coordinator.state.mode == "auto") + + call(topics, listener) + + assert listener.wait_for(lambda: FakePP.triggers), "nothing reached ProPresenter" + assert FakePP.triggers[0] == [ + {"name": "Name", "text": {"text": "Emma"}}, + {"name": "Room", "text": {"text": "Nursery"}}, + ] + assert listener.wait_for( + lambda: contract.SENT_TO_SANCTUARY in listener.statuses("nursery-1") + ) + + +def test_approval_mode_waits_then_sends(rig): + coordinator, listener, settings, topics = rig + + call(topics, listener) + + assert listener.wait_for( + lambda: contract.WAITING_APPROVAL in listener.statuses("nursery-1") + ) + assert FakePP.triggers == [] + + pending = listener.latest(topics.pending) + assert pending["id"] == "nursery-1" and pending["waiting"] == 1 + + listener.publish(topics.approve, json.dumps({"id": "nursery-1"})) + + assert listener.wait_for(lambda: FakePP.triggers) + assert listener.wait_for( + lambda: contract.SENT_TO_SANCTUARY in listener.statuses("nursery-1") + ) + assert listener.wait_for(lambda: listener.latest(topics.pending)["waiting"] == 0) + + +def test_rejection(rig): + coordinator, listener, settings, topics = rig + call(topics, listener) + assert listener.wait_for(lambda: coordinator.state.queue) + + listener.publish(topics.reject, json.dumps({"id": "nursery-1"})) + + assert listener.wait_for( + lambda: contract.REJECTED in listener.statuses("nursery-1") + ) + assert FakePP.triggers == [] + + +def test_off_mode_is_honest(rig): + coordinator, listener, settings, topics = rig + listener.publish(topics.mode_set, "off") + assert listener.wait_for(lambda: coordinator.state.mode == "off") + + call(topics, listener) + + assert listener.wait_for( + lambda: contract.UNAVAILABLE in listener.statuses("nursery-1") + ) + assert FakePP.triggers == [] + assert coordinator.state.queue == [] + + +def test_two_rooms_and_the_operator_picks_the_second(rig): + coordinator, listener, settings, topics = rig + + call(topics, listener, "nursery-a", "nursery", "Emma") + call(topics, listener, "k2-b", "k2", "Noah") + + assert listener.wait_for(lambda: len(coordinator.state.queue) == 2) + + listener.publish(topics.approve, json.dumps({"id": "k2-b"})) + + assert listener.wait_for(lambda: FakePP.triggers) + assert FakePP.triggers[0][0]["text"]["text"] == "Noah" + assert [r.id for r in coordinator.state.queue] == ["nursery-a"] + + +def test_propresenter_failure_never_reports_success(rig): + coordinator, listener, settings, topics = rig + FakePP.status = 500 + listener.publish(topics.mode_set, "auto") + assert listener.wait_for(lambda: coordinator.state.mode == "auto") + + call(topics, listener) + + assert listener.wait_for(lambda: contract.ERROR in listener.statuses("nursery-1")) + assert contract.SENT_TO_SANCTUARY not in listener.statuses("nursery-1") + + +def test_misconfigured_tokens_never_report_success(rig): + """The failure that would call the wrong child's parents.""" + coordinator, listener, settings, topics = rig + FakePP.tokens = ["child", "room"] + listener.publish(topics.mode_set, "auto") + assert listener.wait_for(lambda: coordinator.state.mode == "auto") + + call(topics, listener) + + assert listener.wait_for(lambda: contract.ERROR in listener.statuses("nursery-1")) + assert FakePP.triggers == [] + assert contract.SENT_TO_SANCTUARY not in listener.statuses("nursery-1") + + +def test_a_malformed_payload_does_not_stop_the_service(rig): + coordinator, listener, settings, topics = rig + + for junk in ["not json", "[1,2,3]", json.dumps({"no": "id"}), "\xff"]: + listener.publish(topics.request, junk) + + time.sleep(0.5) + call(topics, listener, "nursery-after") + + assert listener.wait_for( + lambda: contract.WAITING_APPROVAL in listener.statuses("nursery-after") + ) + + +def test_a_stale_request_is_refused(rig): + coordinator, listener, settings, topics = rig + + call(topics, listener, "nursery-old", age=9999) + + assert listener.wait_for( + lambda: contract.EXPIRED in listener.statuses("nursery-old") + ) + assert coordinator.state.queue == [] + + +def test_duplicate_ids_do_not_send_twice(rig): + coordinator, listener, settings, topics = rig + listener.publish(topics.mode_set, "auto") + assert listener.wait_for(lambda: coordinator.state.mode == "auto") + + call(topics, listener, "nursery-dup") + assert listener.wait_for(lambda: FakePP.triggers) + call(topics, listener, "nursery-dup") + time.sleep(1) + + assert len(FakePP.triggers) == 1 + + +def test_statuses_are_never_retained(rig): + """A retained status would tell the next reader about a finished call.""" + coordinator, listener, settings, topics = rig + call(topics, listener) + assert listener.wait_for(lambda: listener.statuses("nursery-1")) + + later = Listener(settings.topic_root) + try: + time.sleep(1) + replayed = [t for t, _, retain in later.seen if "/status/" in t] + assert replayed == [], f"a status was retained: {replayed}" + finally: + later.stop() + + +def test_pending_is_retained_so_companion_sees_it_on_connect(rig): + coordinator, listener, settings, topics = rig + call(topics, listener) + assert listener.wait_for(lambda: coordinator.state.queue) + + later = Listener(settings.topic_root) + try: + assert later.wait_for(lambda: later.latest(topics.pending) is not None) + assert later.latest(topics.pending)["id"] == "nursery-1" + finally: + later.stop() + + +def test_mode_is_retained_and_recovered(rig): + coordinator, listener, settings, topics = rig + listener.publish(topics.mode_set, "auto") + assert listener.wait_for(lambda: listener.latest(topics.mode) == "auto") + + restarted = Coordinator(settings, ProPresenter( + settings.propresenter_base_url, "Parent Caller", "Name", "Room", timeout=3)) + restarted.start() + try: + assert listener.wait_for(lambda: restarted.state.mode == "auto", timeout=8) + assert restarted.state.queue == [], "a restart must not inherit a queue" + finally: + restarted.stop() + + +def test_health_separates_the_service_from_its_dependencies(rig): + coordinator, listener, settings, topics = rig + + report = coordinator.health() + + assert report["alive"] is True + assert report["broker_connected"] is True + assert report["mode"] == "approval" diff --git a/tests/test_propresenter.py b/tests/test_propresenter.py new file mode 100644 index 0000000..a2af975 --- /dev/null +++ b/tests/test_propresenter.py @@ -0,0 +1,180 @@ +"""The ProPresenter adapter, against a mock of the real API. + +The mock behaves the way the church's ProPresenter 21.3 was observed to +behave, including the part that matters most: an unknown token name +still returns 204. +""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from parent_call.propresenter import ProPresenter, ProPresenterError + +UUID = "7D044452-B5A1-49F5-AC9C-1FD53A9A7353" + +MESSAGES = [ + {"id": {"name": "Gbreaker", "index": 0, "uuid": "FE2A"}, "tokens": []}, + { + "id": {"name": "Parent Caller", "index": 2, "uuid": UUID}, + "message": "{Name}'s parent please go to the {Room}", + "tokens": [ + {"name": "Name", "text": {"text": ""}}, + {"name": "Room", "text": {"text": ""}}, + ], + }, +] + + +class Fake(BaseHTTPRequestHandler): + messages = MESSAGES + triggers = [] + clears = [] + trigger_status = 204 + messages_status = 200 + + def log_message(self, *args): + pass + + def _send(self, status, body=None): + self.send_response(status) + if body is None: + self.send_header("Content-Length", "0") + self.end_headers() + return + raw = json.dumps(body).encode() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + if self.path == "/version": + return self._send(200, {"host_description": "ProPresenter 21.3", + "api_version": "v1"}) + if self.path == "/v1/messages": + if Fake.messages_status != 200: + return self._send(Fake.messages_status) + return self._send(200, Fake.messages) + if self.path.endswith("/clear"): + Fake.clears.append(self.path) + return self._send(204) + return self._send(404) + + def do_POST(self): + if not self.path.endswith("/trigger"): + return self._send(404) + if UUID not in self.path: + return self._send(404) + length = int(self.headers.get("Content-Length", 0)) + Fake.triggers.append(json.loads(self.rfile.read(length) or b"[]")) + # Exactly as the real one behaves: it does not check token names. + return self._send(Fake.trigger_status) + + +@pytest.fixture +def server(): + Fake.messages = [dict(m) for m in MESSAGES] + Fake.triggers = [] + Fake.clears = [] + Fake.trigger_status = 204 + Fake.messages_status = 200 + + httpd = HTTPServer(("127.0.0.1", 0), Fake) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{httpd.server_port}" + httpd.shutdown() + + +def adapter(base, child="Name", room="Room", message="Parent Caller"): + return ProPresenter(base, message, child, room, timeout=3) + + +def test_finds_the_message_and_its_tokens(server): + message = adapter(server).check() + + assert message.uuid == UUID + assert message.tokens == ("Name", "Room") + + +def test_trigger_sends_both_tokens(server): + adapter(server).trigger("Emma", "Nursery") + + assert Fake.triggers == [[ + {"name": "Name", "text": {"text": "Emma"}}, + {"name": "Room", "text": {"text": "Nursery"}}, + ]] + + +def test_both_tokens_always_go_even_though_only_one_changed(server): + """ProPresenter keeps the previous value of any token left out, so a + partial trigger would show half of the call before it.""" + pp = adapter(server) + pp.trigger("Emma", "Nursery") + pp.trigger("Noah", "Nursery") + + assert [t["text"]["text"] for t in Fake.triggers[1]] == ["Noah", "Nursery"] + + +def test_a_wrong_token_name_is_refused_before_anything_is_triggered(server): + """The whole reason this adapter reads the message first. + + The real API returns 204 for an unknown token and puts the previous + child's name on screen. Nothing may be sent unless the tokens exist. + """ + with pytest.raises(ProPresenterError) as raised: + adapter(server, child="child", room="room").trigger("Emma", "Nursery") + + assert "no token" in str(raised.value) + assert Fake.triggers == [] + + +def test_a_missing_message_is_an_error_not_a_success(server): + with pytest.raises(ProPresenterError) as raised: + adapter(server, message="Parent Call").trigger("Emma", "Nursery") + + assert "no message named" in str(raised.value) + assert Fake.triggers == [] + + +def test_an_unreachable_propresenter_raises(server): + pp = ProPresenter("http://127.0.0.1:9", "Parent Caller", "Name", "Room", timeout=1) + + with pytest.raises(ProPresenterError) as raised: + pp.trigger("Emma", "Nursery") + + assert "cannot reach" in str(raised.value) + + +def test_a_non_2xx_trigger_is_an_error(server): + Fake.trigger_status = 500 + + with pytest.raises(ProPresenterError) as raised: + adapter(server).trigger("Emma", "Nursery") + + assert "500" in str(raised.value) + + +def test_a_renamed_message_is_noticed_rather_than_cached_forever(server): + pp = adapter(server) + pp.trigger("Emma", "Nursery") + + Fake.messages = [{"id": {"name": "Renamed", "uuid": UUID}, "tokens": []}] + + with pytest.raises(ProPresenterError): + pp.trigger("Noah", "Nursery") + + +def test_clear_uses_the_resolved_uuid(server): + pp = adapter(server) + pp.trigger("Emma", "Nursery") + pp.clear() + + assert Fake.clears == [f"/v1/message/{UUID}/clear"] + + +def test_no_address_configured_is_an_error(server): + with pytest.raises(ProPresenterError): + ProPresenter("", "Parent Caller", "Name", "Room").trigger("Emma", "Nursery") diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..e5a0005 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,307 @@ +"""The parent call state machine. + +Every awkward case that matters in a real service: two rooms calling at +once, an approval for the wrong request, an approval that arrives after +the child has already been collected, a clear timer firing after the +next call has gone up. +""" + +import pytest + +from parent_call import contract +from parent_call.config import Settings +from parent_call.state import ( + ClearNow, + Deliver, + ParentCall, + PublishPending, + PublishStatus, + ScheduleClear, +) +from parent_call.validation import Request + +NOW = 1_780_000_000.0 + + +@pytest.fixture +def settings(): + return Settings(request_ttl=180, propresenter_clear_after=20, default_mode="approval") + + +def request(id="nursery-1", room="nursery", child="Emma", created_at=NOW): + return Request(id=id, room=room, child=child, created_at=created_at) + + +def statuses(effects): + return [e.status for e in effects if isinstance(e, PublishStatus)] + + +def deliveries(effects): + return [e for e in effects if isinstance(e, Deliver)] + + +def pendings(effects): + return [e for e in effects if isinstance(e, PublishPending)] + + +# -- mode off ---------------------------------------------------------- + +def test_off_refuses_and_does_not_queue(settings): + call = ParentCall(settings, mode=contract.MODE_OFF) + + effects = call.submit(request(), NOW) + + assert statuses(effects) == [contract.UNAVAILABLE] + assert deliveries(effects) == [] + assert call.queue == [] + + +def test_off_says_unavailable_rather_than_pretending(settings): + call = ParentCall(settings, mode=contract.MODE_OFF) + effect = call.submit(request(), NOW)[0] + + assert contract.WORDING[effect.status] == "Parent Call is unavailable" + + +# -- mode auto --------------------------------------------------------- + +def test_auto_delivers_immediately(settings): + call = ParentCall(settings, mode=contract.MODE_AUTO) + + effects = call.submit(request(), NOW) + + assert statuses(effects) == [contract.SUBMITTED, contract.SENDING] + assert len(deliveries(effects)) == 1 + assert deliveries(effects)[0].request.child == "Emma" + assert call.queue == [] + + +def test_auto_never_claims_more_than_sent(settings): + call = ParentCall(settings, mode=contract.MODE_AUTO) + sent = deliveries(call.submit(request(), NOW))[0] + + effects = call.delivered(sent.request, sent.serial, True, None, NOW) + + assert statuses(effects) == [contract.SENT_TO_SANCTUARY] + assert contract.WORDING[contract.SENT_TO_SANCTUARY] == "Sent to sanctuary" + assert "displayed" not in contract.WORDING[contract.SENT_TO_SANCTUARY].lower() + + +# -- mode approval ----------------------------------------------------- + +def test_approval_queues_and_waits(settings): + call = ParentCall(settings) + + effects = call.submit(request(), NOW) + + assert statuses(effects) == [contract.SUBMITTED, contract.WAITING_APPROVAL] + assert deliveries(effects) == [] + assert [r.id for r in call.queue] == ["nursery-1"] + assert pendings(effects)[-1].request.id == "nursery-1" + + +def test_approval_sends_when_approved(settings): + call = ParentCall(settings) + call.submit(request(), NOW) + + effects = call.approve("nursery-1", NOW) + + assert statuses(effects) == [contract.SENDING] + assert len(deliveries(effects)) == 1 + assert call.queue == [] + assert pendings(effects)[-1].request is None + + +def test_rejection_is_reported(settings): + call = ParentCall(settings) + call.submit(request(), NOW) + + effects = call.reject("nursery-1", NOW) + + assert statuses(effects) == [contract.REJECTED] + assert deliveries(effects) == [] + assert call.queue == [] + + +# -- more than one room at once --------------------------------------- + +def test_queue_is_fifo(settings): + call = ParentCall(settings) + call.submit(request(id="a", room="nursery", child="Emma"), NOW) + call.submit(request(id="b", room="k2", child="Noah"), NOW) + call.submit(request(id="c", room="older", child="Ava"), NOW) + + assert [r.id for r in call.queue] == ["a", "b", "c"] + + effects = call.approve("a", NOW) + + assert pendings(effects)[-1].request.id == "b" + assert pendings(effects)[-1].waiting == 2 + + +def test_approving_by_id_cannot_hit_the_wrong_child(settings): + """Two rooms call at the same moment; the operator approves the + second. The first must stay exactly where it was.""" + call = ParentCall(settings) + call.submit(request(id="a", room="nursery", child="Emma"), NOW) + call.submit(request(id="b", room="k2", child="Noah"), NOW) + + effects = call.approve("b", NOW) + + assert deliveries(effects)[0].request.child == "Noah" + assert [r.id for r in call.queue] == ["a"] + + +def test_approving_something_not_queued_does_nothing(settings): + call = ParentCall(settings) + call.submit(request(id="a"), NOW) + + assert call.approve("does-not-exist", NOW) == [] + assert [r.id for r in call.queue] == ["a"] + + +# -- duplicates -------------------------------------------------------- + +def test_the_same_id_is_never_delivered_twice(settings): + call = ParentCall(settings, mode=contract.MODE_AUTO) + + first = call.submit(request(id="a"), NOW) + second = call.submit(request(id="a"), NOW) + + assert len(deliveries(first)) == 1 + assert deliveries(second) == [] + + +def test_a_repeat_is_answered_again(settings): + """A classroom that missed the status and retries should be told + where its request got to, not ignored.""" + call = ParentCall(settings) + call.submit(request(id="a"), NOW) + + repeat = call.submit(request(id="a"), NOW) + + assert statuses(repeat) == [contract.WAITING_APPROVAL] + assert [r.id for r in call.queue] == ["a"] + + +# -- age --------------------------------------------------------------- + +def test_a_stale_request_is_never_accepted(settings): + call = ParentCall(settings) + + effects = call.submit(request(created_at=NOW - 9999), NOW) + + assert statuses(effects) == [contract.EXPIRED] + assert call.queue == [] + + +def test_a_queued_request_expires_on_its_own(settings): + call = ParentCall(settings) + call.submit(request(id="a"), NOW) + + assert call.tick(NOW + 10) == [] + + effects = call.tick(NOW + settings.request_ttl + 1) + + assert statuses(effects) == [contract.EXPIRED] + assert call.queue == [] + assert pendings(effects)[-1].request is None + + +def test_an_approval_after_expiry_does_not_deliver(settings): + """The operator was away; by the time they pressed approve the call + was stale. It must not go on screen.""" + call = ParentCall(settings) + call.submit(request(id="a"), NOW) + + effects = call.approve("a", NOW + settings.request_ttl + 1) + + assert statuses(effects) == [contract.EXPIRED] + assert deliveries(effects) == [] + + +# -- delivery outcome -------------------------------------------------- + +def test_propresenter_failure_is_never_a_success(settings): + call = ParentCall(settings, mode=contract.MODE_AUTO) + sent = deliveries(call.submit(request(), NOW))[0] + + effects = call.delivered(sent.request, sent.serial, False, "cannot reach", NOW) + + assert statuses(effects) == [contract.ERROR] + assert not any(isinstance(e, ScheduleClear) for e in effects) + assert effects[0].detail == "cannot reach" + + +def test_success_schedules_a_clear(settings): + call = ParentCall(settings, mode=contract.MODE_AUTO) + sent = deliveries(call.submit(request(), NOW))[0] + + effects = call.delivered(sent.request, sent.serial, True, None, NOW) + scheduled = [e for e in effects if isinstance(e, ScheduleClear)] + + assert scheduled and scheduled[0].after == 20 + + +def test_clear_disabled_schedules_nothing(): + settings = Settings(propresenter_clear_after=0, default_mode="auto") + call = ParentCall(settings, mode=contract.MODE_AUTO) + sent = deliveries(call.submit(request(), NOW))[0] + + effects = call.delivered(sent.request, sent.serial, True, None, NOW) + + assert not any(isinstance(e, ScheduleClear) for e in effects) + + +# -- the clear timer race ---------------------------------------------- + +def test_an_old_clear_cannot_wipe_a_newer_call(settings): + """The nursery's call is still on screen when K2 calls. The nursery's + twenty second timer fires. It must not clear K2's call.""" + call = ParentCall(settings, mode=contract.MODE_AUTO) + + first = deliveries(call.submit(request(id="a", child="Emma"), NOW))[0] + call.delivered(first.request, first.serial, True, None, NOW) + + second = deliveries(call.submit(request(id="b", child="Noah"), NOW))[0] + call.delivered(second.request, second.serial, True, None, NOW) + + assert call.clear_due(first.serial) == [] + + due = call.clear_due(second.serial) + assert [type(e) for e in due] == [ClearNow] + + +# -- mode changes ------------------------------------------------------ + +def test_turning_off_empties_the_queue(settings): + call = ParentCall(settings) + call.submit(request(id="a"), NOW) + call.submit(request(id="b"), NOW) + + effects = call.set_mode(contract.MODE_OFF, NOW) + + assert statuses(effects) == [contract.UNAVAILABLE, contract.UNAVAILABLE] + assert call.queue == [] + assert pendings(effects)[-1].request is None + + +def test_an_unknown_mode_is_ignored(settings): + call = ParentCall(settings) + assert call.set_mode("banana", NOW) == [] + assert call.mode == contract.MODE_APPROVAL + + +# -- restart ----------------------------------------------------------- + +def test_a_new_coordinator_starts_empty(settings): + """Whatever was queued when Control lost power is gone. A parent call + surfacing ten minutes late would be worse than losing it.""" + call = ParentCall(settings) + call.submit(request(id="a"), NOW) + assert call.queue + + restarted = ParentCall(settings) + + assert restarted.queue == [] + assert restarted.outcomes == {} diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..9c955fd --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,132 @@ +"""What a classroom is allowed to send. + +This is the only place classroom input is read, and everything that +reaches a sanctuary screen comes through it. +""" + +import json + +import pytest + +from parent_call.config import Settings +from parent_call.validation import Invalid, parse + +NOW = 1_780_000_000.0 + + +@pytest.fixture +def settings(): + return Settings(max_child_length=40, max_room_length=32, request_ttl=180, + max_clock_skew=120) + + +def body(**fields): + payload = {"id": "nursery-1", "room": "nursery", "child": "Emma", + "created_at": NOW} + payload.update(fields) + return json.dumps(payload) + + +def test_a_good_request(settings): + request = parse(body(), NOW, settings) + + assert (request.id, request.room, request.child) == ("nursery-1", "nursery", "Emma") + + +def test_only_the_four_fields_survive(settings): + """A payload cannot smuggle anything through to ProPresenter.""" + request = parse( + body(message="{Name} OWNS YOU", theme="evil", propresenter="anything"), + NOW, settings, + ) + + assert set(vars(request)) == {"id", "room", "child", "created_at"} + + +@pytest.mark.parametrize("payload, reason", [ + (b"\xff\xfe", "payload is not utf-8"), + ("{{{", "payload is not json"), + ("[1,2,3]", "payload is not an object"), + (json.dumps({"room": "nursery", "child": "E", "created_at": NOW}), "id is missing"), + (json.dumps({"id": 7, "room": "n", "child": "E", "created_at": NOW}), "id is missing"), +]) +def test_unusable_payloads(settings, payload, reason): + with pytest.raises(Invalid) as raised: + parse(payload, NOW, settings) + + assert raised.value.reason == reason + + +@pytest.mark.parametrize("field, value", [ + ("id", "has/slash"), + ("id", "has#hash"), + ("id", "has+plus"), + ("id", ""), + ("id", "x" * 200), +]) +def test_ids_that_could_reshape_a_topic(settings, field, value): + with pytest.raises(Invalid): + parse(body(**{field: value}), NOW, settings) + + +def test_the_id_is_reported_back_so_the_classroom_can_be_answered(settings): + with pytest.raises(Invalid) as raised: + parse(body(child=""), NOW, settings) + + assert raised.value.request_id == "nursery-1" + assert raised.value.room == "nursery" + + +@pytest.mark.parametrize("room", ["a/b", "a+b", "a#b", "", "x" * 99, " "]) +def test_rooms_that_are_not_rooms(settings, room): + with pytest.raises(Invalid): + parse(body(room=room), NOW, settings) + + +def test_room_is_lowercased(settings): + assert parse(body(room="Nursery"), NOW, settings).room == "nursery" + + +@pytest.mark.parametrize("child", ["", " ", "x" * 41]) +def test_child_names_that_are_refused(settings, child): + with pytest.raises(Invalid): + parse(body(child=child), NOW, settings) + + +@pytest.mark.parametrize("child, expected", [ + ("Emma\nPASTOR SAYS EVACUATE", "Emma PASTOR SAYS EVACUATE"), + ("Emma​Rose", "Emma Rose"), + ("Emma‮", "Emma"), + (" Emma Rose ", "Emma Rose"), + ("Zoé", "Zoé"), +]) +def test_names_are_made_safe_to_put_on_a_screen(settings, child, expected): + """Nothing invisible, and nothing that can push text around a screen, + survives into a sanctuary display.""" + assert parse(body(child=child), NOW, settings).child == expected + + +def test_an_old_timestamp_is_still_a_valid_shape(settings): + """How old is too old is policy, and the state machine decides it — + it has to make the same judgement again when a late approval arrives. + See test_state.test_a_stale_request_is_never_accepted.""" + assert parse(body(created_at=NOW - 9999), NOW, settings).child == "Emma" + + +def test_requests_from_the_future(settings): + with pytest.raises(Invalid) as raised: + parse(body(created_at=NOW + 3600), NOW, settings) + + assert "future" in raised.value.reason + + +def test_a_slightly_fast_classroom_clock_is_tolerated(settings): + assert parse(body(created_at=NOW + 60), NOW, settings).child == "Emma" + + +@pytest.mark.parametrize("created_at", [None, "soon", True, {}]) +def test_timestamps_that_are_not_timestamps(settings, created_at): + with pytest.raises(Invalid) as raised: + parse(body(created_at=created_at), NOW, settings) + + assert raised.value.reason == "created_at is missing"