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
@@ -0,0 +1,138 @@
|
|||||||
|
# Classroom messaging
|
||||||
|
|
||||||
|
The rooms talk to the church over MQTT. `classroom-mqtt` is the link:
|
||||||
|
one small service per room that subscribes, validates what arrives, and
|
||||||
|
hands the work to the classroom 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 of
|
||||||
|
those is a service sitting quietly, not a classroom that fails. The
|
||||||
|
dock, the browser and the DVD player never ask whether it is connected,
|
||||||
|
and nothing waits for it at boot.
|
||||||
|
|
||||||
|
## The room is the machine
|
||||||
|
|
||||||
|
The room name is the hostname: `nursery`, `k2`, `older`. Lowercased,
|
||||||
|
domain stripped, and anything that is not a letter, digit, `-` or `_`
|
||||||
|
replaced, because it becomes a topic segment. `ROOM=` in device.conf
|
||||||
|
overrides it for a machine whose hostname cannot be changed.
|
||||||
|
|
||||||
|
## Topics
|
||||||
|
|
||||||
|
No product name appears in any topic. The broker is the church's and
|
||||||
|
these are just rooms.
|
||||||
|
|
||||||
|
| Topic | Retained | Direction | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `classroom/notify/all` | no | to the rooms | Show this in every room |
|
||||||
|
| `classroom/notify/<room>` | no | to the rooms | Show this in one room |
|
||||||
|
| `classroom/status/<room>` | **yes** | from the room | Whether that room is up |
|
||||||
|
|
||||||
|
Notifications are events, so they are never retained. Status is state,
|
||||||
|
so it is — published on connect, and set to `online: false` by the
|
||||||
|
broker's last will if the room drops off.
|
||||||
|
|
||||||
|
### Notification payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "unique-id",
|
||||||
|
"title": "Sermon Starting",
|
||||||
|
"message": "The sermon has started in the sanctuary.",
|
||||||
|
"urgency": "normal",
|
||||||
|
"expires_at": 1780000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `title` is required. `urgency` is `low`, `normal` or `critical` and
|
||||||
|
anything else becomes `normal`. `expires_at` is epoch seconds. `id` is
|
||||||
|
what deduplication works on; without one, a message is shown every time
|
||||||
|
it arrives.
|
||||||
|
|
||||||
|
### Status payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"room": "nursery", "online": true, "version": "0.1.36", "since": 1780000000}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What gets dropped
|
||||||
|
|
||||||
|
Every one of these is ordinary traffic, logged and counted, never a
|
||||||
|
reason to fall over:
|
||||||
|
|
||||||
|
- retained notifications (see below)
|
||||||
|
- payloads that are not JSON, or not a JSON object
|
||||||
|
- a missing, blank or non-text title
|
||||||
|
- a message or id that is not text
|
||||||
|
- an `expires_at` that is not a time, or is in the past
|
||||||
|
- an id already seen — the last 512 are remembered
|
||||||
|
|
||||||
|
## Stale messages cannot come back
|
||||||
|
|
||||||
|
This is the property that matters most, and it has two halves.
|
||||||
|
|
||||||
|
**The subscription is clean.** The client connects with a clean session,
|
||||||
|
so a broker never hands over a backlog from while the room was off.
|
||||||
|
Yesterday's message is not news.
|
||||||
|
|
||||||
|
**Retained notifications are refused.** If someone publishes a
|
||||||
|
notification with the retain flag, the broker will replay it to every
|
||||||
|
room that subscribes afterwards — at every reboot, forever. Those are
|
||||||
|
dropped on arrival.
|
||||||
|
|
||||||
|
One honest limitation: MQTT only sets the retain flag on a message
|
||||||
|
*replayed to a new subscription*. A retained publish delivered live to
|
||||||
|
an already-connected room is indistinguishable from an ordinary one, and
|
||||||
|
is shown once. That is the correct outcome — it is a live message at
|
||||||
|
that moment — and the replay, which is the actual hazard, is blocked.
|
||||||
|
Publishers should still not retain notifications.
|
||||||
|
|
||||||
|
## When the broker goes away
|
||||||
|
|
||||||
|
The service stays up and paho retries with a backoff from 2 to 60
|
||||||
|
seconds. The disconnection is logged once, not once per attempt, so a
|
||||||
|
broker down for a morning does not fill the journal. On reconnect the
|
||||||
|
subscriptions are re-established and the retained status is republished.
|
||||||
|
|
||||||
|
Verified against a real broker: the daemon survives the broker being
|
||||||
|
killed, reconnects when it returns, and delivers the next message
|
||||||
|
normally.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
In `/etc/ekklesia-classroom/device.conf`:
|
||||||
|
|
||||||
|
```
|
||||||
|
MQTT_ENABLED=false
|
||||||
|
MQTT_HOST=
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USERNAME=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
With `MQTT_ENABLED=false`, or no host, the service logs one line and
|
||||||
|
exits. That is the shipped default: every existing classroom upgrades
|
||||||
|
into this release with messaging off until a broker exists.
|
||||||
|
|
||||||
|
Because the broker password lives here, device.conf is now `0640
|
||||||
|
root:classroom` rather than world readable. Everything that reads it —
|
||||||
|
the dock, the notification daemon, the DVD commands, the updater — runs
|
||||||
|
as root or as the classroom user.
|
||||||
|
|
||||||
|
## Trying it
|
||||||
|
|
||||||
|
```
|
||||||
|
mosquitto_pub -h <broker> -t classroom/notify/nursery \
|
||||||
|
-m '{"id":"t1","title":"Sermon Starting","message":"The sermon has started."}'
|
||||||
|
|
||||||
|
mosquitto_sub -h <broker> -t 'classroom/status/#' -v
|
||||||
|
journalctl -u ekklesia-classroom-mqtt -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this is for
|
||||||
|
|
||||||
|
Parent Call and Sanctuary Live View are next, and both need a room to be
|
||||||
|
reachable. The transport is deliberately separate from what it carries:
|
||||||
|
`classroom_mqtt` holds room identity, the topic contract and the rules
|
||||||
|
for an acceptable message, with no broker library in it at all, so those
|
||||||
|
decisions can be tested without one running.
|
||||||
@@ -129,6 +129,21 @@ session instead. It never starts mako.
|
|||||||
- [ ] Running it as an unprivileged admin account explains it needs sudo
|
- [ ] Running it as an unprivileged admin account explains it needs sudo
|
||||||
- [ ] `tests/test-classroom-notify-test.sh` passes (also run by the build)
|
- [ ] `tests/test-classroom-notify-test.sh` passes (also run by the build)
|
||||||
|
|
||||||
|
## Messaging
|
||||||
|
|
||||||
|
- [ ] With `MQTT_ENABLED=false`, `journalctl -u ekklesia-classroom-mqtt`
|
||||||
|
shows one line and the service is inactive — and the room is
|
||||||
|
completely normal
|
||||||
|
- [ ] With a broker configured, the service connects and
|
||||||
|
`mosquitto_sub -t 'classroom/status/#' -v` shows the room online
|
||||||
|
- [ ] A published notification appears on the classroom screen in the
|
||||||
|
classroom styling
|
||||||
|
- [ ] `urgency: critical` renders in the critical style
|
||||||
|
- [ ] Killing the broker leaves the room entirely usable
|
||||||
|
- [ ] The service reconnects on its own when the broker returns
|
||||||
|
- [ ] Rebooting with the broker **down** boots normally to the dock
|
||||||
|
- [ ] A retained notification left on the broker does not appear at boot
|
||||||
|
|
||||||
## DVD
|
## DVD
|
||||||
|
|
||||||
With `DVD_ENABLED=false`:
|
With `DVD_ENABLED=false`:
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""The classroom's side of the church message broker.
|
||||||
|
|
||||||
|
Room identity, the topic contract, and the rules for what counts as an
|
||||||
|
acceptable message. Deliberately free of any broker library, so the
|
||||||
|
decisions can be tested without one running.
|
||||||
|
|
||||||
|
Topics carry no product name. The broker belongs to the church and
|
||||||
|
these are just rooms:
|
||||||
|
|
||||||
|
classroom/notify/all every room
|
||||||
|
classroom/notify/<room> one room
|
||||||
|
classroom/status/<room> retained; whether that room is up
|
||||||
|
|
||||||
|
Notifications are events, never retained. Status is state, and is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
NOTIFY_ALL = "classroom/notify/all"
|
||||||
|
NOTIFY_ROOM = "classroom/notify/{room}"
|
||||||
|
STATUS_ROOM = "classroom/status/{room}"
|
||||||
|
|
||||||
|
URGENCIES = ("low", "normal", "critical")
|
||||||
|
DEFAULT_URGENCY = "normal"
|
||||||
|
|
||||||
|
DEFAULT_PORT = 1883
|
||||||
|
|
||||||
|
# Room names become topic segments, so they have to be plain.
|
||||||
|
UNSAFE = re.compile(r"[^a-z0-9_-]+")
|
||||||
|
|
||||||
|
|
||||||
|
def room_name(config=None, hostname=None):
|
||||||
|
"""Which room this machine is.
|
||||||
|
|
||||||
|
The hostname, because that is already how these machines are named —
|
||||||
|
nursery, k2, older. ROOM in device.conf overrides it for the odd
|
||||||
|
machine whose hostname cannot be changed.
|
||||||
|
"""
|
||||||
|
config = config or {}
|
||||||
|
|
||||||
|
room = (config.get("ROOM") or "").strip()
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
room = socket.gethostname() if hostname is None else hostname
|
||||||
|
|
||||||
|
room = (room or "").split(".")[0].strip().lower()
|
||||||
|
room = UNSAFE.sub("-", room).strip("-")
|
||||||
|
|
||||||
|
return room or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def notify_topics(room):
|
||||||
|
"""Everything a room listens to for notifications."""
|
||||||
|
return (NOTIFY_ALL, NOTIFY_ROOM.format(room=room))
|
||||||
|
|
||||||
|
|
||||||
|
def status_topic(room):
|
||||||
|
return STATUS_ROOM.format(room=room)
|
||||||
|
|
||||||
|
|
||||||
|
def broker(config):
|
||||||
|
"""Broker settings, or None when this classroom has no broker yet."""
|
||||||
|
config = config or {}
|
||||||
|
|
||||||
|
if (config.get("MQTT_ENABLED") or "").strip().lower() not in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
"enabled",
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
host = (config.get("MQTT_HOST") or "").strip()
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
port = int((config.get("MQTT_PORT") or DEFAULT_PORT))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
port = DEFAULT_PORT
|
||||||
|
|
||||||
|
return {
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"username": (config.get("MQTT_USERNAME") or "").strip() or None,
|
||||||
|
"password": config.get("MQTT_PASSWORD") or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Notifications:
|
||||||
|
"""Decides which incoming payloads become notifications.
|
||||||
|
|
||||||
|
Every rejection is ordinary traffic rather than an error: a
|
||||||
|
malformed, stale or repeated message is something to drop and count,
|
||||||
|
never something to fall over on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, remember=512):
|
||||||
|
self.remember = remember
|
||||||
|
self.seen = collections.OrderedDict()
|
||||||
|
|
||||||
|
def accept(self, payload, retained=False, now=None):
|
||||||
|
"""Returns (notification, reason). notification is None if dropped."""
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
|
||||||
|
# A retained notification is a stale one by definition: it would
|
||||||
|
# arrive again at every reconnect and every reboot.
|
||||||
|
if retained:
|
||||||
|
return None, "retained"
|
||||||
|
|
||||||
|
if isinstance(payload, (bytes, bytearray)):
|
||||||
|
try:
|
||||||
|
payload = payload.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None, "not utf-8"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(payload)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "not json"
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None, "not an object"
|
||||||
|
|
||||||
|
title = data.get("title")
|
||||||
|
|
||||||
|
if not isinstance(title, str) or not title.strip():
|
||||||
|
return None, "no title"
|
||||||
|
|
||||||
|
message = data.get("message", "")
|
||||||
|
|
||||||
|
if not isinstance(message, str):
|
||||||
|
return None, "message is not text"
|
||||||
|
|
||||||
|
expires = data.get("expires_at")
|
||||||
|
|
||||||
|
if expires is not None:
|
||||||
|
if isinstance(expires, bool) or not isinstance(expires, (int, float)):
|
||||||
|
return None, "expires_at is not a time"
|
||||||
|
|
||||||
|
if expires <= now:
|
||||||
|
return None, "expired"
|
||||||
|
|
||||||
|
identifier = data.get("id")
|
||||||
|
|
||||||
|
if identifier is not None:
|
||||||
|
if not isinstance(identifier, str):
|
||||||
|
return None, "id is not text"
|
||||||
|
|
||||||
|
if identifier in self.seen:
|
||||||
|
return None, "duplicate"
|
||||||
|
|
||||||
|
self.seen[identifier] = now
|
||||||
|
|
||||||
|
while len(self.seen) > self.remember:
|
||||||
|
self.seen.popitem(last=False)
|
||||||
|
|
||||||
|
urgency = data.get("urgency", DEFAULT_URGENCY)
|
||||||
|
|
||||||
|
if urgency not in URGENCIES:
|
||||||
|
urgency = DEFAULT_URGENCY
|
||||||
|
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"id": identifier,
|
||||||
|
"title": title.strip(),
|
||||||
|
"message": message.strip(),
|
||||||
|
"urgency": urgency,
|
||||||
|
},
|
||||||
|
"ok",
|
||||||
|
)
|
||||||
@@ -4,7 +4,7 @@ Section: misc
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: amd64
|
Architecture: amd64
|
||||||
Maintainer: Ekklesia
|
Maintainer: Ekklesia
|
||||||
Depends: sway, swaybg, waybar, mako-notifier, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, cups, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, adwaita-icon-theme, libnotify-bin, curl, gnupg, ca-certificates
|
Depends: sway, swaybg, waybar, mako-notifier, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, cups, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, adwaita-icon-theme, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt
|
||||||
Recommends: vlc
|
Recommends: vlc
|
||||||
Description: Ekklesia managed classroom environment
|
Description: Ekklesia managed classroom environment
|
||||||
A minimal Wayland classroom environment for Ekklesia-managed church computers.
|
A minimal Wayland classroom environment for Ekklesia-managed church computers.
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ chmod 0755 /usr/local/bin/classroom-window
|
|||||||
chmod 0755 /usr/local/bin/classroom-dvd
|
chmod 0755 /usr/local/bin/classroom-dvd
|
||||||
chmod 0755 /usr/local/bin/classroom-dvd-session
|
chmod 0755 /usr/local/bin/classroom-dvd-session
|
||||||
chmod 0755 /usr/local/bin/classroom-dvd-css-setup
|
chmod 0755 /usr/local/bin/classroom-dvd-css-setup
|
||||||
|
chmod 0755 /usr/local/bin/classroom-notify
|
||||||
chmod 0755 /usr/local/bin/classroom-notify-test
|
chmod 0755 /usr/local/bin/classroom-notify-test
|
||||||
|
chmod 0755 /usr/local/bin/classroom-mqtt
|
||||||
chmod 0755 /usr/local/bin/classroom-notifications
|
chmod 0755 /usr/local/bin/classroom-notifications
|
||||||
chmod 0755 /usr/local/bin/classroom-scale
|
chmod 0755 /usr/local/bin/classroom-scale
|
||||||
chmod 0755 /usr/local/bin/classroom-shutdown
|
chmod 0755 /usr/local/bin/classroom-shutdown
|
||||||
@@ -48,9 +50,15 @@ DVD_ENABLED=false
|
|||||||
# Size of the dock, clock and notifications, 0.8 to 1.5.
|
# Size of the dock, clock and notifications, 0.8 to 1.5.
|
||||||
# Larger rooms and larger TVs want a larger number.
|
# Larger rooms and larger TVs want a larger number.
|
||||||
UI_SCALE=1.0
|
UI_SCALE=1.0
|
||||||
CONFIG
|
|
||||||
|
|
||||||
chmod 0644 /etc/ekklesia-classroom/device.conf
|
# Church message broker. The room is this machine's hostname unless ROOM
|
||||||
|
# is set. Leave MQTT_ENABLED=false until a broker exists.
|
||||||
|
MQTT_ENABLED=false
|
||||||
|
MQTT_HOST=
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USERNAME=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
CONFIG
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Make the settings discoverable on classrooms installed before they
|
# Make the settings discoverable on classrooms installed before they
|
||||||
@@ -73,6 +81,24 @@ UI_SCALE=1.0
|
|||||||
CONFIG
|
CONFIG
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '^MQTT_ENABLED=' /etc/ekklesia-classroom/device.conf; then
|
||||||
|
cat >> /etc/ekklesia-classroom/device.conf <<'CONFIG'
|
||||||
|
|
||||||
|
# Church message broker. The room is this machine's hostname unless ROOM
|
||||||
|
# is set. Leave MQTT_ENABLED=false until a broker exists.
|
||||||
|
MQTT_ENABLED=false
|
||||||
|
MQTT_HOST=
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USERNAME=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
CONFIG
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The broker password lives here, so the file stops being world readable.
|
||||||
|
# Everything that reads it runs as root or as the classroom user.
|
||||||
|
chown root:classroom /etc/ekklesia-classroom/device.conf 2>/dev/null || true
|
||||||
|
chmod 0640 /etc/ekklesia-classroom/device.conf
|
||||||
|
|
||||||
systemctl daemon-reload || true
|
systemctl daemon-reload || true
|
||||||
|
|
||||||
systemctl enable ekklesia-classroom-update.timer || true
|
systemctl enable ekklesia-classroom-update.timer || true
|
||||||
@@ -94,6 +120,11 @@ systemctl enable ekklesia-classroom-provision.service || true
|
|||||||
# Gives Chrome a chance to close properly on reboot, so its profile is
|
# Gives Chrome a chance to close properly on reboot, so its profile is
|
||||||
# not marked as crashed. Best effort: it must never hold a shutdown up.
|
# not marked as crashed. Best effort: it must never hold a shutdown up.
|
||||||
systemctl enable ekklesia-classroom-shutdown.service || true
|
systemctl enable ekklesia-classroom-shutdown.service || true
|
||||||
|
|
||||||
|
# The room's link to the church broker. Enabled always; it does nothing
|
||||||
|
# at all until a broker is configured in device.conf.
|
||||||
|
systemctl enable ekklesia-classroom-mqtt.service || true
|
||||||
|
systemctl restart ekklesia-classroom-mqtt.service || true
|
||||||
systemctl start ekklesia-classroom-shutdown.service || true
|
systemctl start ekklesia-classroom-shutdown.service || true
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
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())
|
||||||
Executable
+214
@@ -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"
|
||||||
@@ -7,198 +7,14 @@ set -euo pipefail
|
|||||||
# classroom-notify-test
|
# classroom-notify-test
|
||||||
# classroom-notify-test "Title" "Body text"
|
# classroom-notify-test "Title" "Body text"
|
||||||
# classroom-notify-test --critical "Parent Call" "Room 3 needs a parent."
|
# 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
|
# All of the work — finding the classroom desktop session and delivering
|
||||||
# to that user's D-Bus session. An administrator over SSH sits in a
|
# into it — is classroom-notify's. This only supplies the sample.
|
||||||
# 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
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
urgency=normal
|
if [[ -x "$here/classroom-notify" ]]; then
|
||||||
dry_run=false
|
exec "$here/classroom-notify" "$@"
|
||||||
|
|
||||||
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
|
fi
|
||||||
|
|
||||||
environment_block="$(read_environ "$sway_pid")"
|
exec /usr/local/bin/classroom-notify "$@"
|
||||||
|
|
||||||
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"
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Ekklesia Classroom messaging
|
||||||
|
Documentation=file:///usr/local/bin/classroom-mqtt
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
# Nothing in the classroom session requires this, and nothing waits for
|
||||||
|
# it. A broker outage must never reach the dock, the browser or boot.
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# The room's own user, so notifications reach the room's own session and
|
||||||
|
# the broker password is read by nothing with more privilege than that.
|
||||||
|
User=classroom
|
||||||
|
Group=classroom
|
||||||
|
ExecStart=/usr/local/bin/classroom-mqtt
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
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
|
# tests/test-classroom-notify-test.sh
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
SCRIPT="$ROOT/scripts/classroom-notify-test"
|
SCRIPT="$ROOT/scripts/classroom-notify"
|
||||||
|
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
trap 'rm -rf "$WORK"; kill "${FAKE_SWAY_PID:-}" 2>/dev/null' EXIT
|
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
|
FAKE_CURRENT_UID=9999
|
||||||
out="$(run)"
|
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")"
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
||||||
FAKE_CURRENT_UID=4000
|
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
|
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
|
# ----------------------------------------------------------- summary
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -113,10 +113,18 @@ install -Dm755 \
|
|||||||
"$ROOT/scripts/classroom-dvd-css-setup" \
|
"$ROOT/scripts/classroom-dvd-css-setup" \
|
||||||
"$STAGE/usr/local/bin/classroom-dvd-css-setup"
|
"$STAGE/usr/local/bin/classroom-dvd-css-setup"
|
||||||
|
|
||||||
|
install -Dm755 \
|
||||||
|
"$ROOT/scripts/classroom-notify" \
|
||||||
|
"$STAGE/usr/local/bin/classroom-notify"
|
||||||
|
|
||||||
install -Dm755 \
|
install -Dm755 \
|
||||||
"$ROOT/scripts/classroom-notify-test" \
|
"$ROOT/scripts/classroom-notify-test" \
|
||||||
"$STAGE/usr/local/bin/classroom-notify-test"
|
"$STAGE/usr/local/bin/classroom-notify-test"
|
||||||
|
|
||||||
|
install -Dm755 \
|
||||||
|
"$ROOT/scripts/classroom-mqtt" \
|
||||||
|
"$STAGE/usr/local/bin/classroom-mqtt"
|
||||||
|
|
||||||
install -Dm755 \
|
install -Dm755 \
|
||||||
"$ROOT/scripts/classroom-notifications" \
|
"$ROOT/scripts/classroom-notifications" \
|
||||||
"$STAGE/usr/local/bin/classroom-notifications"
|
"$STAGE/usr/local/bin/classroom-notifications"
|
||||||
@@ -146,6 +154,10 @@ install -Dm644 \
|
|||||||
"$ROOT/lib/classroom_icons.py" \
|
"$ROOT/lib/classroom_icons.py" \
|
||||||
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_icons.py"
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_icons.py"
|
||||||
|
|
||||||
|
install -Dm644 \
|
||||||
|
"$ROOT/lib/classroom_mqtt.py" \
|
||||||
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_mqtt.py"
|
||||||
|
|
||||||
install -Dm644 \
|
install -Dm644 \
|
||||||
"$ROOT/lib/classroom_style.py" \
|
"$ROOT/lib/classroom_style.py" \
|
||||||
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py"
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py"
|
||||||
@@ -215,6 +227,10 @@ install -Dm644 \
|
|||||||
"$ROOT/systemd/shutdown/ekklesia-classroom-shutdown.service" \
|
"$ROOT/systemd/shutdown/ekklesia-classroom-shutdown.service" \
|
||||||
"$STAGE/etc/systemd/system/ekklesia-classroom-shutdown.service"
|
"$STAGE/etc/systemd/system/ekklesia-classroom-shutdown.service"
|
||||||
|
|
||||||
|
install -Dm644 \
|
||||||
|
"$ROOT/systemd/mqtt/ekklesia-classroom-mqtt.service" \
|
||||||
|
"$STAGE/etc/systemd/system/ekklesia-classroom-mqtt.service"
|
||||||
|
|
||||||
install -Dm644 \
|
install -Dm644 \
|
||||||
"$ROOT/config/icons/dvd.svg" \
|
"$ROOT/config/icons/dvd.svg" \
|
||||||
"$STAGE/etc/ekklesia-classroom/icons/dvd.svg"
|
"$STAGE/etc/ekklesia-classroom/icons/dvd.svg"
|
||||||
@@ -241,6 +257,10 @@ done
|
|||||||
|
|
||||||
printf '%s\n' "$VERSION" > "$ROOT/VERSION"
|
printf '%s\n' "$VERSION" > "$ROOT/VERSION"
|
||||||
|
|
||||||
|
install -Dm644 \
|
||||||
|
"$ROOT/VERSION" \
|
||||||
|
"$STAGE/usr/local/lib/ekklesia-classroom/VERSION"
|
||||||
|
|
||||||
dpkg-deb \
|
dpkg-deb \
|
||||||
--root-owner-group \
|
--root-owner-group \
|
||||||
--build "$STAGE" "$OUTPUT"
|
--build "$STAGE" "$OUTPUT"
|
||||||
|
|||||||
Reference in New Issue
Block a user