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
+208
@@ -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())
|
||||
Reference in New Issue
Block a user