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]>
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||