#!/usr/bin/env python3 """Turn CoreSystemConfig.json's `mic_follow` block into everything the feature needs. Writes into mic-follow/generated/ (gitignored — the per-client configs carry the MQTT password): /client.json the desktop agent's config /mic-follow-.service the systemd --user unit for it ha-package/mic_follow.yaml one status sensor + one automation per client dock-bindings.md the Stream Dock toggle key, per client identity-toggles.json the allowlist identity serves to the watch One client is one desktop machine and the one person it follows. Adding a second person with a second PC is a second entry in `clients`, and everything below comes out twice with no other change — which is what "prep it for multiple users/clients" means in practice. Usage: mic-follow/generate.py [CoreSystemConfig.json] [--out DIR] """ from __future__ import annotations import json import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent DESK = "desk" def room_label(room: str) -> str: """`living_room` -> `Living room`. Short and readable, because this ends up as the text on a 72-pixel key and on a watch screen, not in a log line.""" if room == DESK: return "Desk" return room.replace("_", " ").strip().capitalize() or room def entity_ids(node_id: str) -> dict[str, str]: """The four entity_ids every consumer refers to. They are deterministic because the agent sets `object_id` in its discovery payloads — see desktop_agent.py.""" return { "armed": f"switch.mic_follow_{node_id}_armed", "input": f"select.mic_follow_{node_id}_input", "actual": f"sensor.mic_follow_{node_id}_actual", "status": f"sensor.mic_follow_{node_id}_status", } def build_client_config(client: dict, cfg: dict) -> dict: prefix = cfg["network"]["subnet_prefix"] container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}" secrets = cfg.get("secrets", {}) or {} return { "node_id": client["node_id"], "friendly_name": client.get("friendly_name") or client["node_id"], "room": client.get("room", ""), "mqtt": { "host": container_ip, "port": cfg["ports"]["mqtt"], "username": secrets.get("mqtt_username", ""), "password": secrets.get("mqtt_password", ""), }, "desk_source": client["desk_source"], "move_streams": list(client.get("move_streams") or []), "reconcile_seconds": client.get("reconcile_seconds", 10), "sources": [ { "room": source["room"], "source": source["source"], "start_command": source.get("start_command", ""), "stop_command": source.get("stop_command", ""), } for source in client.get("sources") or [] ], } def build_unit(client: dict) -> str: node_id = client["node_id"] return f"""[Unit] Description=mic-follow agent for {client.get('friendly_name') or node_id} Documentation=file://{REPO}/mic-follow/README.md After=network-online.target pipewire.service Wants=pipewire.service [Service] Type=simple ExecStart={REPO}/mic-follow/desktop_agent.py --config %h/.config/mic-follow/client.json Restart=always RestartSec=10 [Install] WantedBy=default.target """ def build_ha_package(clients: list[dict]) -> str: lines: list[str] = [ "# Generated by mic-follow/generate.py — do not hand-edit. Change the", "# `mic_follow` block in CoreSystemConfig.json and regenerate.", "#", "# Two things per client:", "#", "# a status sensor whose STATE is the human-readable name of the microphone", "# that is live right now. It exists so that every surface —", "# the Stream Dock key, the Pebble app, a dashboard — can", "# show the answer by displaying ONE entity's state, instead", "# of each one reimplementing the same three-way template.", "# an automation that moves the microphone when the person moves, and only", "# while that client's Follow-me switch is on.", "#", "# The entity_ids below are fixed by the agent's `object_id`, not guessed.", "", "template:", " - sensor:", ] for client in clients: node_id = client["node_id"] ids = entity_ids(node_id) labels = {DESK: "Desk"} for source in client.get("sources") or []: labels[source["room"]] = room_label(source["room"]) label_map = json.dumps(labels) lines += [ f" - name: \"{client.get('friendly_name') or node_id} mic\"", f" unique_id: mic_follow_{node_id}_status", f" # object_id keeps this at {ids['status']} whatever the name becomes.", f" object_id: mic_follow_{node_id}_status", " state: >-", f" {{% set labels = {label_map} %}}", f" {{% set live = states('{ids['input']}') %}}", " {{ labels.get(live, live | replace('_', ' ') | capitalize) }}", " icon: >-", f" {{{{ 'mdi:microphone-message' if is_state('{ids['armed']}', 'on')", " else 'mdi:microphone' }}", " attributes:", f" armed: \"{{{{ is_state('{ids['armed']}', 'on') }}}}\"", f" device: \"{{{{ states('{ids['actual']}') }}}}\"", f" person: \"{client.get('person', '')}\"", "", ] lines += ["automation:"] for client in clients: node_id = client["node_id"] ids = entity_ids(node_id) presence = client["presence_entity"] desk_room = client.get("room", "") dwell = client.get("dwell_seconds", 20) return_dwell = client.get("return_dwell_seconds", 5) policy = client.get("on_unknown_room", "hold") rooms = [source["room"] for source in client.get("sources") or []] room_map = {room: room for room in rooms} if desk_room: room_map[desk_room] = DESK lines += [ f" - id: mic_follow_{node_id}", f" alias: \"Mic follow: {client.get('friendly_name') or node_id}\"", " description: >-", f" Moves {client.get('person') or 'this client'}'s live microphone to match", " where they are, but only while the Follow-me switch is on. The switch", " being off is an active guarantee of the desk mic, so this automation", " never runs then.", " mode: single", " triggers:", ] for room in rooms: lines += [ " - trigger: state", f" entity_id: {presence}", f" to: \"{room}\"", f" for: {{ seconds: {dwell} }}", ] if desk_room: lines += [ " # Coming back is faster than leaving: sitting down should give you", " # the good microphone back before you say anything into it.", " - trigger: state", f" entity_id: {presence}", f" to: \"{desk_room}\"", f" for: {{ seconds: {return_dwell} }}", ] if policy == "desk": known = json.dumps(sorted(room_map.keys())) lines += [ " # on_unknown_room: desk — a room with no microphone configured is a", " # room where nothing can hear you, and this says so rather than", " # leaving another room's mic live.", " - trigger: state", f" entity_id: {presence}", f" not_to: {known}", f" for: {{ seconds: {dwell} }}", ] lines += [ " # And whenever it is switched on, catch up with where the person", " # already is rather than waiting for them to move again.", " - trigger: state", f" entity_id: {ids['armed']}", " to: \"on\"", " conditions:", " - condition: state", f" entity_id: {ids['armed']}", " state: \"on\"", " actions:", " - variables:", f" room_map: {json.dumps(room_map)}", f" current: \"{{{{ states('{presence}') }}}}\"", f" wanted: \"{{{{ room_map.get(current, '{'desk' if policy == 'desk' else 'HOLD'}') }}}}\"", " # HOLD is how on_unknown_room: hold is expressed — no service call at", " # all, so the last microphone stays live while somebody walks through", " # a room nothing can hear them in.", " - condition: template", " value_template: \"{{ wanted != 'HOLD' }}\"", " - action: select.select_option", " target:", f" entity_id: {ids['input']}", " data:", " option: \"{{ wanted }}\"", "", ] return "\n".join(lines) def build_dock_bindings(clients: list[dict]) -> str: out = ["# Stream Dock — the follow-me toggle", "", "Generated by `mic-follow/generate.py`. One key per client. Put it on whichever", "OpenDeck layer you like — it does not have to share the lighting layer.", ""] for client in clients: node_id = client["node_id"] ids = entity_ids(node_id) out += [ f"## {client.get('friendly_name') or node_id}", "", "| Field | Value |", "|---|---|", f"| Entity | `{ids['status']}` |", f"| Service | `switch.toggle` |", "| Service data JSON | see below |", "", "```json", json.dumps({"entity_id": ids["armed"]}, indent=2), "```", "", "**Point the key's displayed entity at the status sensor, not at the switch.**", f"The switch's state is `on`/`off`, which tells you nothing useful; " f"`{ids['status']}`'s state is the *name of the microphone that is live right now*", "— `Desk`, `Loggia` — which is what you actually want to read at a glance. Its", "icon changes with the switch (`mdi:microphone-message` armed, `mdi:microphone`", "off), so one key shows both facts: which mic, and whether it will follow you.", "", "The plugin subscribes to Home Assistant's websocket, so the key updates when", "the state changes rather than on a timer — nothing here polls, and the key is", "correct within a moment of the microphone actually moving, including when it", "was the watch or an automation that moved it.", "", "Suggested title: leave it EMPTY and let the state be the whole label. A key", "reading `Loggia` in large type is readable across a room; the same key reading", "`Follow-me mic` over a small `Loggia` is not.", "", ] return "\n".join(out) def main(argv: list[str]) -> int: args = [a for a in argv[1:] if not a.startswith("--")] out_dir = Path(__file__).resolve().parent / "generated" if "--out" in argv: out_dir = Path(argv[argv.index("--out") + 1]) config_path = Path(args[0]) if args else REPO / "CoreSystemConfig.json" if not config_path.exists(): print(f"error: {config_path} not found", file=sys.stderr) return 2 cfg = json.loads(config_path.read_text()) section = cfg.get("mic_follow") or {} if not section.get("enabled"): print("error: mic_follow.enabled is false — nothing to generate", file=sys.stderr) return 2 clients = [c for c in (section.get("clients") or []) if isinstance(c, dict) and c.get("node_id")] if not clients: print("error: mic_follow.clients is empty", file=sys.stderr) return 2 out_dir.mkdir(parents=True, exist_ok=True) for client in clients: node_id = client["node_id"] client_dir = out_dir / node_id client_dir.mkdir(parents=True, exist_ok=True) config_file = client_dir / "client.json" config_file.write_text(json.dumps(build_client_config(client, cfg), indent=2) + "\n") config_file.chmod(0o600) (client_dir / f"mic-follow-{node_id}.service").write_text(build_unit(client)) package_dir = out_dir / "ha-package" package_dir.mkdir(parents=True, exist_ok=True) (package_dir / "mic_follow.yaml").write_text(build_ha_package(clients)) (out_dir / "dock-bindings.md").write_text(build_dock_bindings(clients)) allowlist = [ { "id": client["node_id"], "name": client.get("friendly_name") or client["node_id"], "switch_entity": entity_ids(client["node_id"])["armed"], "detail_entity": entity_ids(client["node_id"])["status"], } for client in clients ] (out_dir / "identity-toggles.json").write_text(json.dumps(allowlist, indent=2) + "\n") # The same thing as one line, ready to paste into identity.env on the container # host — which is where it has to end up for the watch to see any toggles at all. (out_dir / "identity-toggles.env").write_text( "# Paste into /opt/smart-home/identity/identity.env on the container host,\n" "# then: docker compose restart identity\n" f"TOGGLE_ALLOWLIST_JSON={json.dumps(allowlist, separators=(',', ':'))}\n") print(f"wrote {out_dir}/ for {len(clients)} client(s):") for client in clients: print(f" {client['node_id']}/client.json, {client['node_id']}/mic-follow-{client['node_id']}.service") print(" ha-package/mic_follow.yaml") print(" dock-bindings.md") print(" identity-toggles.json + identity-toggles.env (paste into identity.env)") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))