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:
ekklesia
2026-09-06 14:41:49 -05:00
co-authored by Claude Opus 5
parent c3106edd30
commit dfd42f6fdf
12 changed files with 992 additions and 195 deletions
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""The classroom's link to the church broker.
Runs as the classroom user so it can put messages on the room's screen
through classroom-notify, and holds no opinion about anything else: it
subscribes, validates, and hands work to the classroom commands that
already exist.
Nothing here is allowed to matter to the room. A broker that is down, a
network that is missing, a machine with no broker configured at all —
each of those is a service that sits quietly, not a classroom that
fails. The dock, the browser and the DVD player never ask this whether
it is connected.
"""
import json
import os
import subprocess
import sys
import time
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_mqtt as transport # noqa: E402
from classroom_config import read_device_config # noqa: E402
NOTIFY = "/usr/local/bin/classroom-notify"
# Long enough that a broker down for the morning does not fill the
# journal, short enough that a blip is invisible.
RECONNECT_MIN_SECONDS = 2
RECONNECT_MAX_SECONDS = 60
VERSION_FILE = "/usr/local/lib/ekklesia-classroom/VERSION"
def log(message):
print(f"[classroom-mqtt] {message}", flush=True)
def version():
try:
with open(VERSION_FILE, encoding="utf-8") as handle:
return handle.read().strip()
except OSError:
return "unknown"
class Classroom:
def __init__(self, room, settings):
self.room = room
self.settings = settings
self.notifications = transport.Notifications()
self.connected = False
# -- delivery ---------------------------------------------------
def show(self, notification):
try:
subprocess.Popen(
[
NOTIFY,
f"--urgency={notification['urgency']}",
"--",
notification["title"],
notification["message"],
],
start_new_session=True,
)
except OSError as error:
log(f"could not show a notification: {error}")
# -- broker callbacks -------------------------------------------
def on_connect(self, client, userdata, flags, reason_code, properties=None):
if reason_code != 0:
log(f"broker refused the connection: {reason_code}")
return
self.connected = True
topics = transport.notify_topics(self.room)
for topic in topics:
client.subscribe(topic, qos=1)
log(f"connected as {self.room}, listening on {', '.join(topics)}")
# Retained, because this is state rather than an event: whoever
# asks later should be told the room is up without waiting for
# it to say so again.
client.publish(
transport.status_topic(self.room),
json.dumps(
{
"room": self.room,
"online": True,
"version": version(),
"since": int(time.time()),
}
),
qos=1,
retain=True,
)
def on_disconnect(self, client, userdata, flags, reason_code=None, properties=None):
# Only worth a line the first time: paho retries on its own, and
# a broker that is down for an hour should not write an hour of
# log lines.
if self.connected:
self.connected = False
log("lost the broker; retrying quietly until it returns")
def on_message(self, client, userdata, message):
try:
self.dispatch(message)
except Exception as error: # noqa: BLE001
# A bad message is never worth taking the service down for.
log(f"dropped a message on {message.topic}: {error}")
def dispatch(self, message):
topic = message.topic
if topic in transport.notify_topics(self.room):
notification, reason = self.notifications.accept(
message.payload,
retained=bool(message.retain),
)
if notification is None:
log(f"ignored a notification on {topic}: {reason}")
return
self.show(notification)
return
log(f"nothing listens to {topic} yet")
def main():
config = read_device_config()
room = transport.room_name(config)
settings = transport.broker(config)
if settings is None:
log("no broker configured for this classroom; nothing to do")
return 0
try:
import paho.mqtt.client as mqtt
except ImportError:
log("python3-paho-mqtt is not installed; nothing to do")
return 0
classroom = Classroom(room, settings)
# A stable client id keeps one room from appearing twice, and a clean
# session means a broker never hands over a backlog of notifications
# from while the room was off. Yesterday's message is not news.
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=f"classroom-{room}",
protocol=mqtt.MQTTv311,
clean_session=True,
)
if settings["username"]:
client.username_pw_set(settings["username"], settings["password"])
client.will_set(
transport.status_topic(room),
json.dumps({"room": room, "online": False}),
qos=1,
retain=True,
)
client.on_connect = classroom.on_connect
client.on_disconnect = classroom.on_disconnect
client.on_message = classroom.on_message
client.reconnect_delay_set(
min_delay=RECONNECT_MIN_SECONDS,
max_delay=RECONNECT_MAX_SECONDS,
)
log(f"room {room}, broker {settings['host']}:{settings['port']}")
# connect_async plus loop_forever means a broker that is not there at
# boot is simply a broker that is not there yet.
client.connect_async(settings["host"], settings["port"], keepalive=45)
try:
client.loop_forever(retry_first_connection=True)
except KeyboardInterrupt:
pass
finally:
client.disconnect()
return 0
if __name__ == "__main__":
sys.exit(main())
+214
View File
@@ -0,0 +1,214 @@
#!/bin/bash
set -euo pipefail
# Puts a notification on the classroom screen, from anywhere.
#
# classroom-notify "Title" "Body text"
# classroom-notify --critical "Parent Call" "Room 3 needs a parent."
# classroom-notify --urgency=low "DVD" "Disc ejected."
# sudo classroom-notify --dry-run
#
# Used by classroom-notify-test to send a sample, and by classroom-mqtt
# to put messages from the church broker on the screen.
#
# The classroom desktop runs as the classroom user, and mako is attached
# to that user's D-Bus session. An administrator over SSH sits in a
# different session where nothing owns org.freedesktop.Notifications, so
# a plain notify-send there asks D-Bus to start a second mako, which has
# no display to open and times out with NoReply. Find the running desktop
# session and deliver into it instead.
CLASSROOM_USER=classroom
urgency=normal
dry_run=false
while [[ $# -gt 0 ]]; do
case "$1" in
--critical)
urgency=critical
shift
;;
--low)
urgency=low
shift
;;
--urgency=*)
urgency="${1#--urgency=}"
case "$urgency" in
low|normal|critical) ;;
*) urgency=normal ;;
esac
shift
;;
--dry-run)
dry_run=true
shift
;;
--)
shift
break
;;
-*)
echo "classroom-notify: unknown option $1" >&2
echo "usage: classroom-notify-test [--low|--critical] [--dry-run] [title] [body]" >&2
exit 2
;;
*)
break
;;
esac
done
title="${1:-DVD Ready}"
body="${2:-A disc is ready to play.}"
fail() {
echo "classroom-notify: $1" >&2
exit 1
}
# Another process's environment, or nothing. Reading this needs ptrace
# access, which root has on the endpoint but not, for instance, inside a
# container with CAP_SYS_PTRACE dropped — so every caller has to cope
# with getting nothing back.
read_environ() {
{ tr '\0' '\n' < "/proc/$1/environ"; } 2>/dev/null || true
}
environ_value() {
printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1
}
# sway holds its own wayland-N.lock open, so the display name can be read
# back off the process instead of being guessed at.
display_from_lock() {
local link target
for link in "/proc/$1/fd"/*; do
target="$(readlink "$link" 2>/dev/null || true)"
case "$target" in
*/wayland-*.lock)
target="${target%.lock}"
echo "${target##*/}"
return 0
;;
esac
done
return 0
}
classroom_uid="$(id -u "$CLASSROOM_USER" 2>/dev/null || true)"
[[ -n "$classroom_uid" ]] ||
fail "there is no $CLASSROOM_USER user on this machine."
current_uid="$(id -u)"
runner=()
session="this session"
source_note="inherited from this session"
if [[ "$current_uid" == "$classroom_uid" &&
-n "${WAYLAND_DISPLAY:-}" &&
-n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
# Already inside the classroom desktop session.
sway_pid="n/a"
runtime_dir="${XDG_RUNTIME_DIR:-}"
bus="$DBUS_SESSION_BUS_ADDRESS"
wayland_display="$WAYLAND_DISPLAY"
else
session="the $CLASSROOM_USER desktop session"
sway_pid="$(pgrep -u "$CLASSROOM_USER" -x sway | head -n 1 || true)"
[[ -n "$sway_pid" ]] ||
fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER."
# Checked before anything that needs to look inside the session, so
# an administrator gets told what to do rather than a permission
# error from somewhere further down.
if [[ "$current_uid" != "$classroom_uid" && "$current_uid" != "0" ]]; then
fail "run this as root (sudo classroom-notify) or as the $CLASSROOM_USER user."
fi
environment_block="$(read_environ "$sway_pid")"
if [[ -n "$environment_block" ]]; then
source_note="read from sway (pid $sway_pid)"
else
source_note="sway's environment was unreadable; using session defaults"
fi
runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)"
runtime_dir="${runtime_dir:-/run/user/$classroom_uid}"
[[ -d "$runtime_dir" ]] ||
fail "the classroom session runtime directory $runtime_dir does not exist."
bus="$(environ_value "$environment_block" DBUS_SESSION_BUS_ADDRESS)"
bus="${bus:-unix:path=$runtime_dir/bus}"
case "$bus" in
unix:path=*)
bus_socket="${bus#unix:path=}"
bus_socket="${bus_socket%%,*}"
[[ -S "$bus_socket" ]] ||
fail "the classroom session bus socket $bus_socket does not exist. Is the desktop session fully up?"
;;
esac
wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)"
if [[ -z "$wayland_display" ]]; then
wayland_display="$(display_from_lock "$sway_pid")"
fi
environment=(
"XDG_RUNTIME_DIR=$runtime_dir"
"DBUS_SESSION_BUS_ADDRESS=$bus"
)
# notify-send only needs the bus. The display is passed through for
# the sake of anything else in the session, and its absence is not
# worth refusing to send over.
if [[ -n "$wayland_display" ]]; then
environment+=("WAYLAND_DISPLAY=$wayland_display")
fi
if [[ "$current_uid" == "$classroom_uid" ]]; then
runner=(env "${environment[@]}")
else
runner=(runuser -u "$CLASSROOM_USER" -- env "${environment[@]}")
fi
fi
# Deliver to the mako that is already running. Sending into a session
# without one would have D-Bus start a second mako, which is exactly the
# failure this script exists to avoid.
pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 ||
fail "mako is not running in the classroom session, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a mako"
if [[ "$dry_run" == true ]]; then
echo "Would notify $session:"
echo " sway pid: $sway_pid"
echo " session environment: $source_note"
echo " XDG_RUNTIME_DIR: $runtime_dir"
echo " DBUS_SESSION_BUS_ADDRESS: $bus"
echo " WAYLAND_DISPLAY: ${wayland_display:-(not resolved)}"
echo " urgency: $urgency"
echo " title: $title"
echo " body: $body"
exit 0
fi
"${runner[@]}" notify-send \
--app-name="Ekklesia Classroom" \
--urgency="$urgency" \
-- \
"$title" "$body"
echo "Sent $urgency notification to $session: $title"
+6 -190
View File
@@ -7,198 +7,14 @@ set -euo pipefail
# classroom-notify-test
# classroom-notify-test "Title" "Body text"
# classroom-notify-test --critical "Parent Call" "Room 3 needs a parent."
# sudo classroom-notify-test --dry-run
#
# The classroom desktop runs as the classroom user, and mako is attached
# to that user's D-Bus session. An administrator over SSH sits in a
# different session where nothing owns org.freedesktop.Notifications, so
# a plain notify-send there asks D-Bus to start a second mako, which has
# no display to open and times out with NoReply. Find the running desktop
# session and deliver into it instead.
# All of the work — finding the classroom desktop session and delivering
# into it — is classroom-notify's. This only supplies the sample.
CLASSROOM_USER=classroom
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
urgency=normal
dry_run=false
while [[ $# -gt 0 ]]; do
case "$1" in
--critical)
urgency=critical
shift
;;
--low)
urgency=low
shift
;;
--dry-run)
dry_run=true
shift
;;
--)
shift
break
;;
-*)
echo "classroom-notify-test: unknown option $1" >&2
echo "usage: classroom-notify-test [--low|--critical] [--dry-run] [title] [body]" >&2
exit 2
;;
*)
break
;;
esac
done
title="${1:-DVD Ready}"
body="${2:-A disc is ready to play.}"
fail() {
echo "classroom-notify-test: $1" >&2
exit 1
}
# Another process's environment, or nothing. Reading this needs ptrace
# access, which root has on the endpoint but not, for instance, inside a
# container with CAP_SYS_PTRACE dropped — so every caller has to cope
# with getting nothing back.
read_environ() {
{ tr '\0' '\n' < "/proc/$1/environ"; } 2>/dev/null || true
}
environ_value() {
printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1
}
# sway holds its own wayland-N.lock open, so the display name can be read
# back off the process instead of being guessed at.
display_from_lock() {
local link target
for link in "/proc/$1/fd"/*; do
target="$(readlink "$link" 2>/dev/null || true)"
case "$target" in
*/wayland-*.lock)
target="${target%.lock}"
echo "${target##*/}"
return 0
;;
esac
done
return 0
}
classroom_uid="$(id -u "$CLASSROOM_USER" 2>/dev/null || true)"
[[ -n "$classroom_uid" ]] ||
fail "there is no $CLASSROOM_USER user on this machine."
current_uid="$(id -u)"
runner=()
session="this session"
source_note="inherited from this session"
if [[ "$current_uid" == "$classroom_uid" &&
-n "${WAYLAND_DISPLAY:-}" &&
-n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
# Already inside the classroom desktop session.
sway_pid="n/a"
runtime_dir="${XDG_RUNTIME_DIR:-}"
bus="$DBUS_SESSION_BUS_ADDRESS"
wayland_display="$WAYLAND_DISPLAY"
else
session="the $CLASSROOM_USER desktop session"
sway_pid="$(pgrep -u "$CLASSROOM_USER" -x sway | head -n 1 || true)"
[[ -n "$sway_pid" ]] ||
fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER."
# Checked before anything that needs to look inside the session, so
# an administrator gets told what to do rather than a permission
# error from somewhere further down.
if [[ "$current_uid" != "$classroom_uid" && "$current_uid" != "0" ]]; then
fail "run this as root (sudo classroom-notify-test) or as the $CLASSROOM_USER user."
fi
environment_block="$(read_environ "$sway_pid")"
if [[ -n "$environment_block" ]]; then
source_note="read from sway (pid $sway_pid)"
else
source_note="sway's environment was unreadable; using session defaults"
fi
runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)"
runtime_dir="${runtime_dir:-/run/user/$classroom_uid}"
[[ -d "$runtime_dir" ]] ||
fail "the classroom session runtime directory $runtime_dir does not exist."
bus="$(environ_value "$environment_block" DBUS_SESSION_BUS_ADDRESS)"
bus="${bus:-unix:path=$runtime_dir/bus}"
case "$bus" in
unix:path=*)
bus_socket="${bus#unix:path=}"
bus_socket="${bus_socket%%,*}"
[[ -S "$bus_socket" ]] ||
fail "the classroom session bus socket $bus_socket does not exist. Is the desktop session fully up?"
;;
esac
wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)"
if [[ -z "$wayland_display" ]]; then
wayland_display="$(display_from_lock "$sway_pid")"
fi
environment=(
"XDG_RUNTIME_DIR=$runtime_dir"
"DBUS_SESSION_BUS_ADDRESS=$bus"
)
# notify-send only needs the bus. The display is passed through for
# the sake of anything else in the session, and its absence is not
# worth refusing to send over.
if [[ -n "$wayland_display" ]]; then
environment+=("WAYLAND_DISPLAY=$wayland_display")
fi
if [[ "$current_uid" == "$classroom_uid" ]]; then
runner=(env "${environment[@]}")
else
runner=(runuser -u "$CLASSROOM_USER" -- env "${environment[@]}")
fi
if [[ -x "$here/classroom-notify" ]]; then
exec "$here/classroom-notify" "$@"
fi
# Deliver to the mako that is already running. Sending into a session
# without one would have D-Bus start a second mako, which is exactly the
# failure this script exists to avoid.
pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 ||
fail "mako is not running in the classroom session, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a mako"
if [[ "$dry_run" == true ]]; then
echo "Would notify $session:"
echo " sway pid: $sway_pid"
echo " session environment: $source_note"
echo " XDG_RUNTIME_DIR: $runtime_dir"
echo " DBUS_SESSION_BUS_ADDRESS: $bus"
echo " WAYLAND_DISPLAY: ${wayland_display:-(not resolved)}"
echo " urgency: $urgency"
echo " title: $title"
echo " body: $body"
exit 0
fi
"${runner[@]}" notify-send \
--app-name="Ekklesia Classroom" \
--urgency="$urgency" \
-- \
"$title" "$body"
echo "Sent $urgency notification to $session: $title"
exec /usr/local/bin/classroom-notify "$@"