diff --git a/config/plasma/xdg/kdeglobals b/config/plasma/xdg/kdeglobals index 7fb90b8..b65d87d 100644 --- a/config/plasma/xdg/kdeglobals +++ b/config/plasma/xdg/kdeglobals @@ -32,7 +32,12 @@ action/help_report_bug=false action/help_about_kde=false action/help_contents=false -[General][$i] +# Not immutable. Marking this group [$i] froze the colour scheme so hard +# that `classroom-theme light` changed the running session and then lost +# the change at the next start — measured. These are defaults for a room +# that has no settings application anyway; the lock belongs on the +# restrictions above, not on the palette. +[General] ColorScheme=EkklesiaDark AccentColor=79,179,168 # The teacher's own words are not decoration: keep text rendering plain @@ -41,13 +46,13 @@ XftAntialias=true XftHintStyle=hintslight XftSubPixel=rgb -[KDE][$i] +[KDE] widgetStyle=Breeze LookAndFeelPackage=org.ekklesia.classroom.desktop SingleClick=false AnimationDurationFactor=0.5 -[Icons][$i] +[Icons] Theme=breeze-dark [KFileDialog Settings][$i] diff --git a/config/plasma/xdg/kwinrulesrc b/config/plasma/xdg/kwinrulesrc index d46f6fa..5be0128 100644 --- a/config/plasma/xdg/kwinrulesrc +++ b/config/plasma/xdg/kwinrulesrc @@ -1,48 +1,32 @@ # Window rules. # -# There are three, they are all about size, and none of them is about -# stacking. Dialogs staying in front of the window that opened them, -# secondary windows floating over the main one instead of resizing it, and -# a print dialog being the window in front are all ordinary window-manager -# behaviour here, so there is nothing to write down. +# There is one, and it is about video. # -# `types=1` is NET::Normal only. Without it a rule that maximises would -# also maximise the dialogs, which is exactly the behaviour the classroom -# is moving away from. +# Everything else the classroom used to arrange by hand is ordinary window +# manager behaviour and needs no rule: a dialog stays in front of the window +# that opened it, a window a page opens floats over the browser instead of +# resizing it, and a print dialog is the window in front. +# +# There is deliberately no rule that maximises the browser. A rule can only +# match a window class, and Chrome gives its main window and every window a +# page opens the same one — matching on it maximised the lesson's popup to +# the whole display, which is the behaviour this architecture exists to +# stop. Chrome can tell them apart, so `--start-maximized` in the launcher +# does it: the first window fills the screen, later ones keep the size the +# page asked for. Arranging that from outside the browser is what the old +# 314-line window arbiter was doing. [General] -count=3 -rules=classroom-browser,classroom-wonderink,classroom-video - -[classroom-browser] -Description=The classroom browser fills the screen -wmclass=google-chrome -wmclassmatch=1 -wmclasscomplete=false -types=1 -maximizehoriz=true -maximizehorizrule=3 -maximizevert=true -maximizevertrule=3 - -[classroom-wonderink] -Description=Wonder Ink fills the screen -# Chrome derives this from the --app URL. Measured on a running session -# rather than guessed; it is also what the launcher's StartupWMClass says, -# and the two have to agree or the panel shows two buttons for one app. -wmclass=chrome-app.wonderink.org__-Default -wmclassmatch=1 -wmclasscomplete=false -types=1 -maximizehoriz=true -maximizehorizrule=3 -maximizevert=true -maximizevertrule=3 +count=1 +rules=classroom-video [classroom-video] Description=Video is an appliance screen, not a window -wmclass=vlc -wmclassmatch=2 +# VLC reports its class as "Vlc", and KWin's matching is case sensitive — +# measured on a running session, where a lower-case substring rule matched +# nothing at all and said so nowhere. +wmclass=(?i)vlc +wmclassmatch=3 wmclasscomplete=false noborder=true noborderrule=2 diff --git a/docs/screenshots/lock-desktop-rmb.png b/docs/screenshots/lock-desktop-rmb.png new file mode 100644 index 0000000..58d43cd Binary files /dev/null and b/docs/screenshots/lock-desktop-rmb.png differ diff --git a/docs/screenshots/lock-krunner.png b/docs/screenshots/lock-krunner.png new file mode 100644 index 0000000..09abf1e Binary files /dev/null and b/docs/screenshots/lock-krunner.png differ diff --git a/docs/screenshots/lock-panel-rmb.png b/docs/screenshots/lock-panel-rmb.png new file mode 100644 index 0000000..09abf1e Binary files /dev/null and b/docs/screenshots/lock-panel-rmb.png differ diff --git a/docs/screenshots/v2-browser.png b/docs/screenshots/v2-browser.png new file mode 100644 index 0000000..a14ba67 Binary files /dev/null and b/docs/screenshots/v2-browser.png differ diff --git a/docs/screenshots/v2-dark-bar.png b/docs/screenshots/v2-dark-bar.png new file mode 100644 index 0000000..67e97bb Binary files /dev/null and b/docs/screenshots/v2-dark-bar.png differ diff --git a/docs/screenshots/v2-fullscreen.png b/docs/screenshots/v2-fullscreen.png new file mode 100644 index 0000000..cfe0e7f Binary files /dev/null and b/docs/screenshots/v2-fullscreen.png differ diff --git a/docs/screenshots/v2-idle-desktop.png b/docs/screenshots/v2-idle-desktop.png new file mode 100644 index 0000000..0935b5e Binary files /dev/null and b/docs/screenshots/v2-idle-desktop.png differ diff --git a/docs/screenshots/v2-light-bar.png b/docs/screenshots/v2-light-bar.png new file mode 100644 index 0000000..1cfb745 Binary files /dev/null and b/docs/screenshots/v2-light-bar.png differ diff --git a/docs/screenshots/v2-light.png b/docs/screenshots/v2-light.png new file mode 100644 index 0000000..e7892fb Binary files /dev/null and b/docs/screenshots/v2-light.png differ diff --git a/docs/screenshots/v2-notification-crop.png b/docs/screenshots/v2-notification-crop.png new file mode 100644 index 0000000..31db85f Binary files /dev/null and b/docs/screenshots/v2-notification-crop.png differ diff --git a/docs/screenshots/v2-notification.png b/docs/screenshots/v2-notification.png new file mode 100644 index 0000000..c76f41f Binary files /dev/null and b/docs/screenshots/v2-notification.png differ diff --git a/docs/screenshots/v2-print-dialog.png b/docs/screenshots/v2-print-dialog.png new file mode 100644 index 0000000..76d2c5a Binary files /dev/null and b/docs/screenshots/v2-print-dialog.png differ diff --git a/docs/screenshots/v2-secondary-window.png b/docs/screenshots/v2-secondary-window.png new file mode 100644 index 0000000..3db7d9b Binary files /dev/null and b/docs/screenshots/v2-secondary-window.png differ diff --git a/docs/screenshots/v2-testpage.png b/docs/screenshots/v2-testpage.png new file mode 100644 index 0000000..77e90a8 Binary files /dev/null and b/docs/screenshots/v2-testpage.png differ diff --git a/docs/screenshots/v2-theme-compare.png b/docs/screenshots/v2-theme-compare.png new file mode 100644 index 0000000..97cc62f Binary files /dev/null and b/docs/screenshots/v2-theme-compare.png differ diff --git a/docs/screenshots/wtype-probe.png b/docs/screenshots/wtype-probe.png new file mode 100644 index 0000000..b3b1af1 Binary files /dev/null and b/docs/screenshots/wtype-probe.png differ diff --git a/lib/classroom_audio.py b/lib/classroom_audio.py new file mode 100644 index 0000000..a614cdb --- /dev/null +++ b/lib/classroom_audio.py @@ -0,0 +1,162 @@ +"""Deciding which output a classroom should be playing through. + +This module is only the decision. It takes the state of the sound cards as +PipeWire reports it and returns the port that should be in use, so the +policy can be tested against the situation a real room was actually in +rather than against a running machine. + +The room that prompted this had a Samsung television on HDMI with working +PCM audio, nothing at all plugged into the onboard analog jack, and +WirePlumber had chosen analog anyway. A teacher who fixed it by hand then +had to restart the browser before any sound came out. + +Two rules come out of that: + +* a port that reports itself unavailable is not a candidate, and "analog + exists" is not evidence that a cable is in it; and +* the decision has to be re-made whenever the hardware situation changes, + not once at boot, because a television that is switched on after the + computer is the normal case in a classroom. +""" + +# Port name prefixes PipeWire uses for the digital display outputs. +DIGITAL = ("hdmi-output", "displayport-output", "hdmi", "dp-") +ANALOG = ("analog-output", "lineout", "headphones", "speaker") + +AVAILABLE = "available" +UNAVAILABLE = "not available" +UNKNOWN = "unknown" + +AUTO = "auto" +HDMI = "hdmi" +ANALOG_ONLY = "analog" + + +def _kind(port_name): + name = (port_name or "").lower() + + for prefix in DIGITAL: + if name.startswith(prefix): + return HDMI + + for prefix in ANALOG: + if name.startswith(prefix): + return ANALOG_ONLY + + return None + + +def _availability(port): + value = (port.get("availability") or "").strip().lower() + + if value in ("available", "yes"): + return AVAILABLE + + if value in ("not available", "no"): + return UNAVAILABLE + + return UNKNOWN + + +def candidates(cards): + """Every output port, tagged with what it is and whether it is there. + + `cards` is the structure `pactl -f json list cards` produces. + """ + found = [] + + for card in cards or []: + for name, port in (card.get("ports") or {}).items(): + if (port.get("direction") or "Output").lower() != "output": + continue + + kind = _kind(name) + + if kind is None: + continue + + found.append({ + "card": card.get("name"), + "card_index": card.get("index"), + "port": name, + "kind": kind, + "availability": _availability(port), + "priority": port.get("priority") or 0, + "profiles": list(port.get("profiles") or []), + }) + + return found + + +def choose(cards, preference=AUTO): + """The port the room should be playing through, or None. + + An explicit preference is obeyed as far as the hardware allows, and + falls back rather than leaving the room silent. + """ + found = candidates(cards) + + if not found: + return None + + def ranked(kind, accept): + pool = [ + item for item in found + if item["kind"] == kind and item["availability"] in accept + ] + # Highest priority first, then a stable order so a machine with two + # identical outputs does not pick a different one each boot. + return sorted( + pool, + key=lambda item: (-item["priority"], item["card"] or "", item["port"]), + ) + + HERE = (AVAILABLE,) + SILENT = (AVAILABLE, UNKNOWN) + ANYTHING = (AVAILABLE, UNKNOWN, UNAVAILABLE) + + # A television that is on says so. A port that reports nothing is still + # worth using if there is no better answer, but never ahead of one that + # reports itself connected. A port that reports itself absent is chosen + # only when the alternative is a silent room — or when somebody has + # named it explicitly, which is what an override is for. + order = { + AUTO: [ + (HDMI, HERE), (ANALOG_ONLY, HERE), + (HDMI, SILENT), (ANALOG_ONLY, SILENT), + (HDMI, ANYTHING), (ANALOG_ONLY, ANYTHING), + ], + HDMI: [ + (HDMI, HERE), (HDMI, SILENT), (HDMI, ANYTHING), + (ANALOG_ONLY, HERE), (ANALOG_ONLY, SILENT), (ANALOG_ONLY, ANYTHING), + ], + ANALOG_ONLY: [ + (ANALOG_ONLY, HERE), (ANALOG_ONLY, SILENT), (ANALOG_ONLY, ANYTHING), + (HDMI, HERE), (HDMI, SILENT), (HDMI, ANYTHING), + ], + }[preference] + + for kind, accept in order: + pool = ranked(kind, accept) + + if pool: + return pool[0] + + return None + + +def profile_for(port): + """The card profile to switch to so that a port is usable. + + A port lists the profiles it appears in; the classroom wants the one + that turns the output on without also claiming inputs it does not need, + which in practice is the shortest name among the stereo output ones. + """ + profiles = [name for name in port.get("profiles") or [] if name != "off"] + + if not profiles: + return None + + outputs = [name for name in profiles if "output" in name] or profiles + + return sorted(outputs, key=lambda name: (len(name), name))[0] diff --git a/lib/classroom_config.py b/lib/classroom_config.py index 2045231..137e2b7 100644 --- a/lib/classroom_config.py +++ b/lib/classroom_config.py @@ -31,6 +31,18 @@ DVD_ENABLED=false # implemented yet; this only controls whether the current placeholder appears. LIVE_ENABLED=false +# Which output the room plays through. +# +# auto prefer a connected HDMI or DisplayPort television, and fall +# back to the analog jack. This is right for almost every room. +# hdmi always the television, even if it does not report itself. +# analog always the jack. +# +# auto re-decides whenever the hardware changes, so a television switched +# on after the computer is picked up without anybody touching anything, +# and sound that is already playing is moved across to it. +AUDIO_OUTPUT=auto + # Size of the dock, clock and notifications, 0.8 to 1.5. # Larger rooms and larger TVs want a larger number. UI_SCALE=1.0 @@ -60,6 +72,21 @@ DVD_ENABLED=false """# Show the Sanctuary LIVE placeholder in the dock. Live viewing is not # implemented yet; this only controls whether the current placeholder appears. LIVE_ENABLED=false +""", + ), + ( + "AUDIO_OUTPUT", + """# Which output the room plays through. +# +# auto prefer a connected HDMI or DisplayPort television, and fall +# back to the analog jack. This is right for almost every room. +# hdmi always the television, even if it does not report itself. +# analog always the jack. +# +# auto re-decides whenever the hardware changes, so a television switched +# on after the computer is picked up without anybody touching anything, +# and sound that is already playing is moved across to it. +AUDIO_OUTPUT=auto """, ), ( diff --git a/scripts/classroom-audio b/scripts/classroom-audio new file mode 100755 index 0000000..e243bcd --- /dev/null +++ b/scripts/classroom-audio @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 + +"""Keep a classroom playing through the right output. + +The policy is in classroom_audio; this is the part that talks to PipeWire. +It applies the decision, and then re-applies it whenever the sound hardware +changes, which is what makes a television switched on after the computer +work without anybody touching anything. + +Three things happen on every change, and the third is the one that fixes +the complaint from the room: + + set the card profile so the output exists at all + set the default sink so new sound goes there + move existing streams so sound that is ALREADY PLAYING follows + +Without the last step a browser that was started before the change keeps +playing into the old sink, and the only fix a teacher can find is to close +and reopen it. That is exactly what happened in the room this was written +for. + +Volume and mute are set once, when the output actually changes. After that +the room's own volume is left alone. +""" + +import json +import os +import subprocess +import sys +import time + +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_audio as policy # noqa: E402 + +try: + from classroom_config import read_device_config +except ImportError: # pragma: no cover - the daemon must not need it + def read_device_config(): + return {} + + +# Loud enough to be heard at the back of a classroom, quiet enough that the +# first thing a teacher plays is not a shock. Only ever applied when the +# output changes, never on top of a volume somebody has chosen. +DEFAULT_VOLUME = "70%" + +# Several udev events arrive for one cable being plugged in. Settle first. +SETTLE_SECONDS = 1.5 + + +def pactl(*args, parse=False): + command = ["pactl"] + + if parse: + command += ["-f", "json"] + + command += list(args) + + try: + result = subprocess.run( + command, capture_output=True, text=True, timeout=15, check=False + ) + except (OSError, subprocess.SubprocessError) as error: + log(f"pactl {' '.join(args)}: {error}") + return None + + if result.returncode != 0: + log(f"pactl {' '.join(args)}: {result.stderr.strip()}") + return None + + if not parse: + return result.stdout + + try: + return json.loads(result.stdout or "[]") + except ValueError as error: + log(f"pactl {' '.join(args)}: unreadable output: {error}") + return None + + +def log(message): + print(f"classroom-audio: {message}", flush=True) + + +def preference(): + value = (read_device_config() or {}).get("AUDIO_OUTPUT", policy.AUTO) + value = (value or "").strip().lower() + + return value if value in (policy.AUTO, policy.HDMI, policy.ANALOG_ONLY) \ + else policy.AUTO + + +def sink_for_card(card_name): + for sink in pactl("list", "sinks", parse=True) or []: + properties = sink.get("properties") or {} + + if properties.get("alsa.card_name") and card_name and \ + properties.get("device.string"): + pass + + if (sink.get("card") == card_name + or properties.get("device.name") == card_name + or (card_name or "") in (sink.get("name") or "")): + return sink + + return None + + +def apply(state): + cards = pactl("list", "cards", parse=True) + + if cards is None: + return + + chosen = policy.choose(cards, preference()) + + if chosen is None: + if state.get("port") is not None: + log("no usable output") + state["port"] = None + return + + profile = policy.profile_for(chosen) + changed = state.get("port") != (chosen["card"], chosen["port"]) + + if changed and profile: + pactl("set-card-profile", str(chosen["card"]), profile) + # The sink appears only after the profile is in place. + time.sleep(0.4) + + sink = sink_for_card(chosen["card"]) + + if sink is None: + log(f"no sink for {chosen['card']} after {profile}") + return + + name = sink.get("name") + pactl("set-default-sink", name) + + if changed: + pactl("set-sink-mute", name, "0") + pactl("set-sink-volume", name, DEFAULT_VOLUME) + log(f"output is {chosen['port']} on {chosen['card']} ({name})") + + # Anything already making sound follows, so a browser that was open + # before the television was switched on does not have to be restarted. + moved = 0 + + for stream in pactl("list", "sink-inputs", parse=True) or []: + if stream.get("sink") == sink.get("index"): + continue + + if pactl("move-sink-input", str(stream.get("index")), name) is not None: + moved += 1 + + if moved: + log(f"moved {moved} playing stream(s) to {name}") + + state["port"] = (chosen["card"], chosen["port"]) + + +def watch(): + """Follow PipeWire's own event stream rather than polling it.""" + state = {} + apply(state) + + while True: + process = subprocess.Popen( + ["pactl", "subscribe"], + stdout=subprocess.PIPE, + text=True, + ) + + try: + for line in process.stdout: + if not any( + word in line for word in ("card", "server", "sink", "source") + ): + continue + + # Drain the rest of the burst before acting once. + time.sleep(SETTLE_SECONDS) + apply(state) + except OSError as error: + log(f"subscription ended: {error}") + finally: + process.terminate() + + log("re-subscribing") + time.sleep(3) + + +def main(): + if len(sys.argv) > 1 and sys.argv[1] == "once": + apply({}) + return 0 + + if len(sys.argv) > 1 and sys.argv[1] == "show": + cards = pactl("list", "cards", parse=True) or [] + for item in policy.candidates(cards): + print(f"{item['kind']:6} {item['availability']:13} " + f"prio={item['priority']:<6} {item['card']} {item['port']}") + chosen = policy.choose(cards, preference()) + print(f"\npreference={preference()} chosen=" + f"{chosen['port'] if chosen else 'none'}") + return 0 + + watch() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/classroom-browser b/scripts/classroom-browser index 3afccfc..9e3f2cb 100755 --- a/scripts/classroom-browser +++ b/scripts/classroom-browser @@ -26,6 +26,7 @@ HOME_PAGE='https://www.youtube.com/' exec google-chrome \ --ozone-platform=wayland \ + --start-maximized \ --no-first-run \ --no-default-browser-check \ --password-store=basic \ diff --git a/scripts/classroom-theme b/scripts/classroom-theme new file mode 100755 index 0000000..614afcb --- /dev/null +++ b/scripts/classroom-theme @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +# Light or dark, for a room whose teachers prefer one. +# +# There is one interface, not two. Both schemes are the same token file with +# the roles swapped, and Plasma applies them to the panel, the notifications, +# the window decorations and every dialog at once, so nothing has to be kept +# in step by hand. + +usage() { + echo "usage: classroom-theme [light|dark|toggle|show]" >&2 + exit 2 +} + +current() { + kreadconfig6 --file kdeglobals --group General --key ColorScheme 2>/dev/null \ + || echo EkklesiaDark +} + +apply() { + plasma-apply-colorscheme "$1" >/dev/null + echo "$1" +} + +case "${1:-show}" in +light) apply EkklesiaLight ;; +dark) apply EkklesiaDark ;; +toggle) + if [[ "$(current)" == EkklesiaLight ]]; then apply EkklesiaDark + else apply EkklesiaLight; fi + ;; +show) current ;; +*) usage ;; +esac diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index 0412094..d222a42 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -32,6 +32,7 @@ exec google-chrome \ --user-data-dir="$PROFILE" \ --app="$HOME_PAGE" \ --ozone-platform=wayland \ + --start-maximized \ --no-first-run \ --no-default-browser-check \ --password-store=basic \ diff --git a/systemd/user/ekklesia-classroom-audio.service b/systemd/user/ekklesia-classroom-audio.service new file mode 100644 index 0000000..ddc3a67 --- /dev/null +++ b/systemd/user/ekklesia-classroom-audio.service @@ -0,0 +1,17 @@ +[Unit] +Description=Ekklesia Classroom audio output policy +Documentation=file:///usr/local/bin/classroom-audio +After=pipewire.service wireplumber.service +Wants=pipewire.service wireplumber.service +PartOf=graphical-session.target + +[Service] +ExecStart=/usr/local/bin/classroom-audio +# The room losing its sound policy is a Sunday-morning problem, so this is +# never allowed to stay dead — the same rule the panel gets. +Restart=always +RestartSec=5 +Slice=session.slice + +[Install] +WantedBy=plasma-workspace.target diff --git a/tests/test-classroom-audio.sh b/tests/test-classroom-audio.sh new file mode 100755 index 0000000..bd735fe --- /dev/null +++ b/tests/test-classroom-audio.sh @@ -0,0 +1,127 @@ +#!/bin/bash +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PYTHONPATH="$ROOT/lib" python3 - <<'PY' +from classroom_audio import ANALOG_ONLY, AUTO, HDMI, candidates, choose, profile_for + +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 card(name, ports): + return { + "name": name, + "index": 0, + "ports": { + port: { + "availability": availability, + "priority": priority, + "direction": "Output", + "profiles": profiles, + } + for port, availability, priority, profiles in ports + }, + } + + +# The room this policy was written for. A Samsung television on HDMI with +# working PCM audio, and an onboard analog jack with nothing in it that +# reports itself unavailable. WirePlumber chose analog. +THE_ROOM = [card("alsa_card.pci-0000_00_1f.3", [ + ("analog-output-lineout", "not available", 9900, + ["output:analog-stereo", "output:analog-stereo+input:analog-stereo"]), + ("hdmi-output-0", "available", 5900, + ["output:hdmi-stereo", "output:hdmi-surround"]), +])] + +print("1. the classroom that went to analog") +chosen = choose(THE_ROOM, AUTO) +check("auto picks the television, not the empty jack", + chosen and chosen["port"] == "hdmi-output-0", + f"chose {chosen and chosen['port']}") +check("and a stereo output profile for it", + profile_for(chosen) == "output:hdmi-stereo", + f"chose {profile_for(chosen)}") + +print("2. an unavailable digital port is not a candidate") +TV_OFF = [card("alsa_card.pci", [ + ("analog-output-lineout", "available", 9900, ["output:analog-stereo"]), + ("hdmi-output-0", "not available", 5900, ["output:hdmi-stereo"]), +])] +chosen = choose(TV_OFF, AUTO) +check("auto falls back to analog with the television off", + chosen and chosen["port"] == "analog-output-lineout", + f"chose {chosen and chosen['port']}") + +print("3. availability is not always reported") +UNKNOWN = [card("alsa_card.pci", [ + ("analog-output-lineout", "", 9900, ["output:analog-stereo"]), + ("hdmi-output-0", "", 5900, ["output:hdmi-stereo"]), +])] +chosen = choose(UNKNOWN, AUTO) +check("a silent card still prefers the digital output", + chosen and chosen["port"] == "hdmi-output-0", + f"chose {chosen and chosen['port']}") + +print("4. an available analog jack does not outrank a live television") +BOTH = [card("alsa_card.pci", [ + ("analog-output-lineout", "available", 9900, ["output:analog-stereo"]), + ("hdmi-output-0", "available", 5900, ["output:hdmi-stereo"]), +])] +chosen = choose(BOTH, AUTO) +check("auto still picks HDMI when both are connected", + chosen and chosen["port"] == "hdmi-output-0", + f"chose {chosen and chosen['port']}") + +print("5. explicit overrides") +check("analog forces the jack even with a live television", + choose(BOTH, ANALOG_ONLY)["port"] == "analog-output-lineout") +check("hdmi forces the television even when it is off", + choose(TV_OFF, HDMI)["port"] == "hdmi-output-0") +check("hdmi still falls back rather than leaving the room silent", + choose([card("alsa_card.pci", [ + ("analog-output-lineout", "available", 9900, ["output:analog-stereo"]), + ])], HDMI)["port"] == "analog-output-lineout") + +print("6. two displays") +TWO = [card("alsa_card.pci", [ + ("hdmi-output-0", "not available", 5900, ["output:hdmi-stereo"]), + ("hdmi-output-1", "available", 5800, ["output:hdmi-stereo-extra1"]), +])] +chosen = choose(TWO, AUTO) +check("the one that is switched on wins, whatever its priority", + chosen and chosen["port"] == "hdmi-output-1", + f"chose {chosen and chosen['port']}") + +print("7. nothing at all") +check("a machine with no sound card decides nothing rather than crashing", + choose([], AUTO) is None) +check("and a card with only inputs is not an output", + choose([{"name": "c", "index": 0, "ports": { + "analog-input-mic": {"direction": "Input", "availability": "available", + "priority": 100, "profiles": ["input:analog-stereo"]}}}], + AUTO) is None) + +print("8. ports that are not outputs of interest are ignored") +check("a bluetooth headset port is not mistaken for a display", + not [item for item in candidates([card("bt", [ + ("headset-output", "available", 100, ["headset-head-unit"])])]) + if item["kind"] == HDMI]) + +print() +print(f"{passed} passed, {failed} failed") +raise SystemExit(1 if failed else 0) +PY diff --git a/tools/lab/lab b/tools/lab/lab index d435406..ef8b2b5 100755 --- a/tools/lab/lab +++ b/tools/lab/lab @@ -150,6 +150,15 @@ click) sleep 0.5 ;; +rclick) + outer swaymsg seat - cursor set "$2" "$3" >/dev/null + sleep 0.4 + outer swaymsg seat - cursor press button3 >/dev/null + sleep 0.15 + outer swaymsg seat - cursor release button3 >/dev/null + sleep 0.5 + ;; + move) outer swaymsg seat - cursor set "$2" "$3" >/dev/null ;;