The coordinator already existed on the control computer and its contract is frozen, so this is the classroom half of a contract that was read from its README and source rather than guessed: four fields out, the status topic subscribed before anything is published, and every word the teacher reads taken from the coordinator's own message field. It is a separate process, not a Plasma applet. A broker library, a network timeout and a text entry have no business inside the only way a teacher has of getting anywhere; if this dies the bar is untouched. The panel mark is Breeze's call-outgoing. The old one was a bell, sitting two inches from Plasma's notification bell in the same bar and meaning something entirely different. Two things it will not do: claim a call was displayed, and let a teacher type the room. The machine already knows which room it is. Sixty tests cover the contract without a broker. The live path was proven against the real coordinator running in the lab, on a loopback broker, with ProPresenter mocked and the church's networks rejected from inside the container first. Co-Authored-By: Claude Opus 5 <[email protected]>
291 lines
8.3 KiB
Python
291 lines
8.3 KiB
Python
"""The classroom's side of Parent Call.
|
|
|
|
A teacher types a child's name and presses a button. This module holds
|
|
everything about that which does not need a broker: the topics, the shape
|
|
of a request, how to read a status, and what the teacher may be told.
|
|
|
|
**The contract is the coordinator's, not ours.** It is documented in
|
|
`/home/media/docker/parent-call/README.md` on the control computer and was
|
|
read from there rather than guessed. Four fields go out; anything else in
|
|
the payload is discarded at the far end. Every word the teacher reads comes
|
|
back from the coordinator in the `message` field, because the coordinator
|
|
is the only thing that knows what actually happened.
|
|
|
|
The rule the whole design turns on:
|
|
|
|
A ProPresenter call that succeeded means "sent to sanctuary".
|
|
It never means "displayed".
|
|
|
|
Nothing here says a call was seen, because nothing here knows.
|
|
"""
|
|
|
|
import re
|
|
import secrets
|
|
import time
|
|
import unicodedata
|
|
|
|
DEFAULT_ROOT = "classroom/parent-call"
|
|
|
|
# Statuses the coordinator can send. Names and wording are its, verbatim;
|
|
# the wording is only a fallback for a status that arrives without a
|
|
# message, because the coordinator's own text is what should be read.
|
|
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,
|
|
)
|
|
|
|
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",
|
|
}
|
|
|
|
# Once one of these arrives there is nothing further to wait for, and the
|
|
# classroom stops listening. The coordinator's README asks for exactly
|
|
# this.
|
|
TERMINAL = frozenset((
|
|
SENT_TO_SANCTUARY,
|
|
REJECTED,
|
|
EXPIRED,
|
|
UNAVAILABLE,
|
|
ERROR,
|
|
))
|
|
|
|
# Only this one means the call is on its way and the teacher can walk away.
|
|
GOOD = frozenset((SENT_TO_SANCTUARY,))
|
|
|
|
MODE_AUTO = "auto"
|
|
MODE_APPROVAL = "approval"
|
|
MODE_OFF = "off"
|
|
MODES = (MODE_AUTO, MODE_APPROVAL, MODE_OFF)
|
|
|
|
# The coordinator's own limit, so a name is refused here rather than
|
|
# after a round trip. MAX_CHILD_LENGTH in its .env.
|
|
MAX_CHILD = 40
|
|
|
|
# Its id shape, so an id this end builds can never be rejected as
|
|
# unusable — the one failure it cannot answer, because the id is the
|
|
# topic it would answer on.
|
|
ID_SHAPE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
|
|
|
|
# Control, format, unassigned, surrogate, private-use, and every kind of
|
|
# space separator. The coordinator strips these; doing the same here
|
|
# means the teacher sees the name that will actually be sent.
|
|
INVISIBLE = frozenset(("Cc", "Cf", "Cn", "Co", "Cs", "Zl", "Zp", "Zs"))
|
|
|
|
|
|
class Topics:
|
|
"""Every topic the classroom touches, from one root."""
|
|
|
|
def __init__(self, root=DEFAULT_ROOT):
|
|
self.root = (root or DEFAULT_ROOT).rstrip("/")
|
|
|
|
@property
|
|
def mode(self):
|
|
"""Retained. auto | approval | off, as plain text."""
|
|
return f"{self.root}/mode"
|
|
|
|
@property
|
|
def coordinator(self):
|
|
"""Retained. Whether the sanctuary end is up at all."""
|
|
return f"{self.root}/coordinator"
|
|
|
|
@property
|
|
def request(self):
|
|
"""Never retained: a request is an event, and last Sunday's call
|
|
must not replay into this one."""
|
|
return f"{self.root}/request"
|
|
|
|
def status(self, request_id):
|
|
return f"{self.root}/status/{request_id}"
|
|
|
|
|
|
def visible(value):
|
|
"""The same text with everything invisible turned into plain spaces.
|
|
|
|
The same rule the coordinator applies, so the name in the field is
|
|
the name that reaches the screen.
|
|
"""
|
|
swapped = "".join(
|
|
" " if unicodedata.category(char) in INVISIBLE else char
|
|
for char in value
|
|
)
|
|
|
|
return " ".join(swapped.split())
|
|
|
|
|
|
def clean_child(value):
|
|
"""A child's name as it will be sent, or None if it is not one."""
|
|
if not isinstance(value, str):
|
|
return None
|
|
|
|
name = visible(unicodedata.normalize("NFC", value))
|
|
|
|
if not name or len(name) > MAX_CHILD:
|
|
return None
|
|
|
|
return name
|
|
|
|
|
|
def new_request_id(room, token=None):
|
|
"""A room-scoped id that the coordinator will accept.
|
|
|
|
Random rather than sequential: two rooms calling in the same second
|
|
must not be able to collide, and an id that can be predicted is an id
|
|
somebody else's status could be read on.
|
|
"""
|
|
suffix = token or secrets.token_hex(3)
|
|
candidate = f"{room}-{suffix}"
|
|
|
|
return candidate if ID_SHAPE.match(candidate) else suffix
|
|
|
|
|
|
def request_payload(request_id, room, child, now=None):
|
|
"""The whole vocabulary a classroom has: four fields.
|
|
|
|
No ProPresenter text, no message name, no theme. The coordinator
|
|
builds what reaches the screen from values that passed its own
|
|
validation, and a classroom cannot reach past it.
|
|
"""
|
|
return {
|
|
"id": request_id,
|
|
"room": room,
|
|
"child": child,
|
|
"created_at": int(now if now is not None else time.time()),
|
|
}
|
|
|
|
|
|
def read_mode(payload):
|
|
"""auto, approval or off — or None if it is not one of those."""
|
|
if isinstance(payload, (bytes, bytearray)):
|
|
try:
|
|
payload = payload.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return None
|
|
|
|
if not isinstance(payload, str):
|
|
return None
|
|
|
|
mode = payload.strip().lower()
|
|
|
|
return mode if mode in MODES else None
|
|
|
|
|
|
def read_coordinator(payload):
|
|
"""Whether the sanctuary end says it is up.
|
|
|
|
Anything unreadable is treated as down. A classroom that cannot tell
|
|
should offer to send nothing, rather than let a teacher believe a
|
|
call went somewhere.
|
|
"""
|
|
import json
|
|
|
|
if isinstance(payload, (bytes, bytearray)):
|
|
try:
|
|
payload = payload.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return False
|
|
|
|
if not isinstance(payload, str) or not payload.strip():
|
|
return False
|
|
|
|
try:
|
|
body = json.loads(payload)
|
|
except ValueError:
|
|
return False
|
|
|
|
return bool(isinstance(body, dict) and body.get("online") is True)
|
|
|
|
|
|
def read_status(payload, request_id=None):
|
|
"""A status update, or None if this is not one worth showing.
|
|
|
|
Defensive on purpose: this is the one message the teacher reads, and
|
|
a malformed payload must leave the dialog saying what it said before
|
|
rather than showing nonsense.
|
|
"""
|
|
import json
|
|
|
|
if isinstance(payload, (bytes, bytearray)):
|
|
try:
|
|
payload = payload.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return None
|
|
|
|
try:
|
|
body = json.loads(payload)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
if not isinstance(body, dict):
|
|
return None
|
|
|
|
status = body.get("status")
|
|
|
|
if not isinstance(status, str) or status not in STATUSES:
|
|
return None
|
|
|
|
# A status for somebody else's request is not ours to show. It should
|
|
# never arrive — we subscribe to one topic — but a wildcard mistake
|
|
# would otherwise put another room's child on this screen.
|
|
if request_id is not None and body.get("id") not in (None, request_id):
|
|
return None
|
|
|
|
message = body.get("message")
|
|
|
|
if not isinstance(message, str) or not message.strip():
|
|
message = WORDING.get(status, status)
|
|
|
|
detail = body.get("detail")
|
|
|
|
return {
|
|
"id": body.get("id"),
|
|
"status": status,
|
|
"message": visible(message)[:200],
|
|
"detail": visible(detail)[:200] if isinstance(detail, str) else None,
|
|
"terminal": status in TERMINAL,
|
|
"good": status in GOOD,
|
|
}
|
|
|
|
|
|
def availability(mode, coordinator_online, broker_reachable=True):
|
|
"""Whether a teacher may send a call now, and what to say if not.
|
|
|
|
Returns (can_send, message). The wording for an off mode is the
|
|
coordinator's own, so the room says the same thing the sanctuary
|
|
would.
|
|
"""
|
|
if not broker_reachable:
|
|
return False, "Cannot reach the church network"
|
|
|
|
if not coordinator_online:
|
|
return False, "The sanctuary system is not running"
|
|
|
|
if mode == MODE_OFF:
|
|
return False, WORDING[UNAVAILABLE]
|
|
|
|
if mode is None:
|
|
return False, "Parent Call has not been set up yet"
|
|
|
|
return True, None
|