The multi-seat manager¶
pupitre-manager (module pupitre.managerd) is the daemon that turns a room of Dell
Wyse 1010 zero clients into numbered seats. Each client broadcasts a DCP packet on UDP
52330 every few seconds. The manager listens, reads the client MAC, looks it up in the
seat table, and makes sure pupitre-seat@<N>.service is running for that seat. It never
answers the client itself: the DCP assignment and keepalive are sent by the seat's own
session process, which serves exactly one MAC.
The manager, the seat sessions and the interactive tools all bind UDP 52330 with
SO_REUSEADDR, so they all receive every broadcast and coexist on the port.
Units¶
pupitre-nft.service(oneshot) installs the nftables tableip pupitrethat drops the kernel's own RSTs toward the device ports.pupitre-manager.servicewants it.pupitre-manager.serviceruns/usr/bin/pupitre-manageras root (it callssystemctl start), restarts on failure, and owns/run/pupitre,/var/lib/pupitreand/etc/pupitrethroughRuntimeDirectory,StateDirectoryandConfigurationDirectory.pupitre-seat@<N>.service(template, one instance per seat) reads/run/pupitre/seat-<N>.envand runs the seat launcher. It has noPartOf=: a crash of the manager thatRestart=recovers,systemctl restart pupitre-managerand a package upgrade leave every seat running.systemctl stop pupitre-managerstops every seat, and every client returns to its waiting screen: the manager'sExecStop=/usr/lib/pupitre/pupitre-stop-seatssends SIGTERM to the manager and waits for it to end, since a manager still alive would start the stopping seats again, then stopspupitre-seat@*.servicesynchronously. To pause the manager without stopping the seats, usekill -STOPon its main PID. The gaps that remain are listed in the install guide (known limits).
Files¶
| Path | Written by | Content |
|---|---|---|
/etc/pupitre/pupitre.conf |
you | INI, see config/pupitre.conf for the commented defaults |
/var/lib/pupitre/seats.json |
manager, web console, you | the seats table, see below |
/var/lib/pupitre/seats.json.lock |
both daemons | flock target guarding every read-modify-write of the table |
/run/pupitre/seat-<N>.env |
manager | PUPITRE_SEAT, PUPITRE_MAC, PUPITRE_CLIENT_IP, PUPITRE_SERVER_IP, PUPITRE_IFACE |
/run/pupitre/stations.json |
manager | live state of every station seen since start |
/run/pupitre/manager.alive |
manager | empty file touched once per loop iteration (heartbeat) |
/run/pupitre/hold/<mac> |
web console | "leave this MAC alone for a while", see below |
All writes are atomic (temporary file then rename), so a reader never sees a partial file.
stations.json is rewritten whenever a station appears, changes seat, IP or name, or goes
online or offline, and after every table reload; a station is offline after 30 s without a
broadcast. Nothing else happens on offline: the seat unit keeps running and the session
process handles the device's absence.
stations.json carries server_ip, interface, enroll, seats_max (0 for no cap),
updated, and one row per station: mac, ip, seat, name, serial, last_seen,
online, label (from the table, "" if absent) and parked (true when the table lists the
MAC with no seat).
The seats table¶
{
"00:00:5e:00:53:01": {"seat": 3, "label": "Row 2, window side", "transport": "mct"},
"00:00:5e:00:53:09": {"seat": null, "label": "Poste 9, fond de salle", "transport": "mct"}
}
Keys are lower-case MACs with colons. seat is a positive integer or null: a null seat
is a parked station, known and deliberately seatless, never auto enrolled. label is free
text (64 characters at most) shown by the web console and never sent to the device. Seat
numbers must be unique. The old form {"mac": 3} is still read and is rewritten in the new
form at the first save. A key that is not a MAC, or a value that is not an entry, is dropped
with a warning.
transport is how that seat is served. mct is the only one that does anything today, and
a missing or unreadable value becomes mct, so a table written by an older version loads as
what it was: a table of MCT seats. The field is recorded and nothing dispatches on it yet; it
exists so that the day a seat is served over a standard protocol, the table already says which
seat that is. See docs/sieges.md. Its shape is checked (a short lower case name, at most 16
characters of a-z0-9-) but not checked against a list of known transports, which would have
to grow before the code that reads it exists.
The file is read again within a second of any change (the manager compares inode and mtime
once per packet and once per idle second): you can edit it while the manager runs. On reload,
a seat whose MAC vanished or changed is stopped (systemctl stop --no-block) and its env file
removed; an online station that gained a seat is started at once. Every read-modify-write by
the manager (enrolment) or the console runs under seats.json.lock, so neither loses the
other's edit.
What happens on an announce¶
- The packet is parsed; anything that is not a 1344-byte DCP broadcast is ignored.
- The MAC is looked up in the seat table. Unknown MAC: enrolment (below).
seat-<N>.envis written if its content changed (first announce, new client IP).- If the seat's next start is due,
systemctl is-active --quiet pupitre-seat@<N>.serviceis checked and the unit is started when inactive. A unit infailedhas hit its start limit (5 starts in 300 s):systemctl reset-failedruns first.
The interval between two starts of one seat is 30 s while things go well, and doubles each
time the unit is found failed, up to a quarter of an hour; a start that finds the seat
running puts it back to 30 s. This is not politeness: a wedged device answers
nothing for about half an hour AND is kept in that state by every retry, so restarting a failing
seat every 30 s is what maintains the fault. failed and not merely inactive is what grows the
interval: a client that was switched off leaves an inactive unit and comes back at once. The
seat launcher doubles its own cooldown between attempts the same way (25, 50, 100, 200 s at the
default five attempts, PUPITRE_RETRY_MAX=300 capping it only beyond that), and the
unit's RestartSec grows geometrically from 10 s to 900 s.
The three do not add up, and the manager's is the one that counts. A unit inside systemd's own
restart holdoff is activating (auto-restart), which is neither active nor failed, so the
manager starts it and that start pre-empts the holdoff. So RestartSteps only governs a seat
the manager is not watching, and StartLimitBurst is out of reach for anything but a fast
failure. When a seat is stopped or reassigned, _stop_seat drops its interval with its env:
the backoff belongs to the device, not to the seat number.
A MAC with a hold file (/run/pupitre/hold/<mac>, written by the web console during a rename
dialogue) skips steps 2 to 4: its station row is still updated. The file is JSON with
reason, until (epoch seconds) and pid; a hold whose until is past, whose pid is gone
or that does not parse is stale and is removed with a warning.
This is also the recovery path: a client that is power cycled announces again, and if its seat unit had died in the meantime it is started again. A unit that is already active is left alone.
Enrolment¶
With enroll = yes (the default), the first announce of an unknown MAC gives it the
lowest free seat number between 1 and seats (15 by default), and the choice is saved in seats.json
immediately. When every seat is taken the station is logged and left unassigned. With
seats = 0 there is no cap: the lowest free number is taken, whatever it is. A parked
station (in the table with "seat": null) is never enrolled.
With enroll = no, an unknown MAC is logged once per hour at INFO and ignored. Use this
once the room is set up, so that a device plugged in by mistake does not take a seat.
Pinning a MAC to a seat¶
The web console does it in one click; by hand, edit /var/lib/pupitre/seats.json with the
manager running: the change is picked up within a second. To swap two clients, swap their
numbers: both units stop and restart with the right clients at their next broadcasts. To park
a client, set its seat to null; to un-enrol it, delete its line: at its next announce it is
either enrolled again (enroll = yes, it gets the lowest free number, possibly the same one)
or ignored (enroll = no). The manager stops the old seat unit itself.
Running by hand¶
pupitre-manager --config /etc/pupitre/pupitre.conf --verbose
pupitre-manager --dry-run # logs what it would do, writes nothing, never calls systemctl
pupitre-manager --seats-file /tmp/seats.json
Logs go to stderr, so journalctl -u pupitre-manager shows them under systemd.