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

The coordinator already existed on the control computer and its contract
is frozen, so this is the classroom half of a contract that was read from
its README and source rather than guessed: four fields out, the status
topic subscribed before anything is published, and every word the teacher
reads taken from the coordinator's own message field.

It is a separate process, not a Plasma applet. A broker library, a
network timeout and a text entry have no business inside the only way a
teacher has of getting anywhere; if this dies the bar is untouched.

The panel mark is Breeze's call-outgoing. The old one was a bell, sitting
two inches from Plasma's notification bell in the same bar and meaning
something entirely different.

Two things it will not do: claim a call was displayed, and let a teacher
type the room. The machine already knows which room it is.

Sixty tests cover the contract without a broker. The live path was proven
against the real coordinator running in the lab, on a loopback broker,
with ProPresenter mocked and the church's networks rejected from inside
the container first.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 10:37:06 -05:00
co-authored by Claude Opus 5
parent 380f031092
commit 7790003c03
17 changed files with 1478 additions and 28 deletions
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""A stand-in for the church's ProPresenter, so no test can reach it.
It serves exactly what the coordinator's adapter checks for: a message
called "Parent Caller" with tokens Name and Room, a 204 on trigger, and a
204 on clear. Every trigger is appended to a log the test reads, which is
how "sent to sanctuary" is verified without a sanctuary.
"""
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
UUID = "11111111-2222-3333-4444-555555555555"
LOG = "/tmp/propresenter-mock.log"
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _json(self, body, code=200):
payload = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
if self.path == "/version":
return self._json({"name": "mock", "api_version": "v1"})
if self.path == "/v1/messages":
return self._json([{
"id": {"name": "Parent Caller", "uuid": UUID},
"tokens": [{"name": "Name"}, {"name": "Room"}],
}])
if self.path.endswith("/clear"):
with open(LOG, "a") as fh:
fh.write("CLEAR\n")
self.send_response(204)
self.end_headers()
return
self.send_response(404)
self.end_headers()
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length).decode()
if self.path.endswith("/trigger"):
with open(LOG, "a") as fh:
fh.write("TRIGGER " + body + "\n")
self.send_response(204)
self.end_headers()
return
self.send_response(404)
self.end_headers()
if __name__ == "__main__":
open(LOG, "w").close()
HTTPServer(("127.0.0.1", int(sys.argv[1] if len(sys.argv) > 1 else 1025)),
Handler).serve_forever()