Files
Classroom_Desktop/lib/classroom_parent_call.py
T
ekklesiaandClaude Opus 5 34fd02e08f feat: give every room the broker, one scale, and its own panel back
Little Waders took the Plasma build and reported what a container
cannot. A disc player on a machine with no optical drive, a clock nobody
at the back of the room could read, a browser at half the size of the
panel around it, and Parent Call with no broker to call.

The broker is the same for every classroom, so it is a default now and
not something to fill in room by room. classroom-migrate brings an
existing room to the same place without touching its name, its username
or its password, and Parent Call distinguishes a broker it cannot reach
from one that was never set up: those send somebody to look at
completely different things.

The disc player's detection was right and ran too late — alongside the
session rather than before it, and the panel is built from the layout the
moment a session starts. It runs before the display manager now, and
again from udev when a drive is plugged in.

UI_SCALE had a ceiling of 1.5, which cannot express a 4K television; a
clock pinned at 15 points, which is the same size at every scale; and a
browser that was never told the number at all. All three are fixed and
the room doubles together at 200%.

This also brings 0.1.47's work into the repository, where it had only
ever existed in a build directory under /tmp: the hidden display dialog,
its authenticated writer, the polkit action, and the church's own
wallpaper. One piece of it is deliberately left out. The custom volume
display needed a shell package of our own, and with one selected
plasmashell builds no panel at all — measured twice, with that build's
own files, on a classroom that deletes its panel configuration at every
login by design.

The lab connected to the real church broker the moment the package
defaulted to it. Nothing was left behind, which was checked, but the
guard that should have stopped it had never worked: it used iptables,
which the lab image does not have, and probed with /dev/tcp under dash,
where that always fails. Both reported success.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-12 19:05:09 -05:00

310 lines
9.1 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):
"""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, 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:
return False, "Parent Call has not been set up yet"
return True, None