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.
This commit is contained in:
Ekklesia
2026-09-06 15:15:11 -05:00
commit f6c3bac732
22 changed files with 2814 additions and 0 deletions
+53
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
__pycache__/
*.pyc
.pytest_cache/
.coverage
+23
View File
@@ -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"]
+244
View File
@@ -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/<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
+24
View File
@@ -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"
+89
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
paho-mqtt==2.1.0
View File
+110
View File
@@ -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),
)
+142
View File
@@ -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),
}
+360
View File
@@ -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()
+43
View File
@@ -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
+71
View File
@@ -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())
+102
View File
@@ -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())
+184
View File
@@ -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
+230
View File
@@ -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)]
+140
View File
@@ -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))
+4
View File
@@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+370
View File
@@ -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"
+180
View File
@@ -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")
+307
View File
@@ -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 == {}
+132
View File
@@ -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"