feat: let a teacher ask the sanctuary to call a child's parent
The coordinator already existed on the control computer and its contract is frozen, so this is the classroom half of a contract that was read from its README and source rather than guessed: four fields out, the status topic subscribed before anything is published, and every word the teacher reads taken from the coordinator's own message field. It is a separate process, not a Plasma applet. A broker library, a network timeout and a text entry have no business inside the only way a teacher has of getting anywhere; if this dies the bar is untouched. The panel mark is Breeze's call-outgoing. The old one was a bell, sitting two inches from Plasma's notification bell in the same bar and meaning something entirely different. Two things it will not do: claim a call was displayed, and let a teacher type the room. The machine already knows which room it is. Sixty tests cover the contract without a broker. The live path was proven against the real coordinator running in the lab, on a loopback broker, with ProPresenter mocked and the church's networks rejected from inside the container first. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
380f031092
commit
7790003c03
+11
-1
@@ -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
|
||||
|
||||
Executable
+153
@@ -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()
|
||||
Reference in New Issue
Block a user