feat: decide the classroom's audio output from what is plugged in
The room that prompted this had a television on HDMI with working PCM audio and nothing in the analog jack, and WirePlumber chose analog. A teacher who corrected it by hand then had to restart the browser before any sound came out. The policy is a pure function so it can be tested against that room rather than against a machine: an unavailable port is not a candidate, an analog port merely existing is not evidence of a cable, and an explicit AUDIO_OUTPUT overrides the lot. Twelve cases, the real room among them. The daemon follows PipeWire's own event stream rather than polling, and moves streams that are already playing, which is the part that stops a teacher having to close and reopen the browser. Co-Authored-By: Claude Opus 5 <[email protected]>
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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]
|
||||
@@ -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
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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())
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
;;
|
||||
|
||||