"""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]