330 lines
15 KiB
Python
Executable File
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())
|