Parent Call told rooms they had never been set up. Nothing about the rooms was wrong. Read from the church broker: …/coordinator and …/pending are retained and …/mode is absent, and the coordinator's own source says why — it publishes the retained mode only in answer to an operator writing to …/mode/set, and nobody ever has. Every classroom waited a second and a half for a message that will never exist. A missing mode is now read as the coordinator's own default rather than as an unconfigured room: the room offers the call and lets the sanctuary end answer, because that is the end that knows. A mode that really is off still refuses, in the coordinator's words, and a broker that is missing or unreachable still says which of those it is. Proved against a rig broker made to match the church's exactly, with a call landing on the sanctuary mock. The card is anchored to the bottom-left corner instead of centred, by layer shell, which is what the disc player's cover has used under KWin for months. Anchoring rather than positioning is what makes it right at 1080p and at 4K without a coordinate anywhere. It falls back to the centred card if the library is missing. Notifications are sized for a room. Every Plasma measurement is a grid unit and a grid unit is the height of the general font, so the font is the lever — 15 point instead of Breeze's 10, which takes the card from about 350x110 to about 410x145 at 100% and doubles again at 200%. Text, icon, padding and buttons move together. The chime stays off. No shell package: that path cost the panel and is not coming back. And the product name is gone from everything a teacher sees. The notification card is from Classroom, the disc player's from DVD. Co-Authored-By: Claude Opus 5 <[email protected]>
333 lines
10 KiB
Python
333 lines
10 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,
|
|
}
|
|
|
|
|
|
# Why the room has no broker, in the teacher's words.
|
|
#
|
|
# "Cannot reach" and "is not set up" are different problems with different
|
|
# people to fetch: the first is a network or a broker that is down and
|
|
# will come back, the second is a machine that was never told where the
|
|
# church network is and needs an administrator. Saying the first when the
|
|
# truth is the second sends somebody to look at a router for an hour.
|
|
BROKER_UNREACHABLE = "Cannot reach the church network"
|
|
BROKER_WORDING = {
|
|
"not configured": "This room has no church network set up",
|
|
"no broker library": "This room has no church network set up",
|
|
}
|
|
|
|
|
|
def broker_message(why=None):
|
|
"""What to tell the teacher about a broker that is not there."""
|
|
return BROKER_WORDING.get(why, BROKER_UNREACHABLE)
|
|
|
|
|
|
def availability(mode, coordinator_online, broker_reachable=True, why=None,
|
|
assume_default=False):
|
|
"""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. A message of None means the question is not answered yet
|
|
rather than answered no.
|
|
|
|
assume_default is the caller saying the retained topics have had
|
|
their chance. Until then a missing mode is undecided; after it, a
|
|
missing mode is the coordinator's own default — see below.
|
|
"""
|
|
if not broker_reachable:
|
|
return False, broker_message(why)
|
|
|
|
if not coordinator_online:
|
|
return False, "The sanctuary system is not running"
|
|
|
|
if mode == MODE_OFF:
|
|
return False, WORDING[UNAVAILABLE]
|
|
|
|
if mode is None:
|
|
# No retained mode on the broker at all.
|
|
#
|
|
# This is not a room that was never set up, and treating it as
|
|
# one is what every classroom did: the card said "Parent Call has
|
|
# not been set up yet" on machines whose broker, room and
|
|
# coordinator were all exactly right. The coordinator publishes
|
|
# the retained mode only in answer to an operator writing to
|
|
# …/mode/set, and on this church's broker nobody ever has, so the
|
|
# topic does not exist. Read from the real broker: …/coordinator
|
|
# and …/pending are retained, …/mode is absent.
|
|
#
|
|
# So a mode nobody has set is the coordinator's own DEFAULT_MODE,
|
|
# and the sanctuary is the end that knows it. The room offers the
|
|
# call and lets the coordinator answer; if the true mode is off it
|
|
# refuses, in its own words, through the status topic. A wrong no
|
|
# here costs a teacher the call. A wrong yes costs a round trip
|
|
# and tells the truth at the end of it.
|
|
return (True, None) if assume_default else (False, None)
|
|
|
|
return True, None
|