diff --git a/config/icons/ekklesia-parent-call.svg b/config/icons/ekklesia-parent-call.svg
deleted file mode 100644
index f4661b8..0000000
--- a/config/icons/ekklesia-parent-call.svg
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
diff --git a/config/plasma/applications/ekklesia-parent-call.desktop b/config/plasma/applications/ekklesia-parent-call.desktop
index 1a32b1e..c53205e 100644
--- a/config/plasma/applications/ekklesia-parent-call.desktop
+++ b/config/plasma/applications/ekklesia-parent-call.desktop
@@ -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
diff --git a/config/plasma/xdg/kwinrulesrc b/config/plasma/xdg/kwinrulesrc
index 5be0128..63b5b09 100644
--- a/config/plasma/xdg/kwinrulesrc
+++ b/config/plasma/xdg/kwinrulesrc
@@ -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
diff --git a/docs/screenshots/icon-candidates.png b/docs/screenshots/icon-candidates.png
new file mode 100644
index 0000000..bd693c5
Binary files /dev/null and b/docs/screenshots/icon-candidates.png differ
diff --git a/docs/screenshots/pc-crop.png b/docs/screenshots/pc-crop.png
new file mode 100644
index 0000000..f97325b
Binary files /dev/null and b/docs/screenshots/pc-crop.png differ
diff --git a/docs/screenshots/pc-not-set-up.png b/docs/screenshots/pc-not-set-up.png
new file mode 100644
index 0000000..0bce018
Binary files /dev/null and b/docs/screenshots/pc-not-set-up.png differ
diff --git a/docs/screenshots/pc-ready.png b/docs/screenshots/pc-ready.png
new file mode 100644
index 0000000..cfbee71
Binary files /dev/null and b/docs/screenshots/pc-ready.png differ
diff --git a/docs/screenshots/pc-sent.png b/docs/screenshots/pc-sent.png
new file mode 100644
index 0000000..3bbc46a
Binary files /dev/null and b/docs/screenshots/pc-sent.png differ
diff --git a/docs/screenshots/pc-state.png b/docs/screenshots/pc-state.png
new file mode 100644
index 0000000..78d759c
Binary files /dev/null and b/docs/screenshots/pc-state.png differ
diff --git a/docs/screenshots/pc-typed.png b/docs/screenshots/pc-typed.png
new file mode 100644
index 0000000..7a588f7
Binary files /dev/null and b/docs/screenshots/pc-typed.png differ
diff --git a/docs/screenshots/pc-waiting.png b/docs/screenshots/pc-waiting.png
new file mode 100644
index 0000000..ac6fd48
Binary files /dev/null and b/docs/screenshots/pc-waiting.png differ
diff --git a/lib/classroom_parent_call.py b/lib/classroom_parent_call.py
new file mode 100644
index 0000000..bb9b843
--- /dev/null
+++ b/lib/classroom_parent_call.py
@@ -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
diff --git a/scripts/classroom-parent-call b/scripts/classroom-parent-call
new file mode 100755
index 0000000..2fb3159
--- /dev/null
+++ b/scripts/classroom-parent-call
@@ -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())
diff --git a/tests/test-classroom-parent-call.sh b/tests/test-classroom-parent-call.sh
new file mode 100755
index 0000000..20df31a
--- /dev/null
+++ b/tests/test-classroom-parent-call.sh
@@ -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("Emma") == "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
diff --git a/tools/build-release.sh b/tools/build-release.sh
index 3de1d89..1bb79b6 100755
--- a/tools/build-release.sh
+++ b/tools/build-release.sh
@@ -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
diff --git a/tools/lab/parent-call-rig b/tools/lab/parent-call-rig
new file mode 100755
index 0000000..b1b0cb2
--- /dev/null
+++ b/tools/lab/parent-call-rig
@@ -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' < /opt/pc-test/coordinator.env' < /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
diff --git a/tools/lab/propresenter-mock.py b/tools/lab/propresenter-mock.py
new file mode 100644
index 0000000..f3857d8
--- /dev/null
+++ b/tools/lab/propresenter-mock.py
@@ -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()