SmartestHome/mic-follow/desktop_agent.py

355 lines
16 KiB
Python
Executable File

#!/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))
# How long a source with a start hook gets to show up before it counts as
# absent. An SSH tunnel over the LAN is up well inside this; the timeout is
# what stops a dead panel from hanging the switch.
self.source_timeout = float(config.get("source_timeout_seconds", 6))
# 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.
ORDER MATTERS HERE, and it was wrong the first time: a remote microphone does
not EXIST as a PipeWire source until its start hook has run and the stream is
up. Looking for the source first meant a network mic could never be selected —
it fell back to the desk every time, correctly by the letter of the code and
uselessly in practice. So: hooks first, then wait for the source to appear,
then fall back only if it genuinely never does.
"""
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]
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")
target = self._await_source(wanted["source"],
timeout=self.source_timeout if wanted.get("start_command") else 0)
if target is None and option != DESK:
log.warning("no audio source matching %r appeared — falling back to %s",
wanted["source"], DESK)
# Tear down whatever the failed start hook left running before leaving it
# behind: a half-started remote microphone is the hot-mic case.
self._run_hook(wanted.get("stop_command", ""), f"{option} stop (failed)")
option, wanted = DESK, self.sources[DESK]
target = self._await_source(wanted["source"], timeout=0)
if target is None:
log.error("no audio source matching %r either — leaving the input alone",
wanted["source"])
return self.active
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 _await_source(self, pattern: str, timeout: float) -> audio_sources.Source | None:
"""Look for a source, giving a just-started one time to register.
A local device is found on the first pass and this costs nothing. A tunnelled
one takes a moment: the hook returns as soon as the transport is up, and the
node appears in PipeWire slightly after that.
"""
deadline = time.monotonic() + max(0.0, timeout)
while True:
found = audio_sources.select_source(audio_sources.list_sources(), pattern)
if found is not None or time.monotonic() >= deadline:
return found
time.sleep(0.25)
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())