332 lines
14 KiB
Python
Executable File
332 lines
14 KiB
Python
Executable File
#!/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):
|
|
|
|
<node_id>/client.json the desktop agent's config
|
|
<node_id>/mic-follow-<node_id>.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))
|