#!/usr/bin/env python3 """mic-follow's desktop half: one MQTT device per client machine, exposing "which microphone is live" to Home Assistant and nothing else. THE DIVISION OF LABOUR, and why it is this way round: Home Assistant decides WHERE the person is and therefore which mic should be live. It is the only thing that sees the locator sources at all. this agent knows HOW to change the input on this machine, and nothing about presence, people, or rooms beyond the names in its own config. Same rule as every other agent in this repo: the inbound control surface is MQTT discovery entities and nothing else — no HTTP listener, no direct path from the LLM, no presence logic on the desktop. It also means a second client machine is a second copy of this file with a different config, which is what "prep it for multiple users/clients" comes down to. THE ONE RULE THAT MAKES IT PREDICTABLE: **off means the desk mic.** The Follow-me switch being off is not "ignore me", it is an active guarantee that this machine is on its own microphone. Being live on the wrong mic is the failure somebody notices in front of their friends, so the safe state is reachable by one tap on the dock, one button on a watch, or one MQTT message — and it does not depend on presence being right, on the remote machine being up, or on this agent having seen a recent update. Nothing here has run against a real PipeWire or a real Home Assistant. The audio layer it calls is `audio_sources.py`, whose parsing is fixture-tested; the commands that change state are not. See mic-follow/README.md. """ from __future__ import annotations import argparse import json import logging import os import shlex import signal import socket import subprocess import sys import threading import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import audio_sources # noqa: E402 import paho.mqtt.client as mqtt # noqa: E402 log = logging.getLogger("mic-follow") DESK = "desk" DISCOVERY_PREFIX = "homeassistant" class Client: """One desktop machine, its microphones, and its Home Assistant entities.""" def __init__(self, config: dict) -> None: self.config = config self.node_id = config["node_id"] self.friendly_name = config.get("friendly_name") or self.node_id self.room = config.get("room", "") self.desk_source = config["desk_source"] self.move_streams = list(config.get("move_streams") or []) self.reconcile_seconds = float(config.get("reconcile_seconds", 10)) # option name -> {source, start_command, stop_command} self.sources: dict[str, dict] = { DESK: {"source": self.desk_source, "start_command": "", "stop_command": ""} } for entry in config.get("sources") or []: self.sources[entry["room"]] = { "source": entry["source"], "start_command": entry.get("start_command", ""), "stop_command": entry.get("stop_command", ""), } self.base = f"smarthome/mic_follow/{self.node_id}" self.armed = False self.selected = DESK self.active = DESK self.actual_description = "" # --- entity plumbing ------------------------------------------------------------ @property def availability_topic(self) -> str: return f"{self.base}/availability" def device_block(self) -> dict: block = { "identifiers": [f"mic_follow_{self.node_id}"], "name": self.friendly_name, "manufacturer": "SmartestHome", "model": "mic-follow desktop agent", } if self.room: block["suggested_area"] = self.room return block def discovery_payloads(self) -> list[tuple[str, dict]]: device = self.device_block() common = { "device": device, "availability_topic": self.availability_topic, "payload_available": "online", "payload_not_available": "offline", } return [ (f"{DISCOVERY_PREFIX}/switch/{self.node_id}/follow_me/config", { **common, "name": "Follow-me mic", "unique_id": f"mic_follow_{self.node_id}_armed", # object_id fixes the entity_id instead of letting HA derive one from # the device and entity names. Everything downstream — the generated # automations, the template sensor, the dock binding, identity's toggle # allowlist, the watch — refers to these by name, and "probably # switch.amirs_desktop_follow_me_mic" is not a thing to build four # consumers on. "object_id": f"mic_follow_{self.node_id}_armed", "command_topic": f"{self.base}/armed/set", "state_topic": f"{self.base}/armed/state", "payload_on": "ON", "payload_off": "OFF", "icon": "mdi:microphone-message", }), (f"{DISCOVERY_PREFIX}/select/{self.node_id}/mic_input/config", { **common, "name": "Mic input", "unique_id": f"mic_follow_{self.node_id}_input", "object_id": f"mic_follow_{self.node_id}_input", "command_topic": f"{self.base}/input/set", "state_topic": f"{self.base}/input/state", "options": list(self.sources.keys()), "icon": "mdi:microphone", }), (f"{DISCOVERY_PREFIX}/sensor/{self.node_id}/mic_actual/config", { **common, "name": "Live microphone", "unique_id": f"mic_follow_{self.node_id}_actual", "object_id": f"mic_follow_{self.node_id}_actual", "state_topic": f"{self.base}/actual/state", "icon": "mdi:microphone-settings", }), ] # --- the actual switching -------------------------------------------------------- def _run_hook(self, command: str, label: str) -> None: """A source's start/stop hook. This is what brings a NETWORK microphone up and down — and bringing it down matters more than bringing it up: a room mic that keeps streaming after the switch left it is a hot mic in somebody's flat.""" if not command.strip(): return try: result = subprocess.run(shlex.split(command), capture_output=True, text=True, timeout=20, check=False) if result.returncode != 0: log.warning("%s hook failed (%s): %s", label, result.returncode, (result.stderr or "").strip()[:200]) except (OSError, subprocess.SubprocessError) as exc: log.warning("%s hook could not run: %s", label, exc) def apply(self, option: str) -> str: """Make `option` the live input. Returns the option actually applied — which is `desk` whenever the requested one cannot be found, because silence is a worse answer than the wrong room's microphone only until you remember that the desk mic is the one the person is not standing in front of. Falling back loudly and predictably beats leaving Discord holding a device that has gone away.""" wanted = self.sources.get(option) if wanted is None: log.warning("unknown input %r — falling back to %s", option, DESK) option, wanted = DESK, self.sources[DESK] sources = audio_sources.list_sources() target = audio_sources.select_source(sources, wanted["source"]) if target is None and option != DESK: log.warning("no audio source matching %r on this machine — falling back to %s", wanted["source"], DESK) option, wanted = DESK, self.sources[DESK] target = audio_sources.select_source(sources, wanted["source"]) if target is None: log.error("no audio source matching %r either — leaving the input alone", wanted["source"]) return self.active if option != self.active: previous = self.sources.get(self.active) if previous: self._run_hook(previous.get("stop_command", ""), f"{self.active} stop") self._run_hook(wanted.get("start_command", ""), f"{option} start") audio_sources.set_default_source(target) # And move what is already recording: an application that picked a specific # microphone in its own settings — which anyone with a studio mic has — does # not follow the default. for stream in audio_sources.streams_to_move( audio_sources.list_capture_streams(), self.move_streams, target): log.info("moving %s's live stream to %s", stream.application, target.description) audio_sources.move_stream(stream, target) self.active = option self.actual_description = target.description log.info("input is now %s (%s)", option, target.description) return option def make_mqtt_client(client_id: str) -> mqtt.Client: # Same shim as every other agent here: paho 2.x wants an explicit callback API # version, bookworm's 1.6.x has no such argument. callback_api = getattr(mqtt, "CallbackAPIVersion", None) if callback_api is not None: return mqtt.Client(callback_api.VERSION1, client_id=client_id) return mqtt.Client(client_id=client_id) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="mic-follow desktop agent") parser.add_argument("--config", default=os.environ.get( "MIC_FOLLOW_CONFIG", str(Path.home() / ".config/mic-follow/client.json"))) parser.add_argument("--list-sources", action="store_true", help="print this machine's audio inputs and exit") args = parser.parse_args(argv) logging.basicConfig(level=os.environ.get("MIC_FOLLOW_LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(name)s: %(message)s", stream=sys.stdout) if args.list_sources: return audio_sources.main() config_path = Path(args.config).expanduser() if not config_path.exists(): log.error("no config at %s — generate one with mic-follow/generate.py", config_path) return 2 client = Client(json.loads(config_path.read_text())) broker = client.config.get("mqtt", {}) or {} host = broker.get("host", "") if not host: log.error("mqtt.host is not set in %s", config_path) return 2 mqtt_client = make_mqtt_client(f"mic-follow-{client.node_id}-{socket.gethostname()}") if broker.get("username"): mqtt_client.username_pw_set(broker["username"], broker.get("password") or None) mqtt_client.will_set(client.availability_topic, "offline", qos=1, retain=True) def publish_state() -> None: mqtt_client.publish(f"{client.base}/armed/state", "ON" if client.armed else "OFF", qos=1, retain=True) mqtt_client.publish(f"{client.base}/input/state", client.active, qos=1, retain=True) mqtt_client.publish(f"{client.base}/actual/state", client.actual_description or "unknown", qos=1, retain=True) def desired_option() -> str: # The whole policy, in one line: armed follows the selection, unarmed is the # desk mic. Everything else in this file is mechanism. return client.selected if client.armed else DESK def reconcile(force: bool = False) -> None: wanted = desired_option() if force or wanted != client.active: client.apply(wanted) publish_state() return # Nothing asked for a change — but something else on the desktop may have moved # the default (plugging in a headset does exactly that), so the sensor has to be # re-read rather than assumed. current = audio_sources.current_default_source_name() target = audio_sources.select_source( audio_sources.list_sources(), client.sources[client.active]["source"]) if target is not None and current and current != target.name: log.info("something else changed the default input — putting it back") client.apply(wanted) publish_state() def on_connect(_client, _userdata, _flags, rc): if rc != 0: log.error("MQTT connection refused (rc=%s)", rc) return log.info("connected to MQTT %s:%s", host, broker.get("port", 1883)) for topic, payload in client.discovery_payloads(): mqtt_client.publish(topic, json.dumps(payload), qos=1, retain=True) mqtt_client.subscribe([(f"{client.base}/armed/set", 1), (f"{client.base}/input/set", 1)]) mqtt_client.publish(client.availability_topic, "online", qos=1, retain=True) reconcile(force=True) def on_message(_client, _userdata, message): payload = message.payload.decode("utf-8", "replace").strip() if message.topic.endswith("/armed/set"): client.armed = payload.upper() == "ON" log.info("follow-me %s", "armed" if client.armed else "disarmed") elif message.topic.endswith("/input/set"): if payload not in client.sources: log.warning("ignoring unknown input %r", payload) return client.selected = payload log.info("input selection is now %r", payload) reconcile() mqtt_client.on_connect = on_connect mqtt_client.on_message = on_message mqtt_client.on_disconnect = lambda *_: log.warning("disconnected from MQTT; paho will retry") stop = threading.Event() signal.signal(signal.SIGTERM, lambda *_: stop.set()) signal.signal(signal.SIGINT, lambda *_: stop.set()) mqtt_client.connect_async(host, int(broker.get("port", 1883)), keepalive=60) mqtt_client.loop_start() log.info("mic-follow agent for %s started", client.node_id) try: while not stop.wait(client.reconcile_seconds): reconcile() finally: log.info("shutting down — returning to the desk microphone") # Leaving a machine on a remote microphone because a service stopped is exactly # the surprise this component exists to avoid, and it also shuts down any # network mic stream through the stop hook. client.armed = False client.apply(DESK) mqtt_client.publish(client.availability_topic, "offline", qos=1, retain=True) mqtt_client.loop_stop() mqtt_client.disconnect() return 0 if __name__ == "__main__": raise SystemExit(main())