#!/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())