SmartestHome/mic-follow/generate.py

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))