feat: let a teacher ask the sanctuary to call a child's parent

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]>
This commit is contained in:
ekklesia
2026-09-10 10:37:06 -05:00
co-authored by Claude Opus 5
parent 380f031092
commit 7790003c03
17 changed files with 1478 additions and 28 deletions
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Parent Call.
Deliberately not a colour tile. Parent Call is not one of the room's
applications and does not sit in the row with them, so it is drawn in
the interface's own accent as a line mark: present, reachable, and
visibly a different kind of thing from Wonder Ink and the browser. -->
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Inset so the mark has the same optical size as the filled tiles
beside it in the bar; an outline drawn to the edges reads larger. -->
<g transform="translate(64 64) scale(0.62) translate(-64 -64)"
fill="none" stroke="#4FB3A8" stroke-width="8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M40 58a24 24 0 0 1 48 0c0 18 6 24 9 28H31c3-4 9-10 9-28z"/>
<path d="M55 94a9 9 0 0 0 18 0"/>
<path d="M64 26v-8"/>
<path d="M100 30a30 30 0 0 1 8 20"/>
<path d="M28 30a30 30 0 0 0-8 20"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 992 B

@@ -1,10 +1,22 @@
[Desktop Entry]
Type=Application
Name=Parent Call
Comment=Ask for a child's parent to come to the room
Comment=Ask the sanctuary to call a child's parent to this room
# Breeze's own, and deliberately not a bell.
#
# The previous mark was a bell, which sat two inches from Plasma's
# notification bell in the same bar and meant something entirely
# different. A handset with an outgoing arrow cannot be mistaken for an
# alert: it reads as placing a call, and the arrow says the call leaves
# this room. No stock Breeze icon combines a person with a call, so the
# unambiguous half was chosen over the half that could be read as user
# settings — which is what two grey people on a panel look like.
Icon=call-outgoing
Exec=/usr/local/bin/classroom-parent-call
Icon=ekklesia-parent-call
Terminal=false
Categories=Utility;
StartupNotify=false
NoDisplay=false
StartupNotify=true
StartupWMClass=classroom-parent-call
# Never in an application list. It is reached from the bar, and it is not
# one of the room's applications.
NoDisplay=true
+21 -3
View File
@@ -1,6 +1,6 @@
# Window rules.
#
# There is one, and it is about video.
# There are two: one about video, one about the Parent Call card.
#
# Everything else the classroom used to arrange by hand is ordinary window
# manager behaviour and needs no rule: a dialog stays in front of the window
@@ -17,8 +17,8 @@
# 314-line window arbiter was doing.
[General]
count=1
rules=classroom-video
count=2
rules=classroom-video,classroom-parent-call
[classroom-video]
Description=Video is an appliance screen, not a window
@@ -30,3 +30,21 @@ wmclassmatch=3
wmclasscomplete=false
noborder=true
noborderrule=2
[classroom-parent-call]
Description=Parent Call is a quick action, not a window
wmclass=classroom-parent-call
wmclassmatch=1
wmclasscomplete=false
# It is not an application, so it does not belong in the row of them. The
# button that opened it is already in the bar.
skiptaskbar=true
skiptaskbarrule=2
skipswitcher=true
skipswitcherrule=2
skippager=true
skippagerrule=2
# In front of the lesson a teacher was reading when they pressed the
# button, rather than behind it.
above=true
aboverule=2
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+290
View File
@@ -0,0 +1,290 @@
"""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
+762
View File
@@ -0,0 +1,762 @@
#!/usr/bin/env python3
"""Ask the sanctuary to call a child's parent.
A quick action, not an application. The panel launches this, it puts one
small card on the screen, and it goes away again. Everything it knows
about the church end comes from the Parent Call coordinator's own MQTT
contract; see classroom_parent_call for the contract itself.
Why this is a separate process, and not a Plasma applet:
the panel is the only way a teacher has of getting anywhere, and a
broker library, a network timeout and a text entry have no business
living inside it. If this crashes, the bar is untouched and clicking
the button again starts a new one.
Two rules it will not bend:
* a teacher is never told a call succeeded unless the coordinator
said so, and the strongest thing the coordinator ever says is
"Sent to sanctuary" — never "displayed";
* nothing is published until the status topic for that request is
already subscribed, so an answer cannot arrive before anyone is
listening.
"""
import os
import sys
import threading
import time
import gi
gi.require_version("Gtk", "3.0")
# Gdk has to be pinned too. Without it PyGObject warns that it may load a
# different major version than Gtk, and the key constants this file
# compares against would then be somebody else's.
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk, GLib, Gtk # noqa: E402
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
import classroom_parent_call as contract # noqa: E402
from classroom_config import read_device_config # noqa: E402
from classroom_mqtt import room_name # noqa: E402
APP_ID = "classroom-parent-call"
# How long to wait for the broker before telling the teacher the church
# network cannot be reached. Short: a teacher standing at the machine
# would rather be told quickly than watch a spinner.
CONNECT_TIMEOUT = 5.0
# Retained topics arrive immediately after subscribing, or never. This is
# how long "never" takes to establish.
RETAINED_GRACE = 1.5
# The coordinator answers a request almost at once. Silence for this long
# means it did not hear us, which is not the same as a refusal and is not
# reported as one.
FIRST_STATUS_TIMEOUT = 8.0
# Once it has answered, it may legitimately take as long as its own
# request expiry to finish — an operator has to approve. Its REQUEST_TTL
# is 180s; this is that with room to spare.
OVERALL_TIMEOUT = 210.0
# Long enough for a teacher to read "Sent to sanctuary" and walk away.
CLOSE_AFTER_SUCCESS = 4.0
def load_colours():
"""The session's own palette, read from the colour scheme in use.
There is one set of design tokens in this classroom, and they live in
a Plasma colour scheme. Reading them here rather than repeating them
means this card follows classroom-theme light and dark without
knowing that either exists.
"""
palette = {
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
"negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97",
"line": "#25333a",
}
scheme = "EkklesiaDark"
for path in (
os.path.expanduser("~/.config/kdeglobals"),
"/etc/ekklesia-classroom/xdg/kdeglobals",
"/etc/xdg/kdeglobals",
):
try:
with open(path, "r", encoding="utf-8") as handle:
section = None
for line in handle:
line = line.strip()
if line.startswith("["):
section = line
elif section and section.startswith("[General]") and \
line.startswith("ColorScheme="):
scheme = line.split("=", 1)[1].strip()
break
except OSError:
continue
else:
if scheme:
break
def rgb(value):
parts = [part.strip() for part in value.split(",")]
if len(parts) != 3:
return None
try:
return "#%02x%02x%02x" % tuple(int(part) for part in parts)
except ValueError:
return None
try:
with open(f"/usr/share/color-schemes/{scheme}.colors", "r",
encoding="utf-8") as handle:
group = None
for line in handle:
line = line.strip()
if line.startswith("["):
group = line
continue
if "=" not in line or group is None:
continue
key, value = (part.strip() for part in line.split("=", 1))
colour = rgb(value)
if colour is None:
continue
if group == "[Colors:Window]":
if key == "BackgroundNormal":
palette["window"] = colour
elif key == "ForegroundNormal":
palette["text"] = colour
elif key == "ForegroundInactive":
palette["dim"] = colour
elif key == "ForegroundNegative":
palette["negative"] = colour
elif key == "ForegroundNeutral":
palette["neutral"] = colour
elif key == "ForegroundPositive":
palette["positive"] = colour
elif key == "BackgroundAlternate":
palette["line"] = colour
elif group == "[Colors:View]" and key == "BackgroundNormal":
palette["view"] = colour
elif group == "[Colors:Selection]":
if key == "BackgroundNormal":
palette["accent"] = colour
elif key == "ForegroundNormal":
palette["on_accent"] = colour
except OSError:
pass
return palette
STYLE = """
* {{ font-family: "Noto Sans", "DejaVu Sans", sans-serif; }}
/* The card draws its own corners, so the window behind it is nothing at
all. Rounding a window any other way would mean asking the compositor
for something, and this one is ours to draw. */
window {{ background-color: transparent; }}
#card {{
background-color: {window};
border: 1px solid {line};
border-radius: 12px;
padding: 24px 26px 20px 26px;
margin: 10px;
}}
#title {{ color: {text}; font-size: 17px; font-weight: 600; }}
#room {{ color: {accent}; font-size: 13px; font-weight: 600;
letter-spacing: 0.06em; }}
#hint {{ color: {dim}; font-size: 12px; }}
#status {{ color: {text}; font-size: 15px; font-weight: 600; }}
#detail {{ color: {dim}; font-size: 12px; }}
#child {{ color: {dim}; font-size: 13px; }}
#status.good {{ color: {positive}; }}
#status.bad {{ color: {negative}; }}
#status.waiting {{ color: {neutral}; }}
entry {{
background-color: {view};
color: {text};
border: 1px solid {line};
border-radius: 8px;
padding: 10px 12px;
font-size: 16px;
caret-color: {accent};
}}
entry:focus {{ border-color: {accent}; }}
entry:disabled {{ color: {dim}; }}
button {{
border-radius: 8px;
padding: 9px 18px;
font-size: 14px;
font-weight: 600;
border: 1px solid {line};
background-image: none;
background-color: transparent;
color: {text};
}}
button:hover {{ background-color: {line}; }}
button#primary {{
background-color: {accent};
color: {on_accent};
border-color: {accent};
}}
/* Disabled has to read as "not yet", not as a broken button: the accent
drains out of it rather than the whole thing fading. */
button#primary:disabled {{
background-color: transparent;
color: {dim};
border-color: {line};
}}
"""
class Broker(threading.Thread):
"""The MQTT half, kept off the interface thread.
Every callback hands its result to GTK with GLib.idle_add and touches
no widget itself.
"""
def __init__(self, config, topics, on_event):
super().__init__(daemon=True)
self.config = config
self.topics = topics
self.on_event = on_event
self.client = None
self.request_id = None
self._pending = None
self._lock = threading.Lock()
# -- lifecycle -----------------------------------------------------
def run(self):
try:
import paho.mqtt.client as mqtt
except ImportError:
self.emit("broker", reachable=False, why="no broker library")
return
host = (self.config.get("MQTT_HOST") or "").strip()
if not host or (self.config.get("MQTT_ENABLED") or "").strip().lower() \
not in ("1", "true", "yes", "on"):
self.emit("broker", reachable=False, why="not configured")
return
try:
port = int((self.config.get("MQTT_PORT") or "1883").strip())
except ValueError:
port = 1883
# A clean session on purpose. Nothing from a previous connection
# may be redelivered: a parent call from last Sunday arriving now
# would be worse than one that was lost.
try:
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=f"classroom-parent-call-{os.getpid()}",
clean_session=True,
)
except (AttributeError, TypeError):
client = mqtt.Client(
client_id=f"classroom-parent-call-{os.getpid()}",
clean_session=True,
)
username = (self.config.get("MQTT_USERNAME") or "").strip()
if username:
client.username_pw_set(
username, (self.config.get("MQTT_PASSWORD") or "").strip() or None
)
client.on_connect = self._connected
client.on_message = self._message
client.on_disconnect = self._disconnected
self.client = client
try:
client.connect(host, port, keepalive=30)
except (OSError, ValueError) as error:
self.emit("broker", reachable=False, why=str(error))
return
client.loop_forever(retry_first_connection=False)
def stop(self):
client = self.client
if client is not None:
try:
client.disconnect()
except Exception:
pass
# -- callbacks -----------------------------------------------------
def _connected(self, client, userdata, flags, reason_code, properties=None):
if getattr(reason_code, "is_failure", False) or \
(isinstance(reason_code, int) and reason_code != 0):
self.emit("broker", reachable=False, why=f"refused ({reason_code})")
return
client.subscribe([(self.topics.mode, 1), (self.topics.coordinator, 1)])
self.emit("broker", reachable=True)
def _disconnected(self, client, userdata, *args):
self.emit("disconnected")
def _message(self, client, userdata, message):
if message.topic == self.topics.mode:
self.emit("mode", mode=contract.read_mode(message.payload))
elif message.topic == self.topics.coordinator:
self.emit("coordinator",
online=contract.read_coordinator(message.payload))
elif self.request_id and message.topic == self.topics.status(self.request_id):
update = contract.read_status(message.payload, self.request_id)
if update:
self.emit("status", update=update)
# -- sending -------------------------------------------------------
def send(self, request_id, room, child):
"""Subscribe first, publish second. Never the other way round."""
client = self.client
if client is None:
self.emit("send-failed", why="not connected")
return
with self._lock:
self.request_id = request_id
body = contract.request_payload(request_id, room, child)
import json
def after_subscribe(_client, _userdata, _mid, _codes, _properties=None):
# QoS 1 so the coordinator's answer is not lost on a flaky
# link, and never retained: a request is an event.
client.publish(self.topics.request, json.dumps(body), qos=1,
retain=False)
self.emit("published")
client.on_subscribe = after_subscribe
client.subscribe(self.topics.status(request_id), qos=1)
def emit(self, kind, **data):
GLib.idle_add(self.on_event, kind, data)
class ParentCall(Gtk.Window):
def __init__(self):
super().__init__(title="Parent Call")
self.config = read_device_config()
self.room = room_name(self.config)
self.topics = contract.Topics(
(self.config.get("PARENT_CALL_ROOT") or contract.DEFAULT_ROOT).strip()
)
self.mode = None
self.coordinator_online = False
self.broker_reachable = None
self.request_id = None
self.sent_at = None
self.settled = False
self.autosend = False
self._timers = []
self.set_decorated(False)
self.set_titlebar(Gtk.Box())
self.set_resizable(False)
self.set_default_size(460, -1)
# Deliberately not set_position(): a Wayland client cannot place
# itself, and asking for the centre put the card in the top-left
# corner instead. KWin centres it, because kwinrc already asks for
# every window to be centred — no rule of its own is needed, and
# the one that was written for it turned out to be doing nothing.
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
# Belt and braces with the window rule: whichever of the two KWin
# honours, the card must not end up behind the lesson a teacher
# was reading when they pressed the button.
self.set_keep_above(True)
screen = self.get_screen()
visual = screen.get_rgba_visual()
if visual is not None:
self.set_visual(visual)
self.set_app_paintable(True)
self._build()
self._apply_style()
self.connect("key-press-event", self._key)
self.connect("destroy", lambda *_: self._quit())
self.broker = Broker(self.config, self.topics, self._event)
self.broker.start()
self._after(CONNECT_TIMEOUT, self._connect_deadline)
# -- interface -----------------------------------------------------
def _build(self):
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14)
card.set_name("card")
heading = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
title = Gtk.Label(label="Parent Call", xalign=0)
title.set_name("title")
heading.pack_start(title, False, False, 0)
# The room is stated, never asked. This machine already knows
# which room it is, and a teacher picking it from a list is a
# chance to pick the wrong one.
room = Gtk.Label(label=self.room.upper(), xalign=0)
room.set_name("room")
room.set_valign(Gtk.Align.CENTER)
heading.pack_end(room, False, False, 0)
card.pack_start(heading, False, False, 0)
self.hint = Gtk.Label(
label="Who should we call a parent for?", xalign=0)
self.hint.set_name("hint")
card.pack_start(self.hint, False, False, 0)
self.entry = Gtk.Entry()
self.entry.set_placeholder_text("Child's name")
self.entry.set_max_length(contract.MAX_CHILD)
self.entry.set_sensitive(False)
self.entry.connect("activate", lambda *_: self._send())
self.entry.connect("changed", lambda *_: self._refresh_button())
card.pack_start(self.entry, False, False, 0)
self.status = Gtk.Label(label="", xalign=0)
self.status.set_name("status")
self.status.set_line_wrap(True)
self.status.set_no_show_all(True)
card.pack_start(self.status, False, False, 0)
self.detail = Gtk.Label(label="", xalign=0)
self.detail.set_name("detail")
self.detail.set_line_wrap(True)
self.detail.set_no_show_all(True)
card.pack_start(self.detail, False, False, 0)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
buttons.set_halign(Gtk.Align.END)
self.close_button = Gtk.Button(label="Close")
self.close_button.connect("clicked", lambda *_: self._quit())
buttons.pack_start(self.close_button, False, False, 0)
self.primary = Gtk.Button(label="Call parent")
self.primary.set_name("primary")
self.primary.set_sensitive(False)
self.primary.connect("clicked", lambda *_: self._send())
buttons.pack_start(self.primary, False, False, 0)
card.pack_start(buttons, False, False, 0)
card.set_size_request(440, -1)
self.add(card)
card.show_all()
self.entry.grab_focus()
self._set_status("Checking with the sanctuary…", tone=None)
def _apply_style(self):
css = Gtk.CssProvider()
css.load_from_data(STYLE.format(**load_colours()).encode("utf-8"))
Gtk.StyleContext.add_provider_for_screen(
self.get_screen(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
def _set_status(self, text, tone=None, detail=None):
style = self.status.get_style_context()
for name in ("good", "bad", "waiting"):
style.remove_class(name)
if tone:
style.add_class(tone)
self.status.set_text(text or "")
self.status.set_visible(bool(text))
self.detail.set_text(detail or "")
self.detail.set_visible(bool(detail))
def _refresh_button(self):
ready = (
self.request_id is None
and self.broker_reachable
and contract.clean_child(self.entry.get_text()) is not None
and contract.availability(
self.mode, self.coordinator_online, self.broker_reachable
)[0]
)
self.primary.set_sensitive(bool(ready))
def _unavailable(self, message):
self.settled = True
self.entry.set_sensitive(False)
self.primary.hide()
self.hint.hide()
self.entry.hide()
self.close_button.set_label("Close")
self._set_status(message, tone="bad")
# -- events --------------------------------------------------------
def _event(self, kind, data):
if kind == "broker":
self.broker_reachable = data.get("reachable")
if not self.broker_reachable:
self._unavailable("Cannot reach the church network")
else:
self._after(RETAINED_GRACE, self._retained_deadline)
elif kind == "mode":
self.mode = data.get("mode")
self._settle()
elif kind == "coordinator":
self.coordinator_online = bool(data.get("online"))
self._settle()
elif kind == "status":
self._status_arrived(data["update"])
elif kind == "published":
self.sent_at = time.monotonic()
self._after(FIRST_STATUS_TIMEOUT, self._first_status_deadline)
elif kind == "send-failed":
self._set_status("The call was not sent", tone="bad",
detail="Press Call parent to try again.")
self.request_id = None
self._refresh_button()
return False
def _settle(self, final=False):
"""Decide what to offer, once the retained topics have spoken.
A negative is only ever reached with final=True. The mode and the
coordinator's availability are two separate retained messages and
they arrive in whichever order the broker sends them — deciding on
the first one told a teacher the sanctuary was down while the
message saying otherwise was still in flight. Measured, in the
lab, on a coordinator that was demonstrably up.
"""
if self.request_id is not None or self.settled:
return
can, why = contract.availability(
self.mode, self.coordinator_online, self.broker_reachable
)
if can:
self.entry.set_sensitive(True)
self.entry.grab_focus()
note = ("A volunteer will approve it first."
if self.mode == contract.MODE_APPROVAL else None)
self._set_status("", tone=None, detail=note)
self.settled = True
self._refresh_button()
if self.autosend:
self.autosend = False
GLib.timeout_add(300, lambda: (self._send(), False)[1])
elif final and why:
self._unavailable(why)
def _status_arrived(self, update):
self._set_status(
update["message"],
tone=("good" if update["good"]
else "bad" if update["terminal"]
else "waiting"),
detail=update.get("detail"),
)
if not update["terminal"]:
return
# Terminal: stop listening, as the coordinator's contract asks.
self.broker.request_id = None
if update["good"]:
self.close_button.hide()
self._after(CLOSE_AFTER_SUCCESS, self._quit)
else:
self.request_id = None
self.entry.set_sensitive(True)
self.entry.show()
self.hint.hide()
self.primary.set_label("Try again")
self.primary.show()
self._refresh_button()
# -- sending -------------------------------------------------------
def _send(self):
if self.request_id is not None:
return
child = contract.clean_child(self.entry.get_text())
if child is None:
self._set_status("Enter the child's name", tone="bad")
return
can, why = contract.availability(
self.mode, self.coordinator_online, self.broker_reachable
)
if not can:
self._unavailable(why or contract.WORDING[contract.UNAVAILABLE])
return
self.request_id = contract.new_request_id(self.room)
self.entry.set_sensitive(False)
self.primary.set_sensitive(False)
self.primary.hide()
self.hint.set_text(f"Calling a parent for {child}")
self.hint.show()
self._set_status("Sending", tone="waiting")
self.broker.send(self.request_id, self.room, child)
self._after(OVERALL_TIMEOUT, self._overall_deadline)
# -- deadlines -----------------------------------------------------
def _after(self, seconds, callback):
self._timers.append(GLib.timeout_add(int(seconds * 1000), callback))
def _connect_deadline(self):
if self.broker_reachable is None:
self._unavailable("Cannot reach the church network")
return False
def _retained_deadline(self):
# Retained topics arrive at once or not at all. Now that both have
# had their chance, a negative is safe to show.
self._settle(final=True)
if not self.settled:
self._unavailable("Parent Call has not been set up yet")
return False
def _first_status_deadline(self):
if self.request_id is not None and self.status.get_text() == "Sending":
# Silence is not a refusal, and is not reported as one.
self._set_status("No answer from the sanctuary", tone="bad",
detail="The call was not confirmed. "
"Press Try again, or ask for help.")
self.request_id = None
self.entry.set_sensitive(True)
self.primary.set_label("Try again")
self.primary.show()
self._refresh_button()
return False
def _overall_deadline(self):
if self.request_id is not None:
self._set_status("No answer from the sanctuary", tone="bad",
detail="The call was not confirmed.")
self.request_id = None
self.primary.set_label("Try again")
self.primary.show()
self._refresh_button()
return False
# -- leaving -------------------------------------------------------
def _key(self, _widget, event):
"""Escape leaves. Nothing else does.
Deliberately narrow: an earlier version compared against a Gdk
that had not been version-pinned, and in the lab an ordinary
letter closed the card.
"""
if event.keyval == Gdk.KEY_Escape and not event.state & (
Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD1_MASK
):
self._quit()
return True
return False
def _quit(self, *_):
for timer in self._timers:
GLib.source_remove(timer)
self._timers = []
self.broker.stop()
Gtk.main_quit()
return False
def main():
GLib.set_prgname(APP_ID)
GLib.set_application_name("Parent Call")
# --child fills the name in, and --send presses the button once the
# sanctuary has answered for whether it may be pressed. Only the lab
# uses them: there is no teacher-facing path to either, and neither
# can send a call the coordinator would not have accepted anyway.
child = None
autosend = "--send" in sys.argv
if "--child" in sys.argv:
index = sys.argv.index("--child") + 1
if index < len(sys.argv):
child = sys.argv[index]
window = ParentCall()
if child:
window.entry.set_text(child)
if autosend:
window.autosend = True
window.show()
Gtk.main()
return 0
if __name__ == "__main__":
sys.exit(main())
+164
View File
@@ -0,0 +1,164 @@
#!/bin/bash
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" python3 - <<'PY'
import json
from classroom_parent_call import (
ERROR, EXPIRED, MODE_APPROVAL, MODE_AUTO, MODE_OFF, REJECTED,
SENT_TO_SANCTUARY, SUBMITTED, UNAVAILABLE, WAITING_APPROVAL, WORDING,
Topics, availability, clean_child, new_request_id, read_coordinator,
read_mode, read_status, request_payload,
)
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
print("1. the coordinator's topics, exactly")
t = Topics()
check("root is the church's, with no product name",
t.root == "classroom/parent-call")
check("mode", t.mode == "classroom/parent-call/mode")
check("coordinator", t.coordinator == "classroom/parent-call/coordinator")
check("request", t.request == "classroom/parent-call/request")
check("status is per request",
t.status("nursery-8f21c3") == "classroom/parent-call/status/nursery-8f21c3")
check("a different root carries through",
Topics("test/pc").request == "test/pc/request")
check("a trailing slash does not double up",
Topics("test/pc/").mode == "test/pc/mode")
print()
print("2. a request is four fields and nothing else")
body = request_payload("nursery-8f21c3", "nursery", "Emma", now=1780000000)
check("exactly the four the coordinator reads",
set(body) == {"id", "room", "child", "created_at"}, sorted(body))
check("created_at is a number", isinstance(body["created_at"], int))
check("it round-trips as json", json.loads(json.dumps(body)) == body)
print()
print("3. ids the coordinator will accept")
check("room-scoped", new_request_id("nursery", "8f21c3") == "nursery-8f21c3")
check("random by default, and different each time",
new_request_id("k2") != new_request_id("k2"))
check("no topic syntax can get into one",
"/" not in new_request_id("older") and "#" not in new_request_id("older"))
check("two rooms cannot collide on a fixed part",
new_request_id("k2", "aaa") != new_request_id("older", "aaa"))
print()
print("4. the name the teacher typed is the name that is sent")
check("ordinary name", clean_child("Emma") == "Emma")
check("outer space is trimmed", clean_child(" Emma ") == "Emma")
check("inner runs collapse", clean_child("Emma Grace") == "Emma Grace")
check("a newline becomes a space, not a weld",
clean_child("Emma\nGrace") == "Emma Grace")
check("a zero width joiner cannot hide in it",
clean_child("Em‍ma") == "Em ma")
check("empty is not a name", clean_child(" ") is None)
check("nothing is not a name", clean_child(None) is None)
check("longer than the coordinator accepts is refused here first",
clean_child("E" * 41) is None)
check("exactly the limit is fine", clean_child("E" * 40) == "E" * 40)
check("accents normalise to one form",
clean_child("José") == "José")
print()
print("5. reading the mode")
for raw, expected in [(b"auto", MODE_AUTO), ("approval", MODE_APPROVAL),
(" off\n", MODE_OFF), ("AUTO", MODE_AUTO)]:
check(f"{raw!r} -> {expected}", read_mode(raw) == expected)
check("anything else is not a mode", read_mode("banana") is None)
check("an empty retained mode is not a mode", read_mode("") is None)
print()
print("6. is the sanctuary end up")
check("online", read_coordinator('{"online": true}') is True)
check("explicitly offline", read_coordinator('{"online": false}') is False)
check("a cleared retained topic is not up", read_coordinator("") is False)
check("junk is not up", read_coordinator("yes please") is False)
check("a bare true is not the contract", read_coordinator("true") is False)
print()
print("7. statuses, as the coordinator sends them")
def status(**kw):
body = {"id": "nursery-8f21c3", "room": "nursery",
"status": SUBMITTED, "message": "Sending", "updated_at": 1}
body.update(kw)
return json.dumps(body)
seen = read_status(status(status=WAITING_APPROVAL, message="Waiting for approval"))
check("waiting for approval is not terminal",
seen["status"] == WAITING_APPROVAL and not seen["terminal"])
check("its wording is the coordinator's, verbatim",
seen["message"] == "Waiting for approval")
seen = read_status(status(status=SENT_TO_SANCTUARY, message="Sent to sanctuary"))
check("sent to sanctuary is terminal and good",
seen["terminal"] and seen["good"])
check("and it never says displayed",
"displayed" not in seen["message"].lower())
for bad in (REJECTED, EXPIRED, ERROR, UNAVAILABLE):
seen = read_status(status(status=bad))
check(f"{bad} is terminal but not good",
seen["terminal"] and not seen["good"])
check("an unknown status is not shown",
read_status(status(status="displayed")) is None)
check("json that is not an object is not a status",
read_status("[1,2,3]") is None)
check("junk is not a status", read_status("not json") is None)
check("a status with no usable message falls back to the coordinator's word",
read_status(status(message=""))["message"] == WORDING[SUBMITTED])
check("another room's request is never shown here",
read_status(status(id="k2-ffffff"), request_id="nursery-8f21c3") is None)
check("our own request is",
read_status(status(), request_id="nursery-8f21c3")["status"] == SUBMITTED)
check("a detail is carried but bounded",
len(read_status(status(detail="x" * 500))["detail"]) == 200)
print()
print("8. when a teacher may send at all")
can, why = availability(MODE_AUTO, True)
check("auto with the coordinator up", can and why is None)
can, why = availability(MODE_APPROVAL, True)
check("approval with the coordinator up", can and why is None)
can, why = availability(MODE_OFF, True)
check("off cannot send", not can)
check("and says so in the coordinator's own words",
why == WORDING[UNAVAILABLE], why)
can, why = availability(MODE_AUTO, False)
check("the coordinator being down stops it", not can)
check("and does not pretend a call would arrive",
"not running" in why, why)
can, why = availability(MODE_AUTO, True, broker_reachable=False)
check("no broker stops it", not can)
check("and says the network, not the sanctuary",
"network" in why, why)
can, why = availability(None, True)
check("a broker with no retained mode stops it", not can)
check("and says it is not set up", "set up" in why, why)
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY
+11 -1
View File
@@ -257,7 +257,9 @@ install -Dm644 \
"$ROOT/config/plasma/solid/ekklesia-dvd.desktop" \
"$STAGE/usr/share/solid/actions/ekklesia-dvd.desktop"
for icon in ekklesia-wonderink ekklesia-parent-call; do
# Wonder Ink needs a mark of its own. Parent Call does not: it uses
# Breeze's call-outgoing, so there is one fewer drawing to maintain.
for icon in ekklesia-wonderink; do
install -Dm644 \
"$ROOT/config/icons/${icon}.svg" \
"$STAGE/usr/share/icons/hicolor/scalable/apps/${icon}.svg"
@@ -281,6 +283,10 @@ install -Dm755 \
"$ROOT/scripts/classroom-audio" \
"$STAGE/usr/local/bin/classroom-audio"
install -Dm755 \
"$ROOT/scripts/classroom-parent-call" \
"$STAGE/usr/local/bin/classroom-parent-call"
install -Dm755 \
"$ROOT/scripts/classroom-theme" \
"$STAGE/usr/local/bin/classroom-theme"
@@ -289,6 +295,10 @@ install -Dm644 \
"$ROOT/lib/classroom_audio.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_audio.py"
install -Dm644 \
"$ROOT/lib/classroom_parent_call.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_parent_call.py"
# Still here, and still sway-shaped.
#
# The DVD control surface and the MQTT notifier were deliberately not
+153
View File
@@ -0,0 +1,153 @@
#!/bin/bash
# A Parent Call sanctuary that cannot reach the sanctuary.
#
# The real coordinator, from the control computer, running in the lab
# against a broker on loopback and a ProPresenter that is a mock. The
# church's own networks are rejected from inside the container first, so
# a wrong host in a config file cannot put a child's name on the real
# screen. Coordinator development has done that once already.
#
# tools/lab/parent-call-rig up [MODE] # off | approval | auto
# tools/lab/parent-call-rig mode MODE
# tools/lab/parent-call-rig approve|reject
# tools/lab/parent-call-rig watch
# tools/lab/parent-call-rig triggers
# tools/lab/parent-call-rig down
set -uo pipefail
NAME="${LAB_NAME:-classroom-v2-lab}"
SRC="${PARENT_CALL_SRC:-classroom-control:/home/media/docker/parent-call}"
ROOT=classroom/parent-call
inlab() { docker exec "$NAME" "$@"; }
guard() {
# Container-local. This netns belongs to the container alone, so
# nothing on the host or the church network is changed by it.
inlab sh -c '
iptables -C OUTPUT -d 10.30.0.0/16 -j REJECT 2>/dev/null ||
iptables -I OUTPUT -d 10.30.0.0/16 -j REJECT
iptables -C OUTPUT -d 10.40.0.0/16 -j REJECT 2>/dev/null ||
iptables -I OUTPUT -d 10.40.0.0/16 -j REJECT'
for target in 10.40.0.2/1883 10.30.0.2/1025; do
if inlab timeout 4 sh -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
echo "REFUSING TO CONTINUE: ${target} is still reachable" >&2
exit 1
fi
done
echo " church networks unreachable from the lab"
}
case "${1:-up}" in
up)
mode="${2:-approval}"
inlab sh -c 'apt-get install -y -qq --no-install-recommends \
mosquitto mosquitto-clients python3-paho-mqtt iptables >/dev/null 2>&1'
guard
inlab sh -c 'mkdir -p /opt/pc-test'
ssh -o BatchMode=yes "${SRC%%:*}" "cd ${SRC#*:} && tar cf - src" \
| docker exec -i "$NAME" sh -c 'cd /opt/pc-test && tar xf -'
echo " coordinator source copied from the control computer"
docker exec -i "$NAME" sh -c 'cat > /opt/pc-test/mosquitto.conf' <<CONF
# Loopback only. Nothing published here can leave the container.
listener 1883 127.0.0.1
allow_anonymous true
persistence false
CONF
docker exec -i "$NAME" sh -c 'cat > /opt/pc-test/coordinator.env' <<ENV
MQTT_HOST=127.0.0.1
MQTT_PORT=1883
MQTT_CLIENT_ID=parent-call-lab
TOPIC_ROOT=$ROOT
PROPRESENTER_BASE_URL=http://127.0.0.1:1025
PROPRESENTER_MESSAGE='Parent Caller'
PROPRESENTER_CHILD_TOKEN=Name
PROPRESENTER_ROOM_TOKEN=Room
PROPRESENTER_TIMEOUT=4
PROPRESENTER_CLEAR_AFTER=20
ROOM_NAMES='nursery=Nursery,k2=K-2,older=Older Kids'
REQUEST_TTL=180
MAX_CHILD_LENGTH=40
MAX_ROOM_LENGTH=32
MAX_CLOCK_SKEW=120
DEFAULT_MODE=$mode
HEALTH_PORT=8099
ENV
docker exec -i "$NAME" sh -c 'cat > /opt/propresenter-mock.py' \
< "$(dirname "$0")/propresenter-mock.py"
# Debian's own mosquitto unit would listen where the lab does not want
# it to. The rig runs its own, on loopback, and nothing else.
inlab sh -c 'systemctl disable --now mosquitto >/dev/null 2>&1;
pkill -f propresenter-mock; pkill -f parent_call.main; pkill mosquitto;
fuser -k 1025/tcp 1883/tcp 2>/dev/null; true'
sleep 3
inlab sh -c 'setsid mosquitto -c /opt/pc-test/mosquitto.conf > /tmp/mosquitto.log 2>&1 &'
inlab sh -c 'setsid python3 /opt/propresenter-mock.py 1025 > /tmp/ppmock.log 2>&1 &'
sleep 2
inlab sh -c 'cd /opt/pc-test && set -a && . ./coordinator.env && set +a &&
setsid env PYTHONPATH=/opt/pc-test/src python3 -m parent_call.main \
> /tmp/coordinator.log 2>&1 &'
sleep 4
# The room points at the loopback broker, never at the church's.
inlab python3 - <<'PY'
import pathlib
p = pathlib.Path("/etc/ekklesia-classroom/device.conf")
t = p.read_text()
t = t.replace("MQTT_ENABLED=false", "MQTT_ENABLED=true")
if "MQTT_HOST=127.0.0.1" not in t:
t = t.replace("MQTT_HOST=", "MQTT_HOST=127.0.0.1", 1)
if "\nROOM=" not in t:
t += "\n# Lab only.\nROOM=nursery\n"
p.write_text(t)
PY
"$0" mode "$mode"
inlab tail -3 /tmp/coordinator.log
echo " rig up in mode $mode"
;;
mode)
inlab mosquitto_pub -h 127.0.0.1 -t "$ROOT/mode/set" -m "${2:?mode}"
sleep 1
echo -n " mode is now: "
inlab timeout 3 mosquitto_sub -h 127.0.0.1 -t "$ROOT/mode" -C 1 || true
;;
approve|reject)
id=$(inlab timeout 3 mosquitto_sub -h 127.0.0.1 -t "$ROOT/pending" -C 1 |
python3 -c 'import json,sys; print(json.load(sys.stdin).get("id") or "")')
[ -n "$id" ] || { echo " nothing is waiting"; exit 0; }
inlab mosquitto_pub -h 127.0.0.1 -t "$ROOT/$1" -m "{\"id\": \"$id\"}"
echo " $1ed $id"
;;
watch)
inlab mosquitto_sub -h 127.0.0.1 -t "$ROOT/#" -v
;;
triggers)
inlab sh -c 'cat /tmp/propresenter-mock.log 2>/dev/null || echo "(none)"'
;;
log)
inlab tail -n "${2:-25}" /tmp/coordinator.log
;;
down)
inlab sh -c 'pkill -f parent_call.main; pkill -f propresenter-mock; pkill mosquitto; true'
echo " rig down"
;;
*)
sed -n '2,14p' "$0"
exit 1
;;
esac
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""A stand-in for the church's ProPresenter, so no test can reach it.
It serves exactly what the coordinator's adapter checks for: a message
called "Parent Caller" with tokens Name and Room, a 204 on trigger, and a
204 on clear. Every trigger is appended to a log the test reads, which is
how "sent to sanctuary" is verified without a sanctuary.
"""
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
UUID = "11111111-2222-3333-4444-555555555555"
LOG = "/tmp/propresenter-mock.log"
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _json(self, body, code=200):
payload = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
if self.path == "/version":
return self._json({"name": "mock", "api_version": "v1"})
if self.path == "/v1/messages":
return self._json([{
"id": {"name": "Parent Caller", "uuid": UUID},
"tokens": [{"name": "Name"}, {"name": "Room"}],
}])
if self.path.endswith("/clear"):
with open(LOG, "a") as fh:
fh.write("CLEAR\n")
self.send_response(204)
self.end_headers()
return
self.send_response(404)
self.end_headers()
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length).decode()
if self.path.endswith("/trigger"):
with open(LOG, "a") as fh:
fh.write("TRIGGER " + body + "\n")
self.send_response(204)
self.end_headers()
return
self.send_response(404)
self.end_headers()
if __name__ == "__main__":
open(LOG, "w").close()
HTTPServer(("127.0.0.1", int(sys.argv[1] if len(sys.argv) > 1 else 1025)),
Handler).serve_forever()