diff --git a/config/plasma/autostart/ekklesia-classroom-display.desktop b/config/plasma/autostart/ekklesia-classroom-display.desktop new file mode 100644 index 0000000..66089d2 --- /dev/null +++ b/config/plasma/autostart/ekklesia-classroom-display.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=Classroom displays +# Named to sort before ekklesia-classroom-scale.desktop, which is how XDG +# autostart entries are ordered in practice. It matters only a little: +# this decides WHICH displays are on and the scale applies to whatever is +# on, so running them the other way round costs one login before a newly +# enabled display is scaled, not a broken room. +Exec=/usr/local/bin/classroom-display-mode +OnlyShowIn=KDE; +NoDisplay=true diff --git a/docs/testing.md b/docs/testing.md index 050b6ae..274089b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -230,6 +230,82 @@ If step 7 fails, the jack change is not reaching PipeWire at all; capture `pactl subscribe` and `classroom-audio show` with the set off and then on, which is what distinguishes that from a policy fault. +## Device features + +Every optional application is its own setting in device.conf, and +`DEVICE_TYPE` decides none of them. A laptop can take children's church +and a tower can be a Bible study. + +``` +DEVICE_TYPE=desktop|laptop what the hardware IS +WONDERINK_ENABLED=true|false +DVD_ENABLED=true|false a drive is still required as well +PARENT_CALL_ENABLED=true|false +NETWORK_PREFERENCE=wired|wifi +DISPLAY_MODE=all|external-only +``` + +All default to the classroom's existing behaviour, so an upgrade changes +nothing until somebody edits the file. `classroom-desktop-layout` writes +the answers into the packaged layout; the bar is rebuilt from it at the +next login. + +- [ ] `tests/test-classroom-desktop-layout.sh` passes (also run by the build) +- [ ] After changing a setting, `classroom-desktop-layout` then a + logout/login shows the new bar +- [ ] A disabled application leaves no gap: the remaining launchers close up +- [ ] The launcher order is the same on two machines with the same + features enabled +- [ ] Dragging a launcher does not move it +- [ ] Right-clicking a launcher offers no Pin, Unpin, Configure or panel + edit entry +- [ ] The order survives launching and closing applications, logout/login + and a reboot +- [ ] The bar is right at 1080p/100% and 4K/200% + +## The Bible study laptop + +A laptop installed as a fixed appliance: AC power, battery out, lid shut, +one television on HDMI, Wi-Fi. + +``` +DEVICE_TYPE=laptop +DISPLAY_MODE=external-only +NETWORK_PREFERENCE=wifi +WONDERINK_ENABLED=false +PARENT_CALL_ENABLED=false +DVD_ENABLED=false +``` + +Nothing about `DEVICE_TYPE=laptop` turns those applications off; they are +off because those lines say so. + +### Physical verification — a real lid and a real television + +The lab has no lid and no HDMI hotplug, so none of this is proven by a +passing build. Do it once at the machine: + +1. With the lid **open** and the television on, check + `classroom-display-mode` reports the panel switched off and the + television on, and that only the television is lit. +2. Close the lid. The machine must keep running: the television stays on, + audio keeps playing, SSH stays up. `systemctl is-system-running` + still answers, and `loginctl` shows the session active. +3. Leave it closed for ten minutes. It must not suspend, hibernate, log + out or lock. `journalctl -b | grep -i -E "suspend|hibernate|lid"` + should show nothing acting. +4. With the lid still closed, switch the television **off** and on again. + The picture must come back. **Known limitation of 0.2.4:** the display + mode is applied at login, not continuously, so if KWin re-enables the + internal panel on that power cycle it stays enabled until the next + login. Check whether it does, and report it — that decides whether the + follow-up needs an event-driven applier like the audio policy's. +5. Unplug and replug HDMI, lid closed. Same check. +6. Reboot with the lid closed and the television on. It must come back to + the classroom desktop on the television. +7. Confirm Wi-Fi reconnects on its own, with nobody typing a password. +8. Confirm the HDMI audio policy still works (see Audio, above). + ## Messaging - [ ] With `MQTT_ENABLED=false`, `journalctl -u ekklesia-classroom-mqtt` diff --git a/lib/classroom_display.py b/lib/classroom_display.py new file mode 100644 index 0000000..2990869 --- /dev/null +++ b/lib/classroom_display.py @@ -0,0 +1,96 @@ +"""Deciding which displays a classroom machine drives. + +The decision only, so it can be tested against the arrangement a real +machine is in rather than against a running compositor. + +The laptop that prompted this is a fixed appliance: on AC, battery out, +lid shut, one external television on HDMI. Its internal panel should be +off. But external-only is a SETTING and not a property of laptops — +another laptop may want its own screen, or both — so nothing here asks +what shape the machine is. + +IDENTIFYING THE INTERNAL PANEL. + +By kscreen's own output type rather than by connector name. KScreen +reports an integer from KScreen::Output::Type, and 7 is Panel: the +built-in screen, whatever the connector happens to be called on that +hardware. Matching "eDP-1" would be matching one machine's spelling — +eDP, LVDS and DSI are all internal, numbered differently per driver, and +renamed between kernel versions. + +The names are still checked, but only as a fallback for a driver that +reports type Unknown, which some virtual and older devices do. +""" + +# KScreen::Output::Type. Only the two this has to tell apart are named. +TYPE_PANEL = 7 + +# Fallback for a driver that reports no type at all. +INTERNAL_NAMES = ("edp", "lvds", "dsi", "emb") + +ALL = "all" +EXTERNAL_ONLY = "external-only" + + +def is_internal(output): + """Whether this is the machine's own built-in screen.""" + if output.get("type") == TYPE_PANEL: + return True + + if output.get("type") not in (None, 0): + return False + + name = (output.get("name") or "").lower() + + return any(name.startswith(prefix) for prefix in INTERNAL_NAMES) + + +def connected(outputs): + return [item for item in outputs or [] if item.get("connected", True)] + + +def plan(outputs, mode=ALL): + """Which connected outputs to enable and which to disable. + + Returns (enable, disable) as lists of names. + + The one rule that overrides the setting: never leave the machine with + no screen at all. A laptop asked for external-only whose television is + switched off keeps its own panel, because a machine nobody can see is + a machine nobody can fix standing in front of it. It is corrected the + next time this runs with the television on. + """ + here = connected(outputs) + + if not here: + return [], [] + + internal = [item for item in here if is_internal(item)] + external = [item for item in here if not is_internal(item)] + + if mode != EXTERNAL_ONLY or not external: + return [item.get("name") for item in here], [] + + return ( + [item.get("name") for item in external], + [item.get("name") for item in internal], + ) + + +def changes(outputs, mode=ALL): + """The subset of plan() that is not already true. + + Applying only what differs is what keeps a television power cycle from + becoming a stream of mode sets, and keeps this quiet in the journal + when there is nothing to do. + """ + enable, disable = plan(outputs, mode) + state = { + item.get("name"): item.get("enabled", True) + for item in connected(outputs) + } + + return ( + [name for name in enable if not state.get(name, False)], + [name for name in disable if state.get(name, False)], + ) diff --git a/scripts/classroom-display-mode b/scripts/classroom-display-mode new file mode 100755 index 0000000..b941da2 --- /dev/null +++ b/scripts/classroom-display-mode @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 + +"""Put this machine's displays the way device.conf asks. + +The decision is in classroom_display; this applies it through Plasma's +own tool, which is the same way classroom-scale applies UI_SCALE. + +DISPLAY_MODE: + + all every connected display. Every existing classroom. + external-only the external display, with the built-in panel off. + +Run at login from an XDG autostart entry, before classroom-scale, so that +the scale is applied to the displays this leaves enabled rather than to a +panel that is about to go off. + +It never leaves the machine with no screen: a laptop asked for +external-only whose television is off keeps its own panel and says so. +That is a deliberate refusal to obey — a machine nobody can see is one +nobody can fix standing in front of it — and it corrects itself the next +time this runs with the television on. +""" + +import json +import os +import pwd +import subprocess +import sys + +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_display as policy # noqa: E402 +from classroom_config import display_mode, read_device_config # noqa: E402 + + +def say(message): + print(f"classroom-display-mode: {message}", flush=True) + + +def kscreen(*args): + try: + return subprocess.run( + ["kscreen-doctor"] + list(args), + capture_output=True, text=True, timeout=30, check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + say(str(error)) + return None + + +def outputs(): + result = kscreen("-j") + + if result is None or result.returncode != 0: + say("could not read the displays") + return [] + + try: + return json.loads(result.stdout or "{}").get("outputs") or [] + except ValueError as error: + say(f"unreadable display list: {error}") + return [] + + +def main(): + if pwd.getpwuid(os.getuid()).pw_name != "classroom": + return 0 + + mode = display_mode(read_device_config()) + found = outputs() + + if not found: + say("no displays") + return 1 + + described = ", ".join( + f"{item.get('name')}" + f"{' (built-in)' if policy.is_internal(item) else ''}" + f"{'' if item.get('enabled', True) else ' [off]'}" + for item in policy.connected(found) + ) + say(f"DISPLAY_MODE={mode}; connected: {described or 'none'}") + + enable, disable = policy.plan(found, mode) + + if mode == policy.EXTERNAL_ONLY and not disable and not any( + policy.is_internal(item) for item in policy.connected(found)): + say("external-only, and there is no built-in panel to switch off") + elif mode == policy.EXTERNAL_ONLY and all( + policy.is_internal(item) for item in policy.connected(found)): + say("external-only asked for, but the only display is the built-in " + "panel — leaving it on rather than blinding the machine") + + enable, disable = policy.changes(found, mode) + + if not enable and not disable: + say("the displays already match") + return 0 + + failed = False + + # Enable first. Switching the panel off before the television is on + # would leave a moment with no output at all. + for name in enable: + result = kscreen(f"output.{name}.enable") + + if result is None or result.returncode != 0: + failed = True + say(f"{name}: could not enable") + else: + say(f"{name} on") + + for name in disable: + result = kscreen(f"output.{name}.disable") + + if result is None or result.returncode != 0: + failed = True + say(f"{name}: could not disable") + else: + say(f"{name} off") + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-classroom-display-mode.sh b/tests/test-classroom-display-mode.sh new file mode 100755 index 0000000..427d7a8 --- /dev/null +++ b/tests/test-classroom-display-mode.sh @@ -0,0 +1,139 @@ +#!/bin/bash +# Which displays a machine drives, and what a closed lid does. +# +# The laptop going in for the women's Bible study sits on AC with its +# battery out, its lid shut and one television on HDMI. Two separate +# things have to be true for that: its internal panel is off, and closing +# the lid does nothing at all. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +sys.path.insert(0, str(root / "lib")) + +from classroom_display import ( + ALL, EXTERNAL_ONLY, TYPE_PANEL, changes, is_internal, plan, +) +from classroom_config import display_mode + +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 + + +def out(name, kind, enabled=True, connected=True): + return {"name": name, "type": kind, "enabled": enabled, + "connected": connected} + + +HDMI, DISPLAYPORT, UNKNOWN = 6, 14, 0 + +# The Bible study laptop: its own panel, and the television. +LAPTOP = [out("eDP-1", TYPE_PANEL), out("HDMI-A-1", HDMI)] +# A classroom tower with one television. +TOWER = [out("HDMI-A-1", HDMI)] + +print("1. the built-in panel is found by what it IS, not what it is called") +check("kscreen's Panel type is the built-in screen", + is_internal(out("whatever-3", TYPE_PANEL))) +check("an HDMI output is not", + not is_internal(out("HDMI-A-1", HDMI))) +check("nor DisplayPort", + not is_internal(out("DP-2", DISPLAYPORT))) +# Some drivers report no type at all, so the names are a fallback and +# only a fallback. +check("a driver that reports no type falls back to the name", + is_internal(out("eDP-1", UNKNOWN)) and is_internal(out("LVDS-1", UNKNOWN))) +check("and an unknown-type television is still not internal", + not is_internal(out("HDMI-A-1", UNKNOWN))) + +print("2. every existing classroom is untouched") +check("a tower on 'all' drives its television", + plan(TOWER, ALL) == (["HDMI-A-1"], [])) +check("a device that has never heard of DISPLAY_MODE gets 'all'", + display_mode({}) == ALL) +check("and an unreadable value is 'all' rather than a dark room", + display_mode({"DISPLAY_MODE": "nonsense"}) == ALL) + +print("3. the Bible study laptop") +enable, disable = plan(LAPTOP, EXTERNAL_ONLY) +check("the television is driven", enable == ["HDMI-A-1"], str(enable)) +check("and the laptop's own panel is switched off", + disable == ["eDP-1"], str(disable)) + +print("4. the same laptop is not forced into it") +# external-only must be a setting, never a property of having a lid. +check("a laptop on 'all' keeps both screens", + plan(LAPTOP, ALL) == (["eDP-1", "HDMI-A-1"], [])) + +print("5. never leave the machine with no screen") +# A television that is off at boot is the ordinary case, and a laptop +# that answers it by blinding itself is one nobody can fix standing in +# front of it. +lid_only = [out("eDP-1", TYPE_PANEL)] +check("external-only with no external display keeps the panel on", + plan(lid_only, EXTERNAL_ONLY) == (["eDP-1"], []), + str(plan(lid_only, EXTERNAL_ONLY))) +check("and nothing at all connected decides nothing", + plan([], EXTERNAL_ONLY) == ([], [])) +check("a disconnected output is not counted as somewhere to go", + plan([out("eDP-1", TYPE_PANEL), + out("HDMI-A-1", HDMI, connected=False)], EXTERNAL_ONLY) + == (["eDP-1"], [])) + +print("6. only what is not already true is applied") +# This is what keeps a television power cycle from becoming a stream of +# mode sets, and what keeps the journal quiet when there is nothing to do. +settled = [out("eDP-1", TYPE_PANEL, enabled=False), out("HDMI-A-1", HDMI)] +check("a laptop already in external-only needs no change", + changes(settled, EXTERNAL_ONLY) == ([], []), + str(changes(settled, EXTERNAL_ONLY))) +check("a television just switched on is enabled, panel switched off", + changes([out("eDP-1", TYPE_PANEL), out("HDMI-A-1", HDMI, enabled=False)], + EXTERNAL_ONLY) == (["HDMI-A-1"], ["eDP-1"])) +check("a settled tower needs no change", + changes(TOWER, ALL) == ([], [])) + +print("7. a closed lid does nothing, at every layer that could act on it") +logind = (root / "config/systemd/logind-ekklesia-classroom.conf").read_text() +powerdevil = (root / "config/plasma/xdg/powermanagementprofilesrc").read_text() +sleep_rules = (root / "config/polkit/org.ekklesia.classroom.sleep.rules").read_text() +locker = (root / "config/plasma/xdg/kscreenlockerrc").read_text() + +for key in ("HandleLidSwitch=ignore", "HandleLidSwitchExternalPower=ignore", + "HandleLidSwitchDocked=ignore"): + check(f"logind: {key}", key in logind) + +# PowerDevil takes logind's inhibitor while a session is running, so +# logind alone would be overruled. Both halves, in every profile. +check("PowerDevil does nothing on a lid event, on AC and on battery", + powerdevil.count("lidAction=0") >= 3, powerdevil.count("lidAction=0")) +check("and does not act just because a monitor is attached", + powerdevil.count("triggerLidActionWhenExternalMonitorPresent=false") >= 3) +check("every power profile is immutable, so a stray file cannot add one", + powerdevil.count("[$i]") >= 6) +check("the classroom account cannot suspend by any other route", + "login1.suspend" in sleep_rules and "login1.hibernate" in sleep_rules) +check("and the session never locks", + "Autolock=false" in locker or "LockOnResume=false" in locker, + locker[:200]) + +print() +print(f"{passed} passed, {failed} failed") +raise SystemExit(1 if failed else 0) +PY diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index c0fa6dc..5796b2a 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -107,11 +107,17 @@ check("settings install to the classroom's own directory", # above into the cascade at all. A drop-in directory is built to be # added to; a name plasma-workspace already owns is not. xdg_paths = sorted(re.findall(r'\$STAGE(/etc/xdg/[^"]+)', build)) -check("what the classroom puts under /etc/xdg is two drop-ins", +check("what the classroom puts under /etc/xdg is drop-ins only", xdg_paths == [ + "/etc/xdg/autostart/ekklesia-classroom-display.desktop", "/etc/xdg/autostart/ekklesia-classroom-scale.desktop", "/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh", ]) +# Which displays are on is decided before the scale is applied to them, +# and XDG autostart order is the filename order in practice. +check("and the displays are decided before they are scaled", + xdg_paths.index("/etc/xdg/autostart/ekklesia-classroom-display.desktop") + < xdg_paths.index("/etc/xdg/autostart/ekklesia-classroom-scale.desktop")) check("and no settings file takes a name plasma-workspace owns", not any(path.rsplit("/", 1)[0] == "/etc/xdg" for path in xdg_paths)) check("and that directory is searched first", diff --git a/tools/build-release.sh b/tools/build-release.sh index ee4b747..7c13644 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -112,6 +112,10 @@ install -Dm755 \ "$ROOT/scripts/classroom-scale" \ "$STAGE/usr/local/bin/classroom-scale" +install -Dm755 \ + "$ROOT/scripts/classroom-display-mode" \ + "$STAGE/usr/local/bin/classroom-display-mode" + install -Dm755 \ "$ROOT/scripts/classroom-shutdown" \ "$STAGE/usr/local/bin/classroom-shutdown" @@ -139,6 +143,10 @@ install -Dm644 \ "$ROOT/lib/classroom_config.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_config.py" +install -Dm644 \ + "$ROOT/lib/classroom_display.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_display.py" + install -Dm644 \ @@ -427,6 +435,11 @@ install -Dm644 \ "$ROOT/config/plasma/autostart/ekklesia-classroom-scale.desktop" \ "$STAGE/etc/xdg/autostart/ekklesia-classroom-scale.desktop" +# Which displays are on, decided before the scale is applied to them. +install -Dm644 \ + "$ROOT/config/plasma/autostart/ekklesia-classroom-display.desktop" \ + "$STAGE/etc/xdg/autostart/ekklesia-classroom-display.desktop" + # The panel's disc player follows this machine's hardware, and the # detection has to have happened before a session starts. install -Dm644 \