#!/usr/bin/env python3 """A pactl that answers for a classroom nobody has to be standing in. This exists because the one thing the room actually does — a television switched off while the computer boots and switched on afterwards — cannot be produced by any sound card a lab has. ALSA reports a television through the HDMI port's availability, and no loopback or dummy driver has one. So the scenarios that matter are played through this instead, and the real classroom-audio daemon runs against it unmodified. Everything here is shaped the way PipeWire 1.4.2 and WirePlumber 0.5.8 really answer, checked against both running in the lab: cards "ports" is an object keyed by port name, a port carries NO "direction", and an unknown availability is the phrase "availability unknown" sinks have no top-level "card"; the link to the card is properties["device.name"], and "active_port" is a port name profile switching one destroys the old sink and creates a new one, and emits a burst of events rather than a single one It also records every command that changes anything, so a test can say "and nothing was switched twice", which is the property the room needs most and the one that is invisible from a single snapshot. """ import json import os import sys import time STATE = os.environ["MOCK_AUDIO_STATE"] EVENTS = os.environ["MOCK_AUDIO_EVENTS"] JOURNAL = os.environ["MOCK_AUDIO_JOURNAL"] def load(): with open(STATE) as handle: return json.load(handle) def save(state): temporary = f"{STATE}.new" with open(temporary, "w") as handle: json.dump(state, handle, indent=2) os.replace(temporary, STATE) def record(line): with open(JOURNAL, "a") as handle: handle.write(line + "\n") def emit(*events): with open(EVENTS, "a") as handle: for event in events: handle.write(event + "\n") def output_token(profile): """hdmi-stereo, from output:hdmi-stereo+input:analog-stereo.""" head = (profile or "").split("+", 1)[0] return head.split(":", 1)[1] if ":" in head else "" def active_port_of(card): profile = card.get("active_profile") or "" for name, port in (card.get("ports") or {}).items(): if profile in (port.get("profiles") or []) and "output" in profile: # The port whose own kind matches the output half of the # profile, so an analog profile does not light the HDMI port. token = output_token(profile) if name.startswith("hdmi") or name.startswith("displayport"): if "hdmi" in token or "dp" in token: return name, port elif "analog" in token or "stereo" in token: return name, port return None, None def sinks_of(state): """One sink per card, for whatever its active profile turned on.""" sinks = [] index = 40 for card in state["cards"]: profile = card.get("active_profile") or "" index += 1 if not profile.startswith("output:"): continue name, port = active_port_of(card) if name is None: continue suffix = card["name"].split(".", 1)[-1] sink_name = f"alsa_output.{suffix}.{output_token(profile)}" levels = state.setdefault("levels", {}) level = levels.get(sink_name, {"volume": 100, "mute": False}) sinks.append({ "index": index, "state": "SUSPENDED", "name": sink_name, "description": card.get("description", "Built-in Audio"), "mute": level["mute"], "volume": { "front-left": {"value_percent": f"{level['volume']}%"}, "front-right": {"value_percent": f"{level['volume']}%"}, }, "properties": { "device.name": card["name"], "node.name": sink_name, "alsa.card_name": card.get("description", "Built-in Audio"), "device.profile.name": output_token(profile), }, "ports": [{ "name": name, "availability": port.get("availability", "availability unknown"), "priority": port.get("priority", 0), }], "active_port": name, }) return sinks def sink_named(state, name): for sink in sinks_of(state): if sink["name"] == name: return sink return None def main(): argv = sys.argv[1:] if argv[:2] == ["-f", "json"]: argv = argv[2:] as_json = True else: as_json = False if not argv: return 1 state = load() # The server not being up yet is the whole of the boot race, so it is # a state this has to be able to be in. if not state.get("server_up", True) and argv[0] != "subscribe": sys.stderr.write("Connection failure: Connection refused\n") return 1 command = argv[0] if command == "info": if not state.get("server_up", True): sys.stderr.write("Connection failure: Connection refused\n") return 1 print("Server Name: PulseAudio (on PipeWire 1.4.2)") return 0 if command == "list": what = argv[1] if len(argv) > 1 else "" if what == "cards": payload = state["cards"] elif what == "sinks": payload = sinks_of(state) elif what == "sink-inputs": payload = state.get("streams", []) else: payload = [] print(json.dumps(payload) if as_json else "") return 0 if command == "get-default-sink": print(state.get("default_sink") or "") return 0 if command == "set-default-sink": name = argv[1] if sink_named(state, name) is None: sys.stderr.write(f"Failure: No such entity {name}\n") return 1 if state.get("default_sink") != name: record(f"set-default-sink {name}") state["default_sink"] = name save(state) # A no-op set-default-sink emits nothing on the real server; # measured. Only a real change is announced. emit("Event 'change' on server #4294967295") return 0 if command == "set-card-profile": card_name, profile = argv[1], argv[2] for card in state["cards"]: if card["name"] != card_name: continue if profile not in (card.get("profiles") or []): sys.stderr.write(f"Failure: No such profile {profile}\n") return 1 record(f"set-card-profile {card_name} {profile}") old = sinks_of(state) card["active_profile"] = profile save(state) new = sinks_of(state) # Switching a profile tears the old sink down and builds a new # one, and the server says all of it. events = [] for sink in old: if sink["name"] not in [item["name"] for item in new]: events.append(f"Event 'remove' on sink #{sink['index']}") events.append(f"Event 'change' on card #{card.get('index', 0)}") for sink in new: if sink["name"] not in [item["name"] for item in old]: events.append(f"Event 'new' on sink #{sink['index']}") emit(*events) # A stream on a sink that has gone is re-landed by the server # on whatever is default, exactly as WirePlumber does. survivors = [item["name"] for item in new] for stream in state.get("streams", []): if stream.get("sink_name") not in survivors: fallback = state.get("default_sink") or ( survivors[0] if survivors else None) stream["sink_name"] = fallback target = sink_named(state, fallback) if fallback else None stream["sink"] = target["index"] if target else -1 save(state) return 0 sys.stderr.write(f"Failure: No such card {card_name}\n") return 1 if command == "set-sink-volume": name, value = argv[1], argv[2] record(f"set-sink-volume {name} {value}") state.setdefault("levels", {}).setdefault( name, {"volume": 100, "mute": False}) state["levels"][name]["volume"] = int(value.rstrip("%")) save(state) emit(f"Event 'change' on sink #{sink_named(state, name)['index']}") return 0 if command == "set-sink-mute": name, value = argv[1], argv[2] record(f"set-sink-mute {name} {value}") state.setdefault("levels", {}).setdefault( name, {"volume": 100, "mute": False}) state["levels"][name]["mute"] = value not in ("0", "false", "no") save(state) emit(f"Event 'change' on sink #{sink_named(state, name)['index']}") return 0 if command == "move-sink-input": index, name = int(argv[1]), argv[2] target = sink_named(state, name) if target is None: sys.stderr.write(f"Failure: No such entity {name}\n") return 1 for stream in state.get("streams", []): if stream["index"] == index: if stream.get("sink_name") != name: record(f"move-sink-input {index} {name}") stream["sink_name"] = name stream["sink"] = target["index"] save(state) emit(f"Event 'change' on sink-input #{index}") return 0 sys.stderr.write(f"Failure: No such entity {index}\n") return 1 if command == "subscribe": # Blocks and prints events as they happen, like the real one. Only # events written from now on: a subscription never replays what it # missed, which is precisely how the rooms came to be silent. with open(EVENTS) as handle: handle.seek(0, os.SEEK_END) while True: line = handle.readline() if not line: time.sleep(0.05) continue sys.stdout.write(line) sys.stdout.flush() sys.stderr.write(f"mock-pactl: unhandled {' '.join(argv)}\n") return 1 if __name__ == "__main__": sys.exit(main())