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]>
The readiness condition was playback state alone, and on a real disc
VLC reports playing well before its window has taken the screen. The
cover came off onto VLC's ordinary tiled startup window — black, sized
to everything except the dock's reserved strip — and the disc menu only
arrived afterwards. Exactly the state the cover exists to hide.
Ask sway instead. VLC only takes the display when its window goes
fullscreen, and the tree says when that happens. The cover now comes off
when VLC reports playing or paused, its window is fullscreen, and 800ms
have passed for the menu to draw its first frames. The playback check
runs first because it is local and cheap; the tree is only walked once
VLC claims to be playing something.
The twenty second deadline is unchanged: a cover with nothing behind it
is still the one outcome DVD mode must never produce.
Co-Authored-By: Claude Opus 5 <[email protected]>
classroom-dvd-session needs to ask sway whether VLC has taken the
screen, which is the same tree walking classroom-window already does.
One copy, in a module both import; classroom-window loses a third of its
length.
The state machine is worth protecting through a move like that, so its
regression test comes with it: the exact swaymsg commands for launch,
focus, minimise, restore, Super+M, Super+Shift+M, the scratchpad rescue
and sway not running at all.
Co-Authored-By: Claude Opus 5 <[email protected]>
Pressing DVD now enters DVD mode rather than opening VLC. VLC is still
the backend, because its dvdnav module is what decodes disc menus and
nothing else on Debian does that reliably, but nothing a person can see
or press belongs to it any more.
classroom-dvd-session owns the whole thing. It puts a full-screen black
layer-shell surface up the moment the button is pressed, starts VLC
behind it, and drops it once playback is actually on screen — so the
harsh cut from wallpaper to VLC's black window is gone, and no partial
VLC window or reserved dock strip is ever visible. A deadline drops the
surface regardless, because DVD mode must never leave a black screen
with nothing behind it.
The controls are a classroom bar on the overlay layer, the one layer
sway draws above a fullscreen window. It slides up when the pointer
reaches the bottom of the screen and hides again shortly after it
leaves. It is drawn from the dock's own stylesheet at the room's UI
scale, so it is the same surface, the same radius and the same white
icons. While hidden it only accepts the pointer in a thin strip along
the bottom edge, so a click on a disc menu goes to the disc.
The bar reaches VLC through an HTTP interface on loopback behind a
password generated per session, sending hotkey *actions* by name. That
is what makes the next part possible.
All 108 hotkeys VLC 3.0.23 defines are unset, then Space, Esc and the
arrow keys plus Enter are bound. Crop, aspect ratio, subtitles, audio
tracks and deinterlacing are no longer reachable by pressing a key at
random, and the arrow keys no longer change VLC's volume — the
television owns volume, and CEC will own it properly later.
Esc quits instead of leaving fullscreen. There is nothing to leave, and
leaving it was what stranded people on a black VLC window. Close, Eject
and Esc all ask VLC to quit and escalate to SIGTERM then SIGKILL if it
does not; when VLC exits by any route, including a crash, the session
ends and the classroom desktop comes back.
Verified against VLC 3.0.23 on Trixie: every argument accepted, every
control action accepted over the control interface, a wrong password
refused, and quit exiting cleanly. The surfaces were rendered under a
headless sway and screenshotted to check the bar against the dock.
Co-Authored-By: Claude Opus 5 <[email protected]>
A fresh classroom needed two things installed by hand on every box:
Google Chrome, which is not in Debian, and the DVD codecs, which Debian
cannot ship. Both now happen on their own.
ekklesia-classroom-provision.service runs after every boot and does the
work that needs the network and the apt lock, neither of which a package
postinst can have. Each step returns immediately if it is already done,
so a provisioned machine costs nothing and a machine that was offline
the first time gets another go at the next boot. Nothing there is
allowed to fail the boot: a classroom with no DVD codecs is still a
working classroom, and it says so in the log rather than silently.
The postinst enables the service but deliberately does not start it.
Every OTA update reboots the moment the install finishes, and an apt run
killed by that reboot would leave dpkg half-configured.
Chrome comes from Google's own repository rather than a one-off .deb, so
it keeps getting security updates. Google's package migrates whatever we
write into a deb822 file of its own, so the repository is looked for
across every source file instead of only the one we would write;
otherwise a machine that had Chrome removed would end up with two copies
of the same repository.
The DVD codec helper previously assumed deb822 sources and did nothing
on a machine using a classic /etc/apt/sources.list, which is what the
hardware actually runs. It now handles both formats, and only ever adds
a missing component to an existing Debian entry — so it cannot produce a
duplicate repository or a duplicate component however many times it
runs, and third-party repositories like Chrome's are never touched. It
also answers libdvd-pkg's debconf questions, so nothing blocks on a
prompt in a service with no terminal.
Tested against fixture APT trees for classic sources.list, deb822
.sources, contrib already enabled, repeated runs, third-party
repositories and the awkward real lines: bracketed options, indentation,
trailing comments and flat repositories.
Co-Authored-By: Claude Opus 5 <[email protected]>
The classroom desktop belongs to the classroom user and mako sits on
that user's D-Bus session. An administrator over SSH is in a different
session where nothing owns org.freedesktop.Notifications, so notify-send
asked D-Bus to start a second mako, which had no display to open and
timed out with NoReply.
Find the running sway process for the classroom user and deliver into
its session instead. XDG_RUNTIME_DIR and the bus address are read from
that process, falling back to /run/user/<uid> and its bus socket when
the environment cannot be read — root has ptrace access on the endpoint,
but that is not somewhere to be brittle. The Wayland display is read the
same way, and failing that from the wayland-N.lock sway holds open, so
no socket name is ever assumed. notify-send only needs the bus, so a
display that cannot be resolved is not worth refusing to send over.
Refuses to send when mako is not already running, rather than letting
D-Bus start one, and says plainly what is wrong when there is no
session, no privileges, or no bus socket. --dry-run reports what it
resolved without sending.
Behaviour inside the session is unchanged, and the sample notifications
and styling are untouched.
The tests stand a real background process in for sway so /proc/<pid>/environ
is genuine rather than mocked, and cover both invocation paths plus the
fallbacks and every refusal. The build runs them.
Co-Authored-By: Claude Opus 5 <[email protected]>