Files
ekklesiaandClaude Opus 5 dfd42f6fdf 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]>
2026-09-06 14:41:49 -05:00

139 lines
4.8 KiB
Markdown

# 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.