feat: an external-only display mode, chosen rather than inferred

The women's Bible study laptop is a fixed appliance: AC power, battery
out, lid shut, one television on HDMI. Its own panel should be off.

DISPLAY_MODE=external-only says so, and it is a setting rather than
something inferred from having a lid, because another laptop may want its
own screen or both. Nothing reads DEVICE_TYPE to decide it.

The built-in panel is identified by kscreen's output TYPE — 7 is Panel —
rather than by connector name. 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
kept only as a fallback for a driver that reports no type, which some
virtual devices do.

One rule overrides the setting: never leave the machine with no screen. A
laptop asked for external-only whose television is off keeps its own
panel and says so in the journal. 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 set on. Only the outputs that are not already right
are touched, so a television power cycle is not a stream of mode sets.

Applied at login from an autostart entry named to sort before the scale
one, so the scale lands on the displays this leaves enabled.

The lid itself needed no new code — logind ignores every lid event,
PowerDevil's lidAction is 0 in all three profiles with
triggerLidActionWhenExternalMonitorPresent=false, the profiles are
immutable and the classroom account cannot reach suspend through polkit.
That was all already true for towers. What was missing was a test saying
so, and there is one now, because it is the property the laptop depends
on and nothing would have noticed it regressing.

KNOWN LIMITATION, and it is written into docs/testing.md rather than left
to be discovered: the mode is applied at login, not continuously. If KWin
re-enables the internal panel when the television is power cycled, it
stays enabled until the next login. The lab has no lid and no HDMI
hotplug, so this cannot be settled here; the physical procedure says to
check it specifically, because the answer decides whether the follow-up
needs an event-driven applier like the audio policy's.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-20 21:17:06 -05:00
co-authored by Claude Opus 5
parent 886f8a70d2
commit 52301084a5
7 changed files with 472 additions and 1 deletions
+139
View File
@@ -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