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]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Parent Call
|
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
|
Exec=/usr/local/bin/classroom-parent-call
|
||||||
Icon=ekklesia-parent-call
|
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Utility;
|
Categories=Utility;
|
||||||
StartupNotify=false
|
StartupNotify=true
|
||||||
NoDisplay=false
|
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.
|
# 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
|
# 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
|
# manager behaviour and needs no rule: a dialog stays in front of the window
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
# 314-line window arbiter was doing.
|
# 314-line window arbiter was doing.
|
||||||
|
|
||||||
[General]
|
[General]
|
||||||
count=1
|
count=2
|
||||||
rules=classroom-video
|
rules=classroom-video,classroom-parent-call
|
||||||
|
|
||||||
[classroom-video]
|
[classroom-video]
|
||||||
Description=Video is an appliance screen, not a window
|
Description=Video is an appliance screen, not a window
|
||||||
@@ -30,3 +30,21 @@ wmclassmatch=3
|
|||||||
wmclasscomplete=false
|
wmclasscomplete=false
|
||||||
noborder=true
|
noborder=true
|
||||||
noborderrule=2
|
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" \
|
"$ROOT/config/plasma/solid/ekklesia-dvd.desktop" \
|
||||||
"$STAGE/usr/share/solid/actions/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 \
|
install -Dm644 \
|
||||||
"$ROOT/config/icons/${icon}.svg" \
|
"$ROOT/config/icons/${icon}.svg" \
|
||||||
"$STAGE/usr/share/icons/hicolor/scalable/apps/${icon}.svg"
|
"$STAGE/usr/share/icons/hicolor/scalable/apps/${icon}.svg"
|
||||||
@@ -281,6 +283,10 @@ install -Dm755 \
|
|||||||
"$ROOT/scripts/classroom-audio" \
|
"$ROOT/scripts/classroom-audio" \
|
||||||
"$STAGE/usr/local/bin/classroom-audio"
|
"$STAGE/usr/local/bin/classroom-audio"
|
||||||
|
|
||||||
|
install -Dm755 \
|
||||||
|
"$ROOT/scripts/classroom-parent-call" \
|
||||||
|
"$STAGE/usr/local/bin/classroom-parent-call"
|
||||||
|
|
||||||
install -Dm755 \
|
install -Dm755 \
|
||||||
"$ROOT/scripts/classroom-theme" \
|
"$ROOT/scripts/classroom-theme" \
|
||||||
"$STAGE/usr/local/bin/classroom-theme"
|
"$STAGE/usr/local/bin/classroom-theme"
|
||||||
@@ -289,6 +295,10 @@ install -Dm644 \
|
|||||||
"$ROOT/lib/classroom_audio.py" \
|
"$ROOT/lib/classroom_audio.py" \
|
||||||
"$STAGE/usr/local/lib/ekklesia-classroom/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.
|
# Still here, and still sway-shaped.
|
||||||
#
|
#
|
||||||
# The DVD control surface and the MQTT notifier were deliberately not
|
# 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()
|
||||||