feat: give the rooms a line to the church broker
The first piece of turning these machines into connected endpoints. classroom-mqtt is one small service per room that subscribes, validates what arrives, and hands the work to commands that already exist. Nothing in the room depends on it. A broker that is down, a network that is missing, a machine with no broker configured at all — each is a service sitting quietly, not a classroom that fails. Nothing waits for it at boot and the shipped default is off, so every existing classroom upgrades into this release with messaging disabled until a broker exists. The room is the hostname, which is already how these machines are named. Topics carry no product name because the broker is the church's and these are just rooms: classroom/notify/all, classroom/notify/<room>, and a retained classroom/status/<room> so anyone asking later can be told which rooms are up without waiting for them to say so again. Notifications are events and are never retained; status is state and is. Stale messages cannot come back, which is the property that matters most and has two halves. The session is clean, so a broker never hands over a backlog from while the room was off. And retained notifications are refused on arrival, because otherwise one would replay into every room at every reboot forever. The honest limit is written down: MQTT only flags a retained message when it is replayed to a new subscription, so a retained publish delivered live is indistinguishable from an ordinary one and is shown once — which is right, since it is live at that moment, and the replay is the actual hazard. Delivery reuses the session-aware sender rather than a second copy of it. classroom-notify-test grew into the real thing, so it is now classroom-notify with the sample sender a wrapper over it, and its 36 tests came along. The broker password lives in device.conf, so that file stops being world readable: 0640 root:classroom, which everything that reads it already satisfies. Transport logic is kept free of any broker library so it can be tested without one — 40 checks on room identity, the topic contract and which payloads become notifications. Separately verified against a real mosquitto: connect, subscribe, retained status, per-room and all-room delivery, urgency, duplicate and expired and malformed messages ignored, the broker killed with the daemon surviving, reconnect, and a stale retained notification left on the broker not reaching the screen when a room boots. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c3106edd30
commit
dfd42f6fdf
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
set -uo pipefail
|
||||
|
||||
# The rules for what the classroom accepts from the broker: room
|
||||
# identity, the topic contract, and which payloads become notifications.
|
||||
# No broker involved — these are decisions, not plumbing.
|
||||
#
|
||||
# tests/test-classroom-mqtt.sh
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
PYTHONPATH="$ROOT/lib" python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
import classroom_mqtt as m
|
||||
|
||||
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 room is the machine")
|
||||
check("hostname becomes the room", m.room_name({}, "nursery") == "nursery")
|
||||
check("domain is dropped", m.room_name({}, "k2.church.local") == "k2")
|
||||
check("case is normalised", m.room_name({}, "Older") == "older")
|
||||
check("ROOM overrides the hostname",
|
||||
m.room_name({"ROOM": "older"}, "box-17") == "older")
|
||||
check("unsafe characters cannot reach a topic",
|
||||
m.room_name({}, "Room 3!!") == "room-3")
|
||||
check("a nameless machine still has a room",
|
||||
m.room_name({}, "") == "unknown")
|
||||
|
||||
print()
|
||||
print("2. the topic contract")
|
||||
topics = m.notify_topics("nursery")
|
||||
check("listens to every room", "classroom/notify/all" in topics)
|
||||
check("listens to its own room", "classroom/notify/nursery" in topics)
|
||||
check("status is per room", m.status_topic("nursery") == "classroom/status/nursery")
|
||||
check("no product name in any topic",
|
||||
not any("ekklesia" in t for t in topics + (m.status_topic("nursery"),)))
|
||||
|
||||
print()
|
||||
print("3. a classroom without a broker stays quiet")
|
||||
check("disabled means no broker", m.broker({"MQTT_HOST": "h"}) is None)
|
||||
check("enabled with no host means no broker",
|
||||
m.broker({"MQTT_ENABLED": "true"}) is None)
|
||||
check("no config at all means no broker", m.broker({}) is None)
|
||||
settings = m.broker({"MQTT_ENABLED": "yes", "MQTT_HOST": "10.40.0.2"})
|
||||
check("host is taken", settings and settings["host"] == "10.40.0.2")
|
||||
check("port defaults to 1883", settings and settings["port"] == 1883)
|
||||
settings = m.broker({"MQTT_ENABLED": "1", "MQTT_HOST": "h", "MQTT_PORT": "not a port"})
|
||||
check("a nonsense port falls back rather than crashing",
|
||||
settings and settings["port"] == 1883)
|
||||
|
||||
print()
|
||||
print("4. which messages become notifications")
|
||||
|
||||
now = 1_780_000_000
|
||||
|
||||
|
||||
def payload(**fields):
|
||||
body = {"title": "Sermon Starting", "message": "The sermon has started."}
|
||||
body.update(fields)
|
||||
return json.dumps(body)
|
||||
|
||||
|
||||
n = m.Notifications()
|
||||
note, reason = n.accept(payload(id="a1"), now=now)
|
||||
check("a good message is accepted", reason == "ok", reason)
|
||||
check("title survives", note and note["title"] == "Sermon Starting")
|
||||
check("message survives", note and note["message"] == "The sermon has started.")
|
||||
check("urgency defaults to normal", note and note["urgency"] == "normal")
|
||||
|
||||
note, reason = n.accept(payload(id="a2", urgency="critical"), now=now)
|
||||
check("critical is honoured", note and note["urgency"] == "critical")
|
||||
note, reason = n.accept(payload(id="a3", urgency="screaming"), now=now)
|
||||
check("a nonsense urgency falls back to normal",
|
||||
note and note["urgency"] == "normal")
|
||||
|
||||
print()
|
||||
print("5. what gets dropped, without anything falling over")
|
||||
|
||||
n = m.Notifications()
|
||||
for label, args, expected in [
|
||||
("a retained notification is stale by definition",
|
||||
{"payload": payload(id="r"), "retained": True}, "retained"),
|
||||
("not JSON", {"payload": "{{{"}, "not json"),
|
||||
("a JSON array is not a notification", {"payload": "[1,2,3]"}, "not an object"),
|
||||
("no title", {"payload": json.dumps({"message": "x"})}, "no title"),
|
||||
("a blank title", {"payload": json.dumps({"title": " "})}, "no title"),
|
||||
("a title that is not text",
|
||||
{"payload": json.dumps({"title": 7})}, "no title"),
|
||||
("a message that is not text",
|
||||
{"payload": json.dumps({"title": "t", "message": 7})}, "message is not text"),
|
||||
("an expiry that is not a time",
|
||||
{"payload": payload(expires_at="soon")}, "expires_at is not a time"),
|
||||
("an id that is not text", {"payload": payload(id=7)}, "id is not text"),
|
||||
("invalid utf-8", {"payload": b"\xff\xfe"}, "not utf-8"),
|
||||
]:
|
||||
note, reason = n.accept(now=now, **args)
|
||||
check(label, note is None and reason == expected, f"got {reason}")
|
||||
|
||||
print()
|
||||
print("6. expiry and duplicates")
|
||||
|
||||
n = m.Notifications()
|
||||
note, reason = n.accept(payload(id="old", expires_at=now - 1), now=now)
|
||||
check("an expired message is ignored", note is None and reason == "expired")
|
||||
note, reason = n.accept(payload(id="future", expires_at=now + 60), now=now)
|
||||
check("a live message is accepted", reason == "ok")
|
||||
note, reason = n.accept(payload(id="future"), now=now)
|
||||
check("the same id is not shown twice", note is None and reason == "duplicate")
|
||||
note, reason = n.accept(payload(id="different"), now=now)
|
||||
check("a different id still gets through", reason == "ok")
|
||||
note, reason = n.accept(payload(), now=now)
|
||||
check("a message with no id is not deduplicated", reason == "ok")
|
||||
note, reason = n.accept(payload(), now=now)
|
||||
check("and still is not", reason == "ok")
|
||||
|
||||
n = m.Notifications(remember=4)
|
||||
for index in range(10):
|
||||
n.accept(payload(id=f"m{index}"), now=now)
|
||||
check("the id history stays bounded", len(n.seen) == 4, f"{len(n.seen)} kept")
|
||||
note, reason = n.accept(payload(id="m0"), now=now)
|
||||
check("an id forgotten long ago can come round again", reason == "ok")
|
||||
|
||||
print()
|
||||
print(f"passed: {passed} failed: {failed}")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -11,7 +11,7 @@ set -uo pipefail
|
||||
# tests/test-classroom-notify-test.sh
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SCRIPT="$ROOT/scripts/classroom-notify-test"
|
||||
SCRIPT="$ROOT/scripts/classroom-notify"
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"; kill "${FAKE_SWAY_PID:-}" 2>/dev/null' EXIT
|
||||
@@ -192,7 +192,7 @@ echo "5. an unprivileged administrator gets told what to do"
|
||||
|
||||
FAKE_CURRENT_UID=9999
|
||||
out="$(run)"
|
||||
check "explains it needs root" "run this as root (sudo classroom-notify-test)" "$out"
|
||||
check "explains it needs root" "run this as root (sudo classroom-notify)" "$out"
|
||||
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
||||
FAKE_CURRENT_UID=4000
|
||||
|
||||
@@ -285,6 +285,22 @@ check "dry run names this session" "Would notify this session" "$out"
|
||||
|
||||
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR
|
||||
|
||||
# --------------------------------- 12. classroom-notify-test delegates
|
||||
|
||||
echo "12. the sample sender delegates to classroom-notify"
|
||||
|
||||
export WAYLAND_DISPLAY=wayland-4
|
||||
export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus"
|
||||
export XDG_RUNTIME_DIR="$RUNTIME"
|
||||
|
||||
: > "$NOTIFY_LOG"
|
||||
out="$("$ROOT/scripts/classroom-notify-test" 2>&1)"
|
||||
check "sample still reports being sent" "Sent normal notification" "$out"
|
||||
check "sample title unchanged" "DVD Ready" "$(cat "$NOTIFY_LOG")"
|
||||
check "sample body unchanged" "A disc is ready to play." "$(cat "$NOTIFY_LOG")"
|
||||
|
||||
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR
|
||||
|
||||
# ----------------------------------------------------------- summary
|
||||
|
||||
echo
|
||||
Reference in New Issue
Block a user