SmartestHome/mic-follow/desktop_agent.py

330 lines
15 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))
# 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())