Files
Classroom_Desktop/lib/classroom_parent_call.py
ekklesiaandClaude Opus 5 ba8768cee5 fix: a room that was set up, a card where its button is, and larger cards
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]>
2026-09-13 08:34:37 -05:00

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