From cebdc9529bd367bd0d82d102f6287289c4d1ff7e Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 31 Aug 2026 13:41:33 +0200 Subject: [PATCH] Follow-me microphone: the mic switches to the room you walked into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A voice call that survives a smoke break. The person leaves their desk, the locator says which room they are in, their machine's live microphone switches to one that can hear them there, and back to the studio mic when they sit down again. Written per CLIENT, so a second person with a second desktop is one more entry in CoreSystemConfig.json and nothing else changes. ONE RULE MAKES IT PREDICTABLE: off means the desk mic. The Follow-me switch being off is not "ignore me", it is an active guarantee that the machine is on its own microphone — reachable from the dock, the watch or MQTT, honoured when the agent shuts down, and independent of whether presence is right or the remote machine is up. Being live on the wrong mic is the failure you notice in front of five other people, so the safe state has to be the cheap one. Home Assistant decides where the person is; the desktop agent only knows how to change the input. That split is the same one every other agent here uses — the inbound control surface is MQTT discovery entities and nothing else, and no presence logic runs on a desktop. The agent sets the default source AND moves the already-running capture streams of the configured applications. Anyone who owns a studio mic has picked it explicitly in Discord, and an explicitly-picked device does not follow the default; without the second half the switch would appear to do nothing in the one application it exists for. Three surfaces, one entity to read. sensor.mic_follow__status has as its STATE the name of the microphone that is live right now — "Desk", "Loggia" — so nothing has to reimplement the same three-way template: Stream Dock a key showing that sensor, calling switch.toggle. The HA plugin subscribes to the websocket, so it updates on state change rather than on a timer, including when the watch moved it. Leave the key title empty: "Loggia" in large type is readable across a room, "Follow-me mic" over a small "Loggia" is not. Pebble a new toggles screen, long-press Select on the plan. The live microphone in large type, FOLLOWING / DESK ONLY as a coloured pill, Select flips it, Up/Down cycles clients. HA the switch and the select, like anything else. The watch reaches HA through a new allowlist in identity (/toggles), not directly: identity already holds an HA token and the phone already holds identity's, so one button on a wrist does not put an HA admin token into a watchapp's settings. Only switch.* entities that are named in TOGGLE_ALLOWLIST_JSON, only on/off/toggle, anything else is a 404 — and each entry may name a detail_entity whose state is served alongside, which is how the watch displays the live microphone instead of deriving it. Every response re-reads the state rather than assuming it: "I sent the command" is not the same fact as "the switch is on". start_command/stop_command on a source are the hook for a microphone that is not simply plugged into the machine. The STOP hook is the important one: a room microphone still streaming after the switch left it is a hot mic in somebody's flat. It runs on every transition away and on shutdown, and the validator warns about a start with no stop. Tested, and it runs anywhere: 17 fixture cases over source selection — a monitor source can never be selected (picking one transmits what the desktop is PLAYING, the worst outcome available), exact names beat substrings, an ambiguous pattern resolves the same way after a reboot instead of coin-flipping, and only the configured applications' streams move. The new watch message is round-tripped through the real JS packer and the real C parser, including the two cases that decode as plausible garbage otherwise: a value containing the field separator, and a record truncated mid-way. The generated HA package parses as YAML for one client and for several. Untested, and it needs the actual machines: every command that changes state (pactl set-default-source, move-source-output), the pactl JSON shapes the fixtures imitate, the discovery payloads against a real HA — and the one most likely to bite, whether BLE presence reports rooms fast and accurately enough to be worth wiring to a microphone at all. Room-level presence has never been measured in this flat. RuView can say a room is occupied but not by whom, and Frigate recognises faces at the door, not per room, so this rides on BLE with both of those as corroboration. mic-follow/README.md sections 3 and 7. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B7yp4NcmX4zqja1MKRjeWJ --- .gitignore | 4 + CoreSystemConfig.json.template | 36 +++ README.md | 5 + identity/README.md | 24 ++ identity/identity.env.example | 16 ++ identity/server.py | 156 +++++++++++- mic-follow/README.md | 133 ++++++++++ mic-follow/audio_sources.py | 224 +++++++++++++++++ mic-follow/desktop_agent.py | 329 ++++++++++++++++++++++++ mic-follow/generate.py | 331 +++++++++++++++++++++++++ mic-follow/setup-client.sh | 75 ++++++ mic-follow/test_selection.py | 141 +++++++++++ pebble-presence/README.md | 22 ++ pebble-presence/WIREFORMAT.md | 25 ++ pebble-presence/package.json | 4 +- pebble-presence/src/c/main.c | 196 +++++++++++++++ pebble-presence/src/pkjs/index.js | 121 ++++++++- pebble-presence/test/pebble_stub.h | 13 +- pebble-presence/test/run-tests.sh | 14 +- pebble-presence/test/wireformat_test.c | 46 +++- tools/config-export.py | 25 ++ tools/validate-config.py | 132 ++++++++++ 22 files changed, 2065 insertions(+), 7 deletions(-) create mode 100644 mic-follow/README.md create mode 100755 mic-follow/audio_sources.py create mode 100755 mic-follow/desktop_agent.py create mode 100755 mic-follow/generate.py create mode 100755 mic-follow/setup-client.sh create mode 100755 mic-follow/test_selection.py diff --git a/.gitignore b/.gitignore index 887cbcf..ae14dc6 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,7 @@ proxy/ca/ # stream-dock/generate.py's output. led-sync.env holds the Home Assistant token, and # bindings.md names every light entity in the room — regenerate it, never commit it. stream-dock/generated/ + +# mic-follow/generate.py's output. Each client.json holds the MQTT password, and the +# identity-toggles env line names every switch — regenerate it, never commit it. +mic-follow/generated/ diff --git a/CoreSystemConfig.json.template b/CoreSystemConfig.json.template index 13335f3..92345e2 100644 --- a/CoreSystemConfig.json.template +++ b/CoreSystemConfig.json.template @@ -214,6 +214,42 @@ "wake_word": "ok_nabu" }, + "mic_follow": { + "_comment": "Follow-me microphone switching. A person walks out of the room their PC is in; the locator says which room they are in now; that machine's live microphone switches to one that can hear them, and back again when they return. Built for a voice call that has to survive a smoke break, and written per CLIENT so a second person with a second desktop is a second entry here and nothing else.", + "_how_it_decides": "Home Assistant owns the decision and the desktop agent owns the mechanism. The generated automations watch presence_entity and, ONLY while that client's Follow-me switch is on, set its Mic input select. Switch off is not 'ignore me' — it is an active guarantee that the machine is on its own desk microphone.", + "_locator_honesty": "presence_entity must be an entity whose STATE is an HA area_id. The only identity-bearing room-level source in this household is BLE (Bermuda, surfaced by identity/); RuView tells you a room is occupied but not by whom, and Frigate recognises faces at the door, not per room. So RuView/Frigate can corroborate but cannot drive this on their own — see mic-follow/README.md section 3 before trusting the room this switches on.", + "enabled": false, + "clients": [ + { + "_comment": "One desktop machine and the one person it follows. node_id must be unique, lowercase, and is what every generated entity_id contains.", + "node_id": "amir_desktop", + "friendly_name": "Amir's desktop", + "person": "amir", + "room": "amirs_room", + "presence_entity": "sensor.amir_ble_area", + "_desk_source": "Any part of a PipeWire source name or description — run `mic-follow/desktop_agent.py --list-sources` on that machine to see the real strings. This is the microphone the client returns to whenever follow-me is off.", + "desk_source": "MV6", + "_move_streams": "Applications whose ALREADY-RUNNING capture stream gets moved as well as the default being changed. Anyone with a studio mic has picked it explicitly in Discord, and an explicitly-picked device does not follow the default.", + "move_streams": ["Discord"], + "_dwell": "Seconds the person has to be in a room before the microphone follows them. Leaving is slower than returning on purpose: a walk past the door should not move your microphone, but sitting back down should give you your good one back quickly.", + "dwell_seconds": 20, + "return_dwell_seconds": 5, + "_on_unknown_room": "hold | desk. What happens in a room with no microphone configured. 'hold' keeps the last one (you might be walking through); 'desk' is the honest one (nothing in that room can hear you).", + "on_unknown_room": "hold", + "reconcile_seconds": 10, + "_sources": "One per room this client can be heard in. start_command/stop_command are hooks for a microphone that is not simply plugged into this machine — a network mic stream, say — and the STOP hook is the important one: a room microphone that keeps streaming after the switch left it is a hot mic in somebody's flat.", + "sources": [ + { + "room": "loggia", + "source": "DJI MIC MINI", + "start_command": "", + "stop_command": "" + } + ] + } + ] + }, + "stream_dock": { "_comment": "A desk-side Stream Dock (MiraBox N4 Pro / Ajazz AKP05 family: 10 LCD keys, 4 RGB-lit rotary encoders) driving ONE room's colour lamps through OpenDeck. This block generates the dock's key/dial bindings, its knob-LED colours and the LED sync service's environment. It does not build an image and nothing here runs on the container host — the dock hangs off a desktop machine. See stream-dock/README.md.", "_room": "An HA area_id, the same vocabulary as every kiosk (docs/rooms-and-endpoints.md). It labels the generated bindings and names the systemd unit; `lights` is what actually gets controlled, because a dial has to name entities, not an area.", diff --git a/README.md b/README.md index 19a9aae..d6424ca 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ chores/ Presence/calendar-driven household chore nudging + passive fairness tally + camera-verified trash-bin/ dishes/litter checks (systemd-timed oneshot, no long-lived service, no LLM-picked assignment) +mic-follow/ Follow-me microphone switching, per client desktop: HA + decides which room the person is in, a small agent swaps + that machine's live input (and moves running capture + streams), toggled from the dock, the watch or HA stream-dock/ Desk-side MiraBox N4 Pro (10 keys, 4 encoders) driving one room's colour lamps through OpenDeck: an HA script package for the relative-colour maths, generated dial/key @@ -93,6 +97,7 @@ stream-dock/ Desk-side MiraBox N4 Pro (10 keys, 4 encoders) driv ## Status - [x] Project plan drafted +- [ ] **Follow-me microphone** (`mic-follow/`) — a voice call that survives walking out of the room: the person leaves their desk, the locator says which room they are in, and their machine's live microphone switches to one that can hear them there, then back to the studio mic when they sit down. **Written per client**, so a second person with a second desktop is one more entry in `CoreSystemConfig.json`. Home Assistant decides *where the person is*; the desktop agent only knows *how to change the input* — and it both sets the default source and **moves the already-running capture streams** of the configured applications, because anyone who owns a studio mic has picked it explicitly in Discord and an explicitly-picked device does not follow the default. **One rule makes it predictable: off means the desk mic** — the switch being off is an active guarantee, reachable from the dock, the watch, or MQTT, and honoured on shutdown too. Three surfaces, one entity to read: `sensor.mic_follow__status`, whose state is the *name of the live microphone*, shown in large type on a **Stream Dock key** (websocket-driven, nothing polls) and on a new **Pebble toggles screen** (long-press Select), which reaches Home Assistant through a narrow **allowlist** in `identity` (`/toggles`) rather than putting an HA admin token on a phone. Tested: 17 fixture cases over source selection (a monitor source can never be picked — that would transmit what the desktop is *playing*), a JS↔C round trip for the new watch message, and the generated HA package parsing for one client and several. **Untested:** every command that changes state, and the thing most likely to bite — whether BLE presence reports rooms fast and accurately enough to be worth wiring to a microphone at all, which has never been measured in this flat (`mic-follow/README.md` §3, §7) - [ ] **Stream Dock lighting controls** (`stream-dock/`) — four rotary encoders on a desk-side **MiraBox N4 Pro** (the xVSDinside-branded one; Ajazz AKP05 family) as R / G / B / brightness for one room, through **OpenDeck**. **No new plugin was written, deliberately**: [streamdeck-homeassistant](https://github.com/cgiesche/streamdeck-homeassistant) already does HA-over-websocket with encoder actions, and [opendeck-akp05](https://github.com/aroaxinping/opendeck-akp05) already teaches OpenDeck this non-Elgato hardware — what was missing was the configuration between them, which is what this directory is. The one thing neither plugin can do is **relative colour**: an encoder emits "three ticks clockwise" and HA has `brightness_step_pct` but no equivalent for a colour channel, so the dock sends only *which channel, how many ticks* and `ha-package/stream_dock.yaml` does the read-clamp-write against the lamp's current `rgb_color`. Rings 1–3 glow their own channel's value in their own colour and ring 4 glows in **what the room is actually emitting** (rgb scaled by brightness); off the dock's lighting layer they fall back to the desktop's own `#E40046`/`#5018DD`/`#F50505` chase and Home Assistant stops being polled at all. **Nothing has touched hardware.** The ring colours are computed, debounced and written correctly — verified against a stub HA — but the akp05 plugin reads `leds.toml` only at startup and holds the USB device open, so the last hop is an `apply_command` hook that ships empty; the real fix is a file-watch upstream. The layer gate ships answering *"I cannot tell"* rather than guessing OpenDeck's undocumented profile-state schema, and `{{ticks}}` is the first thing to test before binding four dials — see `stream-dock/README.md` §5–7 - [x] Container host setup script v1 (HA, Mosquitto, Zigbee2MQTT USB, Frigate, Grocy) - [x] Node-RED + monitoring (Netdata) + dashboard (Homepage) + ntfy + Portainer added to compose stack diff --git a/identity/README.md b/identity/README.md index 1d93d1d..b817d0e 100644 --- a/identity/README.md +++ b/identity/README.md @@ -766,3 +766,27 @@ no way to send an `Authorization` header. `digest-engine` needs all three places checked. `digest-engine`'s end of it (which sections a run actually generates, and what a stopped `identity` does to a run) is on that component's own verification list. + +## Watch-reachable toggles + +`GET /toggles` and `POST /toggles/` (`{"action": "on"|"off"|"toggle"}`) read and flip +Home Assistant switches on behalf of the Pebble app — today, `mic-follow`'s Follow-me +microphone switches. + +**It is an allowlist, not a proxy.** `TOGGLE_ALLOWLIST_JSON` names the exact entities; +anything not in it is a 404, only `switch.*` entities are accepted at all (an allowlist +that takes any domain is one that eventually contains a lock), and the only verbs are +on/off/toggle. The list is generated by `mic-follow/generate.py` — see +`mic-follow/README.md` §4. + +It lives here for one reason: this service already holds an HA token, and the phone +already holds this service's. The alternative was an HA admin token in a watchapp's +settings to press one button, which is a much worse trade. Each entry may name a +`detail_entity` whose state is served alongside — for mic-follow that is the sensor whose +state is the microphone that is live right now, so a watch can display the answer instead +of deriving it. + +Each response re-reads the state after the call rather than assuming it: "I sent the +command" is not the same fact as "the switch is on", and the caller is a watch that will +draw whatever this says. + diff --git a/identity/identity.env.example b/identity/identity.env.example index 59f4281..c099c63 100644 --- a/identity/identity.env.example +++ b/identity/identity.env.example @@ -119,3 +119,19 @@ IDENTITY_PHOTO_DIR=/data/photos IDENTITY_FLOORPLAN_DIR=/data/floorplans IDENTITY_MAX_IMAGE_MB=15 LOG_LEVEL=INFO + +# --- Watch-reachable toggles ----------------------------------------------------------- +# An ALLOWLIST of Home Assistant switches this service may read and flip on behalf of +# the Pebble app (GET /toggles, POST /toggles/). Empty means the endpoints return +# nothing and accept nothing, which is the default. +# +# It exists so one button on a watch does not require an HA admin token on a phone: +# this service already has one. It must stay an allowlist — only `switch.*` entities +# are accepted, the verbs are on/off/toggle, and an id that is not listed is a 404. +# +# Generated by mic-follow/generate.py — copy the line out of +# mic-follow/generated/identity-toggles.env. One JSON array of +# {id, name, switch_entity, detail_entity}; detail_entity is what the watch DISPLAYS +# (for mic-follow, the sensor whose state is the microphone that is live right now). +TOGGLE_ALLOWLIST_JSON= + diff --git a/identity/server.py b/identity/server.py index 15de284..4b3bb2d 100755 --- a/identity/server.py +++ b/identity/server.py @@ -144,6 +144,49 @@ PRESENT_STATES = {"home"} # project; there's no floor plan or room list to design against yet. AREA_ATTRIBUTE = os.environ.get("AREA_ATTRIBUTE", "area_id") +# --- Watch-reachable toggles (mic-follow's Follow-me switches) -------------------------- +# An ALLOWLIST, not a capability. The Pebble app needs to flip one Home Assistant +# switch, and it already talks to this service with this service's token — so this is +# the one place in the household that can do it without putting an HA admin token on a +# phone. What it must never become is a general-purpose HA proxy: only entities named +# in this list can be read or written, the verbs are on/off/toggle and nothing else, +# and an id that is not in the list is a 404 rather than a passthrough. +# +# Generated by tools/config-export.py from the mic_follow block; each entry is +# {id, name, switch_entity, detail_entity}. detail_entity is optional and is what the +# watch (and anything else) SHOWS: for mic-follow it is the status sensor whose state +# is the name of the microphone that is live right now. +def _load_toggle_allowlist() -> list[dict]: + raw = os.environ.get("TOGGLE_ALLOWLIST_JSON", "").strip() + if not raw: + return [] + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + LOG.warning("TOGGLE_ALLOWLIST_JSON is not valid JSON — no toggles will be served") + return [] + allowed: list[dict] = [] + for entry in parsed if isinstance(parsed, list) else []: + if not isinstance(entry, dict): + continue + toggle_id = str(entry.get("id") or "").strip() + switch_entity = str(entry.get("switch_entity") or "").strip() + # Only switches. An allowlist that accepts any domain is an allowlist that + # eventually contains a lock. + if not toggle_id or not switch_entity.startswith("switch."): + LOG.warning("ignoring toggle entry %r — needs an id and a switch.* entity", entry) + continue + allowed.append({ + "id": toggle_id, + "name": str(entry.get("name") or toggle_id), + "switch_entity": switch_entity, + "detail_entity": str(entry.get("detail_entity") or "").strip(), + }) + return allowed + + +TOGGLE_ALLOWLIST = _load_toggle_allowlist() + MAX_IMAGE_BYTES = int(os.environ.get("IDENTITY_MAX_IMAGE_MB", "15")) * 1024 * 1024 MAX_JSON_BYTES = 32 * 1024 @@ -576,6 +619,85 @@ def _ha_get(path: str): return json.loads(resp.read()) +def _ha_post(path: str, payload: dict) -> None: + if not HA_TOKEN: + raise RuntimeError("HA_TOKEN is not configured") + req = urllib.request.Request( + f"{HA_URL}{path}", + data=json.dumps(payload).encode("utf-8"), + method="POST", + ) + req.add_header("Authorization", f"Bearer {HA_TOKEN}") + req.add_header("Content-Type", "application/json") + urllib.request.urlopen(req, timeout=10).close() + + +def _toggle_by_id(toggle_id: str) -> dict | None: + for entry in TOGGLE_ALLOWLIST: + if entry["id"] == toggle_id: + return entry + return None + + +def _entity_state(entity_id: str) -> str: + if not entity_id: + return "" + try: + return str((_ha_get(f"/api/states/{entity_id}") or {}).get("state") or "") + except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError, ValueError) as exc: + LOG.warning("could not read %s: %s", entity_id, exc) + return "" + + +def list_toggles() -> dict: + """Every allowlisted toggle, with its state and the one line worth displaying. + + `detail` is deliberately part of the payload rather than something each client + derives: a watch with 64 KB of RAM and a 72-pixel key on a desk should both be able + to show what is going on by printing a string somebody else already worked out. + """ + toggles = [] + for entry in TOGGLE_ALLOWLIST: + state = _entity_state(entry["switch_entity"]) + toggles.append({ + "id": entry["id"], + "name": entry["name"], + "state": state or "unknown", + "on": state == "on", + "detail": _entity_state(entry["detail_entity"]) if entry["detail_entity"] else "", + }) + return {"toggles": toggles} + + +def set_toggle(toggle_id: str, action: str) -> dict: + """on | off | toggle, against one allowlisted switch. Returns the resulting state. + + The state is re-read after the call rather than assumed: the caller is a watch that + will draw whatever this says, and "I sent the command" is not the same fact as + "the switch is on". + """ + entry = _toggle_by_id(toggle_id) + if entry is None: + return {"ok": False, "reason": "not_found"} + service = {"on": "turn_on", "off": "turn_off", "toggle": "toggle"}.get(action) + if service is None: + return {"ok": False, "reason": "bad_action"} + try: + _ha_post(f"/api/services/switch/{service}", {"entity_id": entry["switch_entity"]}) + except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError) as exc: + LOG.warning("toggle %s failed: %s", toggle_id, exc) + return {"ok": False, "reason": "home_assistant_unreachable"} + state = _entity_state(entry["switch_entity"]) + return { + "ok": True, + "id": entry["id"], + "name": entry["name"], + "state": state or "unknown", + "on": state == "on", + "detail": _entity_state(entry["detail_entity"]) if entry["detail_entity"] else "", + } + + def _trusted_present_candidates() -> list[dict]: """Entity_ids matching TRUSTED_ENTITY_PREFIXES whose current HA state indicates presence right now. This is the ONLY source of registration candidates — see the @@ -2602,6 +2724,10 @@ class Handler(BaseHTTPRequestHandler): self._respond(HTTPStatus.OK, area_suggestions()) elif path == "/floorplan/sensors": self._respond(HTTPStatus.OK, list_sensors()) + elif path == "/toggles": + # The Pebble app's second screen. See TOGGLE_ALLOWLIST for why this is a + # fixed list rather than a proxy. + self._respond(HTTPStatus.OK, list_toggles()) elif path == "/person-colors": # Served rather than duplicated in the admin panel's JS, so the palette # has one definition — see PERSON_COLORS for what makes these eight @@ -2719,7 +2845,10 @@ class Handler(BaseHTTPRequestHandler): # /people/prune is checked before the bare /people/ edit route so it is # never parsed as a person id (it can't be — it's not digits — but the ordering # makes the intent explicit rather than incidental). - if path == "/register/photo": + toggle_match = re.match(r"^/toggles/([A-Za-z0-9_-]+)$", path) + if toggle_match: + self._handle_set_toggle(toggle_match.group(1)) + elif path == "/register/photo": self._handle_register_photo() elif path == "/register": self._handle_register() @@ -3039,6 +3168,31 @@ class Handler(BaseHTTPRequestHandler): ok = set_manual_presence(person_id, home) self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, {"ok": ok}) + def _handle_set_toggle(self, toggle_id: str) -> None: + """POST /toggles/ {"action": "on"|"off"|"toggle"} + + `toggle` is the default because the caller is usually a button being pressed by + somebody who can see the current state — on the watch, on a dock key — and + "flip it" is what a button means. Explicit on/off exists so an automation can be + idempotent. + """ + payload = self._json_body() + if payload is None: + return + action = str(payload.get("action") or "toggle").strip().lower() + result = set_toggle(toggle_id, action) + if result.get("ok"): + self._respond(HTTPStatus.OK, result) + elif result.get("reason") == "not_found": + # Deliberately the same answer as an id that does not exist: whether a + # given entity is merely absent from the allowlist is not something an + # unauthenticated-in-spirit surface should be able to probe for. + self._respond(HTTPStatus.NOT_FOUND, {"error": "no such toggle"}) + elif result.get("reason") == "bad_action": + self._respond(HTTPStatus.BAD_REQUEST, {"error": "action must be on, off or toggle"}) + else: + self._respond(HTTPStatus.BAD_GATEWAY, {"error": "Home Assistant did not answer"}) + def _handle_chore_settings(self, person_id: int) -> None: payload = self._json_body() if payload is None: diff --git a/mic-follow/README.md b/mic-follow/README.md new file mode 100644 index 0000000..9589205 --- /dev/null +++ b/mic-follow/README.md @@ -0,0 +1,133 @@ +# mic-follow — the microphone follows the person + +A voice call that survives walking out of the room. The person leaves their desk, the +locator says which room they are in now, and their machine's live microphone switches +to one that can hear them there — then back to the good desk mic when they sit down. + +Built for the specific case of a smoke break on the Loggia during a game, and written +**per client** so a second person with a second desktop is one more entry in +`CoreSystemConfig.json` and nothing else. + +## 1. The one rule + +**Off means the desk mic.** + +The Follow-me switch being off is not "ignore me", it is an active guarantee that the +machine is on its own microphone. Being live on the wrong mic is the failure you notice +in front of five other people, 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 the agent having seen a recent update. +The agent also returns to the desk mic when it shuts down. + +## 2. Who decides what + +``` + locator (BLE via identity; RuView/Frigate corroborate — see §3) + │ presence_entity's state is an area_id + ▼ + Home Assistant ── generated automation ──> select.mic_follow__input + │ (only while switch.mic_follow__armed is on) + ▼ MQTT + desktop_agent.py ──> pactl: set the default source, and MOVE the running + capture streams of the configured applications +``` + +Home Assistant decides **where the person is and therefore which mic should be live**. +The 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. + +**Moving the streams matters as much as setting the default.** Changing the default +source only affects applications that asked for "default", and anyone who owns a studio +mic has picked it explicitly in Discord. `move_streams` names the applications whose +already-running capture stream gets moved too. + +## 3. What the locator can and cannot tell you + +`presence_entity` must be an entity whose **state is an HA `area_id`** +(`docs/rooms-and-endpoints.md`). Getting one is the part of this that is not built here: + +| Source | Gives | Usable to drive this? | +|---|---|---| +| **BLE / Bermuda**, surfaced by `identity`'s `/presence` | a *person*, resolved to a room | **Yes** — the only identity-bearing room-level source in this household | +| **RuView** (CSI radar) | a room is occupied, by somebody | No on its own — it cannot say who. Good for corroboration | +| **Frigate** | a recognised face at the door | No — it is a peephole camera, not per-room | + +So this rides on BLE, with everything else as confirmation. That matters because +**room-level presence has never been measured in this flat** — the repo says so +elsewhere and it is still true. Before trusting it with your microphone, watch the +entity in Developer Tools while you walk to the Loggia and back. If it lags by a minute +or reports a friendly room name instead of an `area_id`, the automation will never fire +and the mic will never move. + +`dwell_seconds` (default 20) is the guard against a twitchy locator: a walk past a door +should not move your microphone. `return_dwell_seconds` (default 5) is deliberately +shorter — sitting down should give you the good mic back before you say anything into +it. + +## 4. Three ways to flip it, one place to read it + +Every surface shows the same entity: `sensor.mic_follow__status`, whose **state is +the name of the microphone that is live right now** — `Desk`, `Loggia`. That sensor +exists so no surface has to reimplement the same three-way template, and so the answer +to "what am I being heard through" is one string anything can print. + +- **Stream Dock key** — displays that sensor and calls `switch.toggle`. The + Home Assistant plugin subscribes to HA's websocket, so the key updates when the state + changes rather than on a timer: **nothing polls**, and the key is right within a moment + of the mic actually moving, including when it was the watch or the automation that + moved it. Bindings are generated into `generated/dock-bindings.md`; leave the key's + title empty and let the state be the whole label, because `Loggia` in large type is + readable across a room and `Follow-me mic` over a small `Loggia` is not. +- **Pebble watchapp** — long-press Select on the plan screen. The screen shows the live + microphone in large type, `FOLLOWING` or `DESK ONLY` as a coloured pill, and Select + flips it. Up/Down cycles clients if there is more than one. +- **Home Assistant** — the switch and the select, like anything else. + +The watch goes through `identity`'s `/toggles`, not straight at Home Assistant: identity +already holds an HA token and the phone already holds identity's, so one button on a +wrist does not put an HA admin token in a watchapp's settings. It is an **allowlist**, +not a proxy — only `switch.*` entities that are named in `TOGGLE_ALLOWLIST_JSON`, only +on/off/toggle, and an id that is not listed is a 404. Paste the line from +`generated/identity-toggles.env` into `identity.env` or the watch sees nothing. + +## 5. Setting it up + +1. Fill in `mic_follow` in `CoreSystemConfig.json`. Get the `desk_source` and each + `source` string by running `mic-follow/desktop_agent.py --list-sources` on the + machine in question — any part of a name or description matches. +2. `tools/validate-config.py`. +3. On each client machine: `mic-follow/setup-client.sh `. +4. Put `generated/ha-package/mic_follow.yaml` in HA's `packages/` (the same directory + `stream-dock` uses) and reload YAML. +5. Paste `generated/identity-toggles.env` into `identity.env`, restart identity. +6. Bind the dock key from `generated/dock-bindings.md`. + +## 6. A remote microphone that is not plugged into this machine + +`start_command` / `stop_command` on a source are the hook for that — bringing up a +network mic stream, for instance, when the switch moves to that room. + +**The stop hook is the important one.** A room microphone that keeps streaming after the +switch has left it is a hot mic in somebody's flat. The agent runs the stop hook on +every transition away and again on shutdown, and the validator warns about a +`start_command` with no matching `stop_command`. + +## 7. What is tested, and what is not + +Tested here, and it runs anywhere: + +- `test_selection.py` — 17 cases over the audio layer's parsing and selection: monitor + sources can never be selected (picking one transmits what the desktop is *playing*, + the worst outcome available), exact names beat substrings, an ambiguous pattern + resolves the same way after a reboot instead of coin-flipping, and only the + configured applications' streams get moved. +- `pebble-presence/test/run-tests.sh` — the toggles line is packed by the real JS and + parsed by the real C, so those two implementations of one format cannot drift. +- The generated HA package parses as YAML, for one client and for several. + +**Not tested, because it needs the actual machines:** every command that changes state +(`pactl set-default-source`, `move-source-output`), the pactl JSON shapes the fixtures +imitate, the MQTT discovery payloads against a real Home Assistant, and — the one most +likely to bite — whether the presence entity reports rooms quickly and accurately +enough to be worth wiring to a microphone at all (§3). diff --git a/mic-follow/audio_sources.py b/mic-follow/audio_sources.py new file mode 100755 index 0000000..fabcf85 --- /dev/null +++ b/mic-follow/audio_sources.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Finding, selecting and moving audio inputs on a PipeWire desktop. + +Everything here goes through `pactl` (pipewire-pulse's compatibility layer) rather +than a mix of `wpctl`, `pw-dump` and `pw-metadata`. One tool covers all four things +this needs — list sources, list capture streams, set the default, move a live stream — +and it is the only one of them with a documented JSON output mode, which is the +difference between parsing a stable structure and scraping a table that changes +between releases. + +WHY MOVING STREAMS MATTERS, and is not the same as setting the default: changing the +default source only affects applications that asked for "default". Discord, once you +have picked a specific microphone in its settings, holds that device — and the whole +point of this component is that somebody who has set up a studio mic has certainly +picked it explicitly. So the switch does both: it sets the default (for anything that +follows it) and moves the already-running capture streams of the configured +applications (for anything that does not). + +Nothing in this file has run against a real PipeWire — see mic-follow/README.md. The +parsing and selection logic is tested against captured pactl output in +test_selection.py; what is unverified is the exact shape of that output on the user's +own version, and every command that changes state. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +from dataclasses import dataclass + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Source: + index: int + name: str + description: str + + def matches(self, pattern: str) -> bool: + pattern = pattern.strip().lower() + return (pattern == self.name.lower() + or pattern in self.name.lower() + or pattern in self.description.lower()) + + +@dataclass(frozen=True) +class CaptureStream: + index: int + source: int + application: str + + +def _pactl_json(*args: str) -> list | dict | None: + try: + result = subprocess.run(["pactl", "-f", "json", *args], + capture_output=True, text=True, timeout=10, check=False) + except (OSError, subprocess.SubprocessError) as exc: + log.warning("pactl %s: %s", " ".join(args), exc) + return None + if result.returncode != 0: + log.warning("pactl %s failed: %s", " ".join(args), (result.stderr or "").strip()[:200]) + return None + try: + return json.loads(result.stdout or "null") + except json.JSONDecodeError as exc: + log.warning("pactl %s returned unparseable JSON: %s", " ".join(args), exc) + return None + + +def parse_sources(payload) -> list[Source]: + """pactl's `list sources` JSON -> Source objects, monitors dropped. + + Monitor sources (the loopback of an output) are excluded deliberately: they match + name patterns surprisingly often, and selecting one means transmitting whatever the + desktop is playing instead of what the person is saying — the single worst outcome + this component could produce. + """ + sources: list[Source] = [] + for entry in payload or []: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "") + if not name or name.endswith(".monitor"): + continue + properties = entry.get("properties") or {} + if str(properties.get("device.class", "")).lower() == "monitor": + continue + sources.append(Source( + index=int(entry.get("index", -1)), + name=name, + description=str(entry.get("description") or properties.get("device.description") or name), + )) + return sources + + +def parse_capture_streams(payload) -> list[CaptureStream]: + """pactl's `list source-outputs` JSON -> the live recording streams.""" + streams: list[CaptureStream] = [] + for entry in payload or []: + if not isinstance(entry, dict): + continue + properties = entry.get("properties") or {} + application = str( + properties.get("application.name") + or properties.get("application.process.binary") + or "" + ) + source = entry.get("source") + streams.append(CaptureStream( + index=int(entry.get("index", -1)), + source=int(source) if isinstance(source, int) else -1, + application=application, + )) + return streams + + +def select_source(sources: list[Source], pattern: str) -> Source | None: + """The configured pattern -> one source, preferring the least surprising match. + + Exact `node.name` first, then a substring of the name, then a substring of the + human description. Ties inside a tier are resolved by lowest index (the order + pactl reports, which is stable within a boot) and logged, because a pattern that + matches two microphones is a configuration mistake the user should hear about + rather than a coin flip that lands differently after a reboot. + """ + pattern = (pattern or "").strip().lower() + if not pattern: + return None + exact = [s for s in sources if s.name.lower() == pattern] + by_name = [s for s in sources if pattern in s.name.lower()] + by_description = [s for s in sources if pattern in s.description.lower()] + for tier, label in ((exact, "exact name"), (by_name, "name"), (by_description, "description")): + if not tier: + continue + chosen = sorted(tier, key=lambda s: s.index)[0] + if len(tier) > 1: + log.warning("%r matches %d sources by %s (%s) — using %r", + pattern, len(tier), label, + ", ".join(s.name for s in tier), chosen.name) + return chosen + return None + + +def streams_to_move(streams: list[CaptureStream], applications: list[str], + target: Source) -> list[CaptureStream]: + """Which live capture streams belong to the configured apps and are on the wrong + source already. Streams already on the target are left alone: moving a stream that + is where it should be is a needless glitch in somebody's live audio.""" + wanted = [a.strip().lower() for a in applications if a.strip()] + if not wanted: + return [] + return [ + stream for stream in streams + if stream.source != target.index + and any(pattern in stream.application.lower() for pattern in wanted) + ] + + +# --- The four live commands. Everything above is pure and tested; these are not. ----- + +def list_sources() -> list[Source]: + return parse_sources(_pactl_json("list", "sources")) + + +def list_capture_streams() -> list[CaptureStream]: + return parse_capture_streams(_pactl_json("list", "source-outputs")) + + +def _run(*args: str) -> bool: + try: + result = subprocess.run(["pactl", *args], capture_output=True, text=True, + timeout=10, check=False) + except (OSError, subprocess.SubprocessError) as exc: + log.warning("pactl %s: %s", " ".join(args), exc) + return False + if result.returncode != 0: + log.warning("pactl %s failed: %s", " ".join(args), (result.stderr or "").strip()[:200]) + return False + return True + + +def set_default_source(source: Source) -> bool: + return _run("set-default-source", source.name) + + +def move_stream(stream: CaptureStream, target: Source) -> bool: + return _run("move-source-output", str(stream.index), target.name) + + +def current_default_source_name() -> str: + payload = _pactl_json("info") + if isinstance(payload, dict): + return str(payload.get("default_source_name") or "") + return "" + + +def main() -> int: + """`audio_sources.py` on its own prints what this desktop has, which is how you + fill in the source patterns in CoreSystemConfig.json.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + sources = list_sources() + if not sources: + print("No sources found — is pactl installed and a PipeWire session running?") + return 1 + default = current_default_source_name() + print("Audio inputs on this machine (any part of a name or description works as a") + print("`source` pattern in CoreSystemConfig.json):\n") + for source in sources: + marker = "*" if source.name == default else " " + print(f" {marker} {source.description}") + print(f" {source.name}") + print("\n* = current default") + streams = list_capture_streams() + if streams: + print("\nApplications recording right now:") + for stream in streams: + print(f" {stream.application or '(unnamed)'} (stream {stream.index}, source {stream.source})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mic-follow/desktop_agent.py b/mic-follow/desktop_agent.py new file mode 100755 index 0000000..e9c1c31 --- /dev/null +++ b/mic-follow/desktop_agent.py @@ -0,0 +1,329 @@ +#!/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()) diff --git a/mic-follow/generate.py b/mic-follow/generate.py new file mode 100755 index 0000000..eb25a90 --- /dev/null +++ b/mic-follow/generate.py @@ -0,0 +1,331 @@ +#!/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)) diff --git a/mic-follow/setup-client.sh b/mic-follow/setup-client.sh new file mode 100755 index 0000000..8fde203 --- /dev/null +++ b/mic-follow/setup-client.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Install the mic-follow agent for ONE client, on the machine that client is. +# +# mic-follow/setup-client.sh +# +# Run it on that person's desktop, from a checkout of this repo. It generates from +# CoreSystemConfig.json, installs the client config (mode 0600 — it holds the MQTT +# password) and a systemd --user unit, and starts it. +# +# A second person's desktop runs the same command with their own node_id. Nothing here +# is specific to one machine except the argument. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIR="${REPO_ROOT}/mic-follow" +# shellcheck source=/dev/null +source "${REPO_ROOT}/tools/lib/coreconfig.sh" + +NODE_ID="${1:-}" +[[ -n "$NODE_ID" ]] || core_die "usage: setup-client.sh (from mic_follow.clients in CoreSystemConfig.json)" + +core_load +[[ "$CORE_MIC_FOLLOW_ENABLED" == "true" ]] || core_die \ + "mic_follow.enabled is false in $(basename "$CORE_CONFIG_PATH")" + +core_log "Generating" +"${DIR}/generate.py" "$CORE_CONFIG_PATH" +GEN="${DIR}/generated/${NODE_ID}" +[[ -d "$GEN" ]] || core_die "no client '${NODE_ID}' in the config — have: $(ls -1 "${DIR}/generated" | grep -v '^ha-package$' | tr '\n' ' ')" + +core_log "Checking this machine can see its microphones" +if ! command -v pactl >/dev/null; then + core_warn "pactl is not installed — the agent cannot switch anything without it" + core_warn " Debian/Ubuntu: apt install pulseaudio-utils Arch: pacman -S libpulse" +else + "${DIR}/desktop_agent.py" --list-sources || core_warn "could not list audio sources" +fi + +core_log "Installing" +mkdir -p "$HOME/.config/mic-follow" "$HOME/.config/systemd/user" +install -m 600 "${GEN}/client.json" "$HOME/.config/mic-follow/client.json" +install -m 644 "${GEN}/mic-follow-${NODE_ID}.service" \ + "$HOME/.config/systemd/user/mic-follow.service" + +if ! systemctl --user daemon-reload 2>/dev/null; then + core_warn "systemctl --user is not available here — the unit is installed but not loaded" +elif systemctl --user enable --now mic-follow.service; then + echo " started — follow it with: journalctl --user -fu mic-follow" +else + core_warn "could not start it — try: systemctl --user status mic-follow" +fi + +cat </packages/, then reload YAML. + + [ ] identity: paste the line from + ${DIR}/generated/identity-toggles.env + into /opt/smart-home/identity/identity.env and restart identity. + Without it the Pebble app sees no toggles at all. + + [ ] Stream Dock: bind the toggle key as described in + ${DIR}/generated/dock-bindings.md + + [ ] Check the presence entity actually reports area_ids: + Developer Tools -> States -> the presence_entity for this client. + If its state is a friendly room name rather than an area_id, the automation + will never match and the microphone will never move — see README.md section 3. + +EOF diff --git a/mic-follow/test_selection.py b/mic-follow/test_selection.py new file mode 100755 index 0000000..746bfbf --- /dev/null +++ b/mic-follow/test_selection.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Fixture tests for the audio layer's parsing and selection. + + python3 mic-follow/test_selection.py + +The commands that CHANGE state cannot be tested without a real PipeWire session; what +can be tested is everything that decides which device those commands are pointed at, +and that is where the damaging mistakes live — picking a monitor source (transmitting +what the desktop is playing instead of what the person is saying), or picking a +different microphone after a reboot because two matched and the order changed. + +The fixtures are hand-written in pactl's documented JSON shape. VERIFY THEM against +real output (`pactl -f json list sources`) the first time this runs on the desktop — +they are the assumption this whole component rests on. +""" + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import audio_sources as audio + +SOURCES = [ + { + "index": 44, + "name": "alsa_output.pci-0000_0c_00.4.analog-stereo.monitor", + "description": "Monitor of Family 17h HD Audio", + "properties": {"device.class": "monitor"}, + }, + { + "index": 46, + "name": "alsa_input.usb-Shure_Inc_MV6-00.mono-fallback", + "description": "MV6 Mono", + "properties": {"device.class": "sound"}, + }, + { + "index": 51, + "name": "bluez_input.AC:12:2F:9B:00:01.headset-head-unit", + "description": "Arctis Nova 7 (headset mic)", + "properties": {"device.class": "sound"}, + }, + { + "index": 58, + "name": "alsa_input.usb-DJI_MIC_MINI-00.mono-fallback", + "description": "DJI MIC MINI Mono", + "properties": {"device.class": "sound"}, + }, +] + +STREAMS = [ + {"index": 12, "source": 46, "properties": {"application.name": "Discord"}}, + {"index": 13, "source": 46, "properties": {"application.name": "obs"}}, + {"index": 14, "source": 58, "properties": {"application.process.binary": "Discord"}}, +] + + +class ParsingTests(unittest.TestCase): + def test_monitors_are_dropped(self): + names = [s.name for s in audio.parse_sources(SOURCES)] + self.assertNotIn("alsa_output.pci-0000_0c_00.4.analog-stereo.monitor", names) + self.assertEqual(len(names), 3) + + def test_monitor_dropped_by_property_even_without_the_suffix(self): + odd = [{"index": 1, "name": "weird_monitor_name", "description": "x", + "properties": {"device.class": "Monitor"}}] + self.assertEqual(audio.parse_sources(odd), []) + + def test_description_falls_back_to_the_name(self): + bare = [{"index": 2, "name": "some.source", "properties": {}}] + self.assertEqual(audio.parse_sources(bare)[0].description, "some.source") + + def test_capture_streams_read_either_application_property(self): + streams = audio.parse_capture_streams(STREAMS) + self.assertEqual([s.application for s in streams], ["Discord", "obs", "Discord"]) + + def test_garbage_does_not_raise(self): + self.assertEqual(audio.parse_sources(None), []) + self.assertEqual(audio.parse_sources(["not a dict"]), []) + self.assertEqual(audio.parse_capture_streams(None), []) + + +class SelectionTests(unittest.TestCase): + def setUp(self): + self.sources = audio.parse_sources(SOURCES) + + def test_substring_of_the_description(self): + self.assertEqual(audio.select_source(self.sources, "MV6").index, 46) + + def test_substring_of_the_node_name(self): + self.assertEqual(audio.select_source(self.sources, "DJI_MIC").index, 58) + + def test_case_insensitive(self): + self.assertEqual(audio.select_source(self.sources, "dji mic mini").index, 58) + + def test_exact_name_wins_over_a_substring_of_another(self): + sources = self.sources + [audio.Source(70, "mv6", "Something else entirely")] + self.assertEqual(audio.select_source(sources, "mv6").index, 70) + + def test_ambiguous_match_is_deterministic(self): + # Two headsets, one pattern: the answer must be the same after a reboot, not + # whichever pactl happened to list first. + sources = self.sources + [audio.Source(9, "bluez_input.OTHER.headset", "Other headset mic")] + self.assertEqual(audio.select_source(sources, "headset").index, 9) + + def test_no_match_is_none_not_a_guess(self): + self.assertIsNone(audio.select_source(self.sources, "rode wireless")) + + def test_empty_pattern_is_none(self): + self.assertIsNone(audio.select_source(self.sources, " ")) + + def test_a_monitor_can_never_be_selected(self): + # The pattern below matches the monitor's description and nothing else; the + # right answer is None, never "transmit the desktop's audio". + self.assertIsNone(audio.select_source(self.sources, "Monitor of Family")) + + +class StreamMoveTests(unittest.TestCase): + def setUp(self): + self.streams = audio.parse_capture_streams(STREAMS) + self.dji = audio.Source(58, "alsa_input.usb-DJI_MIC_MINI-00.mono-fallback", "DJI") + self.mv6 = audio.Source(46, "alsa_input.usb-Shure_Inc_MV6-00.mono-fallback", "MV6") + + def test_only_configured_applications_move(self): + moved = audio.streams_to_move(self.streams, ["Discord"], self.dji) + self.assertEqual([s.index for s in moved], [12]) + + def test_streams_already_on_the_target_are_left_alone(self): + moved = audio.streams_to_move(self.streams, ["Discord"], self.mv6) + self.assertEqual([s.index for s in moved], [14]) + + def test_no_applications_configured_moves_nothing(self): + self.assertEqual(audio.streams_to_move(self.streams, [], self.dji), []) + + def test_matching_is_case_insensitive_and_partial(self): + moved = audio.streams_to_move(self.streams, ["discord"], self.dji) + self.assertEqual([s.index for s in moved], [12]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/pebble-presence/README.md b/pebble-presence/README.md index 9efea08..8541f61 100644 --- a/pebble-presence/README.md +++ b/pebble-presence/README.md @@ -7,6 +7,28 @@ disagree, one of them is lying. Designed in [`docs/pebble-presence-watchface.md`](../docs/pebble-presence-watchface.md); this is the build. +## The toggles screen + +Long-press **Select** on the plan screen. It shows one Home Assistant switch per line — +today that means `mic-follow`'s Follow-me microphone switches (`../mic-follow/`) — with +**the live microphone's name in large type**, because standing on a balcony the question +is not "is the switch on" but "what am I being heard through right now". Select flips it; +Up/Down cycles if there is more than one client. + +It goes through `identity`'s `/toggles`, not straight at Home Assistant: identity already +holds an HA token and this phone already holds identity's, so one button on a wrist does +not put an HA admin token into a watchapp's settings. identity serves an **allowlist** — +`switch.*` entities only, on/off/toggle only, anything else is a 404. + +The states are asked for when the screen opens rather than cached, because the switch can +be flipped from the Stream Dock, from a dashboard, or by the automation itself; anything +the watch remembered from last time would be a guess. A press shows `…` until the phone +confirms, so a button that appears to do nothing does not get pressed twice. + +**The toggles line is plain text, not the binary plan format** — see WIREFORMAT.md's +closing section for why, and note that it is round-trip tested the same way. + + ## It is an app, not a face Watchfaces get **no button events** (Select opens the app menu, Up/Down are system diff --git a/pebble-presence/WIREFORMAT.md b/pebble-presence/WIREFORMAT.md index 204581d..ccf3197 100644 --- a/pebble-presence/WIREFORMAT.md +++ b/pebble-presence/WIREFORMAT.md @@ -65,3 +65,28 @@ The writer builds the payload and, if it exceeds the inbox size, sheds in this o It never silently sends a partial structure. A payload that decodes half-way is worse than one that does not arrive, because the watch cannot tell the difference between "three rooms" and "three rooms and then the buffer ran out". + +## What is NOT in this format: the toggles line + +The toggles screen (README.md) sends its own message, as plain text: + +``` +name|1|Loggia\nname|0|Desk +``` + +One record per line, three fields: name, `1`/`0` for on, and the detail string the watch +displays. The watch replies with the row INDEX it was looking at, never an id, so no +identifier string has to be stored on the watch or copied back. + +It is deliberately not squeezed into the binary format above. That format exists because +a floorplan does not otherwise fit in a ~2 KB AppMessage; a handful of short strings with +no geometry gains nothing from it and would lose the readability that makes the format +worth auditing. What the two share is the discipline: the packer (`src/pkjs/index.js`) +and the parser (`src/c/main.c`) are round-tripped against each other in +`test/run-tests.sh`, including the two cases that would otherwise decode as plausible +garbage — a value containing the `|` separator, and a record truncated mid-way. + +Field lengths are capped by the writer to the C buffers' sizes (`TOGGLE_NAME_LEN`, +`TOGGLE_DETAIL_LEN`), so truncation happens once, on the phone, rather than differently +on each side. + diff --git a/pebble-presence/package.json b/pebble-presence/package.json index 2d58ce7..68427d1 100644 --- a/pebble-presence/package.json +++ b/pebble-presence/package.json @@ -15,7 +15,9 @@ }, "messageKeys": [ "PLAN", - "STATUS" + "STATUS", + "TOGGLES", + "TOGGLE_SET" ], "resources": { "media": [] diff --git a/pebble-presence/src/c/main.c b/pebble-presence/src/c/main.c index b447e70..1a79bd7 100644 --- a/pebble-presence/src/c/main.c +++ b/pebble-presence/src/c/main.c @@ -38,6 +38,22 @@ #define MAX_UNPLACED 8 #define NAME_LEN 25 +// --- Toggles screen --------------------------------------------------------------- +// A second thing this watchapp does: flip one Home Assistant switch and show what it +// is currently doing. It exists for mic-follow (see mic-follow/README.md) — walking +// out for a cigarette is exactly the moment your hands are busy and your phone is in +// another room, and the watch is already on your wrist. +// +// The toggles arrive as PLAIN TEXT, not through the binary plan format: they are a +// handful of short strings with no geometry, and putting them through a format whose +// whole justification is squeezing a floorplan into 2 KB would buy nothing and cost +// the round-trip test its clarity. See WIREFORMAT.md's closing section. +#define MAX_TOGGLES 4 +#define TOGGLE_NAME_LEN 22 +#define TOGGLE_DETAIL_LEN 16 +// Sent as the index to flip; 255 means "just send me the current states". +#define TOGGLE_REFRESH 255 + // identity's PERSON_COLORS, same order as the JS table. Every value sits on the // 2-bit-per-channel lattice a colour Pebble renders natively, so what is drawn here is // byte-identical to the swatch in the admin panel rather than a dithered approximation. @@ -82,6 +98,21 @@ static bool s_have_plan; static time_t s_updated_at; static char s_status[64]; +typedef struct { + char name[TOGGLE_NAME_LEN]; + char detail[TOGGLE_DETAIL_LEN]; + bool on; +} Toggle; + +static Toggle s_toggles[MAX_TOGGLES]; +static uint8_t s_toggle_count; +static uint8_t s_toggle_index; +// Set while a press is in flight, cleared when the phone sends new states. Without it +// the screen keeps showing the old value for the second or two the round trip takes, +// which reads as a button that did nothing — and the second press is how you end up +// back where you started. +static bool s_toggle_pending; + // Which room the detail window is showing. s_room_count means the "somewhere in the // house" entry, which is deliberately part of the same cycle — the people the system // cannot place are exactly who you picked the watch up to find. @@ -91,6 +122,8 @@ static Window *s_plan_window; static Layer *s_plan_layer; static Window *s_detail_window; static Layer *s_detail_layer; +static Window *s_toggle_window; +static Layer *s_toggle_layer; static const uint32_t PERSIST_PLAN = 1; @@ -409,6 +442,150 @@ static void detail_load(Window *window) { static void detail_unload(Window *window) { layer_destroy(s_detail_layer); } +// --- toggles -------------------------------------------------------------------------- +// "name|1|Loggia\nname|0|Desk" — one record per line, three fields. The id is not sent: +// the watch replies with an INDEX and the phone maps it back, so no identifier string +// has to be stored on the watch or copied back over AppMessage. +static void parse_toggles(const char *packed) { + s_toggle_count = 0; + if (!packed) return; + + const char *cursor = packed; + while (*cursor && s_toggle_count < MAX_TOGGLES) { + Toggle *toggle = &s_toggles[s_toggle_count]; + toggle->name[0] = 0; + toggle->detail[0] = 0; + toggle->on = false; + + for (uint8_t field = 0; field < 3; field++) { + char *out = NULL; + size_t cap = 0; + if (field == 0) { out = toggle->name; cap = TOGGLE_NAME_LEN; } + if (field == 2) { out = toggle->detail; cap = TOGGLE_DETAIL_LEN; } + + size_t written = 0; + while (*cursor && *cursor != '|' && *cursor != '\n') { + if (field == 1) { + // The state field is one character; anything but '1' is off, which keeps a + // truncated or garbled message showing "off" rather than claiming a + // microphone is following you when it is not. + if (*cursor == '1') toggle->on = true; + } else if (out && written + 1 < cap) { + out[written++] = *cursor; + } + cursor++; + } + if (out) out[written] = 0; + if (*cursor == '|') cursor++; + else break; + } + while (*cursor && *cursor != '\n') cursor++; + if (*cursor == '\n') cursor++; + s_toggle_count++; + } + if (s_toggle_index >= s_toggle_count) s_toggle_index = 0; +} + +static void draw_toggles(Layer *layer, GContext *ctx) { + GRect bounds = layer_get_bounds(layer); + graphics_context_set_fill_color(ctx, GColorBlack); + graphics_fill_rect(ctx, bounds, 0, GCornerNone); + GRect area = grect_inset(bounds, GEdgeInsets(10)); + + if (s_toggle_count == 0) { + graphics_context_set_text_color(ctx, GColorWhite); + graphics_draw_text(ctx, s_toggle_pending ? "Asking…" : "No toggles configured", + fonts_get_system_font(FONT_KEY_GOTHIC_18), + GRect(area.origin.x, area.origin.y + 50, area.size.w, 44), + GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); + return; + } + + Toggle *toggle = &s_toggles[s_toggle_index]; + + // Name, small, at the top: on a one-toggle household you already know what it is. + graphics_context_set_text_color(ctx, GColorLightGray); + graphics_draw_text(ctx, toggle->name, fonts_get_system_font(FONT_KEY_GOTHIC_18), + GRect(area.origin.x, area.origin.y + 2, area.size.w, 22), + GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); + + // The live microphone, big, in the middle. THIS is what the screen is for: not + // "is the switch on" but "what am I being heard through right now", which is the + // question you actually have standing on a balcony. + graphics_context_set_text_color(ctx, GColorWhite); + graphics_draw_text(ctx, toggle->detail[0] ? toggle->detail : "—", + fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), + GRect(area.origin.x, area.origin.y + 30, area.size.w, 34), + GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); + + // And the state as a word, in colour, because "FOLLOWING" and "DESK ONLY" are + // readable at a glance in a way a switch icon is not. + const char *state_word = s_toggle_pending ? "…" : (toggle->on ? "FOLLOWING" : "DESK ONLY"); + graphics_context_set_fill_color(ctx, toggle->on ? GColorGreen : GColorDarkGray); + GRect pill = GRect(area.origin.x + 10, area.origin.y + 72, area.size.w - 20, 26); + graphics_fill_rect(ctx, pill, 6, GCornersAll); + graphics_context_set_text_color(ctx, toggle->on ? GColorBlack : GColorWhite); + graphics_draw_text(ctx, state_word, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), + GRect(pill.origin.x, pill.origin.y + 2, pill.size.w, 22), + GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); + + graphics_context_set_text_color(ctx, GColorLightGray); + char footer[24]; + if (s_toggle_count > 1) { + snprintf(footer, sizeof(footer), "%d/%d ▲▼ SELECT", s_toggle_index + 1, s_toggle_count); + } else { + snprintf(footer, sizeof(footer), "SELECT to switch"); + } + graphics_draw_text(ctx, footer, fonts_get_system_font(FONT_KEY_GOTHIC_14), + GRect(area.origin.x, area.origin.y + 104, area.size.w, 20), + GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); +} + +static void send_toggle(uint8_t index) { + DictionaryIterator *out; + if (app_message_outbox_begin(&out) != APP_MSG_OK) return; + dict_write_uint8(out, MESSAGE_KEY_TOGGLE_SET, index); + app_message_outbox_send(); +} + +static void toggle_press(ClickRecognizerRef recognizer, void *context) { + if (s_toggle_count == 0 || s_toggle_pending) return; + s_toggle_pending = true; + send_toggle(s_toggle_index); + layer_mark_dirty(s_toggle_layer); +} + +static void toggle_cycle(int delta) { + if (s_toggle_count <= 1) return; + s_toggle_index = (uint8_t)((s_toggle_index + delta + s_toggle_count) % s_toggle_count); + layer_mark_dirty(s_toggle_layer); +} + +static void toggle_up(ClickRecognizerRef recognizer, void *context) { toggle_cycle(-1); } +static void toggle_down(ClickRecognizerRef recognizer, void *context) { toggle_cycle(1); } + +static void toggle_click_config(void *context) { + window_single_click_subscribe(BUTTON_ID_SELECT, toggle_press); + window_single_click_subscribe(BUTTON_ID_UP, toggle_up); + window_single_click_subscribe(BUTTON_ID_DOWN, toggle_down); +} + +static void toggle_load(Window *window) { + s_toggle_layer = layer_create(layer_get_bounds(window_get_root_layer(window))); + layer_set_update_proc(s_toggle_layer, draw_toggles); + layer_add_child(window_get_root_layer(window), s_toggle_layer); + // Ask on entry rather than caching: the switch can be flipped from the dock, from a + // dashboard, or by the automation itself, so anything the watch remembers from last + // time is a guess. + s_toggle_pending = true; + send_toggle(TOGGLE_REFRESH); +} + +static void toggle_unload(Window *window) { + layer_destroy(s_toggle_layer); + s_toggle_layer = NULL; +} + static void plan_select(ClickRecognizerRef recognizer, void *context) { if (!s_have_plan || s_room_count == 0) return; s_detail_index = 0; @@ -427,8 +604,15 @@ static void plan_refresh(ClickRecognizerRef recognizer, void *context) { layer_mark_dirty(s_plan_layer); } +// Long-press Select rather than another short press: the plan screen's short Select +// already opens the room list, and that is the thing people came here for. +static void plan_toggles(ClickRecognizerRef recognizer, void *context) { + window_stack_push(s_toggle_window, true); +} + static void plan_click_config(void *context) { window_single_click_subscribe(BUTTON_ID_SELECT, plan_select); + window_long_click_subscribe(BUTTON_ID_SELECT, 0, plan_toggles, NULL); window_single_click_subscribe(BUTTON_ID_UP, plan_refresh); window_single_click_subscribe(BUTTON_ID_DOWN, plan_refresh); } @@ -455,8 +639,15 @@ static void inbox_received(DictionaryIterator *iterator, void *context) { } } + Tuple *toggles = dict_find(iterator, MESSAGE_KEY_TOGGLES); + if (toggles && toggles->type == TUPLE_CSTRING) { + parse_toggles(toggles->value->cstring); + s_toggle_pending = false; + } + layer_mark_dirty(s_plan_layer); if (s_detail_layer) layer_mark_dirty(s_detail_layer); + if (s_toggle_layer) layer_mark_dirty(s_toggle_layer); } static void inbox_dropped(AppMessageResult reason, void *context) { @@ -489,6 +680,10 @@ static void init(void) { window_set_window_handlers(s_detail_window, (WindowHandlers){ .load = detail_load, .unload = detail_unload }); window_set_click_config_provider(s_detail_window, detail_click_config); + s_toggle_window = window_create(); + window_set_window_handlers(s_toggle_window, (WindowHandlers){ .load = toggle_load, .unload = toggle_unload }); + window_set_click_config_provider(s_toggle_window, toggle_click_config); + app_message_register_inbox_received(inbox_received); app_message_register_inbox_dropped(inbox_dropped); // Inbox large enough for the whole plan; outbox tiny, because the only thing this @@ -501,6 +696,7 @@ static void init(void) { static void deinit(void) { window_destroy(s_plan_window); window_destroy(s_detail_window); + window_destroy(s_toggle_window); } int main(void) { diff --git a/pebble-presence/src/pkjs/index.js b/pebble-presence/src/pkjs/index.js index e296f14..ecf73a6 100644 --- a/pebble-presence/src/pkjs/index.js +++ b/pebble-presence/src/pkjs/index.js @@ -261,8 +261,119 @@ function refresh() { request.send(); } +// --- toggles --------------------------------------------------------------------------- +// The watch's second screen: flip one Home Assistant switch, and show what it is +// currently doing. It goes through identity's /toggles rather than straight at Home +// Assistant, because identity already holds an HA token and this phone already holds +// identity's — putting an HA admin token in a watchapp's settings to press one button +// would be the worst credential trade in the household. identity only serves an +// allowlist, so this can never reach anything but the switches somebody configured. +// +// PACKED AS TEXT, not through the binary plan format: a few short strings with no +// geometry gain nothing from a format built to squeeze a floorplan into 2 KB. +// +// name|1|Loggia\nname|0|Desk +// +// Field and record separators are stripped from the values below, because a microphone +// called "Desk | Loft" would otherwise silently become two fields. The slices match the +// C buffers in main.c (TOGGLE_NAME_LEN / TOGGLE_DETAIL_LEN, minus the terminator) so +// truncation happens here, once, rather than differently on each side. +var MAX_TOGGLES = 4; + +function packToggles(toggles) { + return (toggles || []) + .slice(0, MAX_TOGGLES) + .map(function (toggle) { + var name = String(toggle.name || "").replace(/[|\n\r]/g, " ").slice(0, 21); + var detail = String(toggle.detail || "").replace(/[|\n\r]/g, " ").slice(0, 15); + return name + "|" + (toggle.on ? "1" : "0") + "|" + detail; + }) + .join("\n"); +} + +// index -> identity's toggle id. The watch never holds an id: it replies with the row +// it was looking at, and the mapping stays here. +var toggleIds = []; + +function sendToggles(packed) { + Pebble.sendAppMessage({ TOGGLES: packed }, function () {}, function (e) { + console.log("presence: toggle send failed: " + JSON.stringify(e)); + }); +} + +function refreshToggles() { + var settings = config(); + if (!settings.apiUrl || !settings.token) { + sendToggles(""); + return; + } + var request = new XMLHttpRequest(); + request.open("GET", settings.apiUrl + "/toggles", true); + request.setRequestHeader("Authorization", "Bearer " + settings.token); + request.timeout = 12000; + request.onload = function () { + if (request.status !== 200) { + sendToggles(""); + return; + } + var toggles = []; + try { + toggles = (JSON.parse(request.responseText) || {}).toggles || []; + } catch (e) { + toggles = []; + } + toggleIds = toggles.map(function (toggle) { + return toggle.id; + }); + sendToggles(packToggles(toggles)); + }; + request.ontimeout = function () { + sendToggles(""); + }; + request.onerror = function () { + sendToggles(""); + }; + request.send(); +} + +function pressToggle(index) { + var settings = config(); + var id = toggleIds[index]; + if (!settings.apiUrl || !settings.token || !id) { + refreshToggles(); + return; + } + var request = new XMLHttpRequest(); + request.open("POST", settings.apiUrl + "/toggles/" + encodeURIComponent(id), true); + request.setRequestHeader("Authorization", "Bearer " + settings.token); + request.setRequestHeader("Content-Type", "application/json"); + request.timeout = 12000; + // The response already carries the new state, so the watch stops saying "…" as soon + // as the switch has actually flipped. The second refresh a moment later is for the + // DETAIL: the microphone name only changes once the desktop agent has actually moved + // the input, which is a beat behind the switch. + request.onload = function () { + refreshToggles(); + setTimeout(refreshToggles, 2500); + }; + request.ontimeout = refreshToggles; + request.onerror = refreshToggles; + request.send(JSON.stringify({ action: "toggle" })); +} + Pebble.addEventListener("ready", refresh); -Pebble.addEventListener("appmessage", refresh); // the watch asking for a refresh +Pebble.addEventListener("appmessage", function (event) { + var payload = (event && event.payload) || {}; + if (Object.prototype.hasOwnProperty.call(payload, "TOGGLE_SET")) { + // 255 is "just tell me the current states" — sent when the toggles screen opens, + // because the switch may have been flipped from the dock or by the automation + // since the watch last looked. + if (payload.TOGGLE_SET === 255) refreshToggles(); + else pressToggle(payload.TOGGLE_SET); + return; + } + refresh(); // the watch asking for a plan refresh +}); Pebble.addEventListener("showConfiguration", function () { var settings = config(); @@ -308,5 +419,11 @@ Pebble.addEventListener("webviewclosed", function (event) { // undefined — and worth having, because the writer and the C reader agreeing is the one // thing in this app that cannot be checked by looking at it. if (typeof module !== "undefined") { - module.exports = { build: build, buildWithinBudget: buildWithinBudget, projector: projector, utf8: utf8 }; + module.exports = { + build: build, + buildWithinBudget: buildWithinBudget, + projector: projector, + utf8: utf8, + packToggles: packToggles, + }; } diff --git a/pebble-presence/test/pebble_stub.h b/pebble-presence/test/pebble_stub.h index d0107c5..6a6f15a 100644 --- a/pebble-presence/test/pebble_stub.h +++ b/pebble-presence/test/pebble_stub.h @@ -32,6 +32,7 @@ static inline GColor GColorFromRGB(int r, int g, int b) { (void)r; (void)g; (voi #define GColorLightGray ((GColor){2}) #define GColorDarkGray ((GColor){3}) #define GColorOxfordBlue ((GColor){4}) +#define GColorGreen ((GColor){5}) typedef void GContext; typedef struct Layer Layer; @@ -41,13 +42,16 @@ typedef struct { void (*load)(Window *); void (*unload)(Window *); } WindowHandl typedef enum { GTextOverflowModeFill, GTextOverflowModeWordWrap, GTextOverflowModeTrailingEllipsis } GTextOverflowMode; typedef enum { GTextAlignmentLeft, GTextAlignmentCenter } GTextAlignment; -typedef enum { GCornerNone } GCornerMask; +typedef enum { GCornerNone, GCornersAll } GCornerMask; typedef enum { BUTTON_ID_UP, BUTTON_ID_DOWN, BUTTON_ID_SELECT } ButtonId; typedef enum { APP_MSG_OK = 0 } AppMessageResult; #define FONT_KEY_GOTHIC_14_BOLD "g14b" #define FONT_KEY_GOTHIC_18 "g18" #define FONT_KEY_GOTHIC_24_BOLD "g24b" +#define FONT_KEY_GOTHIC_14 "g14" +#define FONT_KEY_GOTHIC_18_BOLD "g18b" +#define FONT_KEY_GOTHIC_28_BOLD "g28b" typedef void *GFont; static inline GFont fonts_get_system_font(const char *k) { (void)k; return NULL; } @@ -89,6 +93,11 @@ static inline void window_set_window_handlers(Window *w, WindowHandlers h) { (vo static inline void window_set_click_config_provider(Window *w, void (*p)(void *)) { (void)w; (void)p; } static inline void window_stack_push(Window *w, bool a) { (void)w; (void)a; } static inline void window_single_click_subscribe(ButtonId b, void (*h)(ClickRecognizerRef, void *)) { (void)b; (void)h; } +static inline void window_long_click_subscribe(ButtonId b, uint16_t ms, + void (*down)(ClickRecognizerRef, void *), + void (*up)(ClickRecognizerRef, void *)) { + (void)b; (void)ms; (void)down; (void)up; +} typedef struct { int unused; } DictionaryIterator; typedef struct { @@ -99,6 +108,8 @@ typedef struct { #define TUPLE_BYTE_ARRAY 2 #define MESSAGE_KEY_PLAN 1 #define MESSAGE_KEY_STATUS 2 +#define MESSAGE_KEY_TOGGLES 3 +#define MESSAGE_KEY_TOGGLE_SET 4 static inline Tuple *dict_find(DictionaryIterator *i, uint32_t k) { (void)i; (void)k; return NULL; } static inline int dict_write_uint8(DictionaryIterator *i, uint32_t k, uint8_t v) { (void)i; (void)k; (void)v; return 0; } static inline int app_message_outbox_begin(DictionaryIterator **i) { (void)i; return APP_MSG_OK; } diff --git a/pebble-presence/test/run-tests.sh b/pebble-presence/test/run-tests.sh index 7d40fd0..43c3d36 100755 --- a/pebble-presence/test/run-tests.sh +++ b/pebble-presence/test/run-tests.sh @@ -9,5 +9,17 @@ node make-payload.js > /tmp/pebble-payload.hex echo "--- compiling the C reader against the stub SDK ---" cc -std=c11 -I. -Wall -Wextra -Wno-unused-parameter -Wno-unused-function \ -o /tmp/wireformat_test wireformat_test.c +echo "--- packing a toggles line with the real JS packer ---" +node -e ' + global.Pebble = { addEventListener() {} }; + global.localStorage = { getItem: () => null, setItem() {} }; + global.XMLHttpRequest = function () {}; + const js = require("../src/pkjs/index.js"); + process.stdout.write(js.packToggles([ + { name: "Amir\u2019s desktop".replace("\u2019", "\x27"), on: true, detail: "Loggia" }, + { name: "A very long client name that will not fit", on: false, detail: "Living room mic here" }, + { name: "Weird | name", on: true, detail: "Desk" }, + ])); +' > /tmp/pebble-toggles.txt echo "--- decoding ---" -/tmp/wireformat_test < /tmp/pebble-payload.hex +/tmp/wireformat_test /tmp/pebble-toggles.txt < /tmp/pebble-payload.hex diff --git a/pebble-presence/test/wireformat_test.c b/pebble-presence/test/wireformat_test.c index 645eea1..d7b8cbb 100644 --- a/pebble-presence/test/wireformat_test.c +++ b/pebble-presence/test/wireformat_test.c @@ -23,7 +23,46 @@ static void check(const char *label, bool ok) { if (!ok) failures++; } -int main(void) { +/* The toggles line is a second format shared by the same two implementations — packed + in src/pkjs/index.js, parsed in src/c/main.c — so it gets the same treatment as the + plan: the JS writes it, this reads it, and a disagreement is a failed test rather + than a watch screen that quietly says the wrong microphone is live. */ +static void check_toggles(const char *path) { + FILE *file = fopen(path, "rb"); + if (!file) { printf(" FAIL could not open %s\n", path); failures++; return; } + static char packed[512]; + size_t length = fread(packed, 1, sizeof(packed) - 1, file); + packed[length] = 0; + fclose(file); + + parse_toggles(packed); + check("three toggles parsed", s_toggle_count == 3); + if (s_toggle_count != 3) return; + + check("first name survives an apostrophe", strcmp(s_toggles[0].name, "Amir's desktop") == 0); + check("first is on", s_toggles[0].on); + check("first detail is the live mic", strcmp(s_toggles[0].detail, "Loggia") == 0); + + /* The JS truncates to the C buffer sizes, so a long name must arrive already short + rather than being cut differently on each side. */ + check("long name truncated by the writer", strlen(s_toggles[1].name) == 21); + check("second is off", !s_toggles[1].on); + check("long detail truncated by the writer", strlen(s_toggles[1].detail) == 15); + + /* A value containing the field separator would split into extra fields; the writer + strips them, and this is what proves it still does. */ + check("separators stripped from values", strcmp(s_toggles[2].name, "Weird name") == 0); + check("third detail intact", strcmp(s_toggles[2].detail, "Desk") == 0); + + parse_toggles(""); + check("empty payload means no toggles", s_toggle_count == 0); + + parse_toggles("Half a record|1"); + check("truncated record still yields a safe row", + s_toggle_count == 1 && s_toggles[0].on && s_toggles[0].detail[0] == 0); +} + +int main(int argc, char **argv) { static uint8_t buffer[4096]; uint16_t length = 0; unsigned int byte; @@ -67,6 +106,11 @@ int main(void) { buffer[0] = 99; check("wrong version refused", !parse_plan(buffer, length)); + if (argc > 1) { + printf("\ntoggles line:\n"); + check_toggles(argv[1]); + } + printf(failures ? "\nFAILURES: %d\n" : "\nall wire-format checks passed\n", failures); return failures ? 1 : 0; } diff --git a/tools/config-export.py b/tools/config-export.py index 9644382..e12379f 100755 --- a/tools/config-export.py +++ b/tools/config-export.py @@ -173,6 +173,31 @@ def main(argv: list[str]) -> int: emit("CORE_FREEIPA_VERIFY_TLS", ipa.get("verify_tls", True)) emit("CORE_FREEIPA_SYNC_INTERVAL_MINUTES", ipa.get("sync_interval_minutes", 60)) + # --- Follow-me microphone switching (mic-follow/) -------------------------------- + # The clients go out as ONE JSON blob, like the OPNsense list and for the same + # reason: it is a list of objects, and emitting scalars per client would mean + # inventing an index-based naming scheme that breaks the moment somebody reorders + # the file. mic-follow/generate.py reads the config directly; this exists so shell + # callers (setup-container-host.sh's identity block) can see the toggle allowlist + # without parsing JSON themselves. + mic_follow = cfg.get("mic_follow", {}) or {} + mic_clients = [c for c in (mic_follow.get("clients") or []) if isinstance(c, dict)] + emit("CORE_MIC_FOLLOW_ENABLED", mic_follow.get("enabled", False)) + emit("CORE_MIC_FOLLOW_JSON", json.dumps({"clients": mic_clients})) + # The switches identity is allowed to toggle on behalf of the watch, as + # entity_id:detail_entity_id pairs. An allowlist, not a capability: identity must + # not become a general-purpose Home Assistant proxy just because the Pebble app + # needed one button. + emit("CORE_TOGGLE_ALLOWLIST_JSON", json.dumps([ + { + "id": client.get("node_id", ""), + "name": client.get("friendly_name") or client.get("node_id", ""), + "switch_entity": f"switch.mic_follow_{client.get('node_id', '')}_armed", + "detail_entity": f"sensor.mic_follow_{client.get('node_id', '')}_status", + } + for client in mic_clients if client.get("node_id") + ] if mic_follow.get("enabled") else [])) + # --- Enable flags --- for flag, value in (cfg.get("container_host", {}).get("enable", {}) or {}).items(): if not flag.startswith("_"): diff --git a/tools/validate-config.py b/tools/validate-config.py index f75e151..8ad4aeb 100755 --- a/tools/validate-config.py +++ b/tools/validate-config.py @@ -728,6 +728,137 @@ def validate_stream_dock(cfg: dict, rep: Report) -> None: "checks first whether 'none' is already enough (stream-dock/README.md §5)") +# An HA entity_id, in the shape every consumer here assumes: one dot, lowercase. +ENTITY_ID_RE = re.compile(r"^[a-z_]+\.[a-z0-9_]+$") + +# A mic-follow client's node_id. It ends up inside every generated entity_id, a systemd +# unit name and an MQTT topic, so it is held to the strictest of those. +NODE_ID_RE = re.compile(r"^[a-z0-9_]+$") + +UNKNOWN_ROOM_POLICIES = {"hold", "desk"} + + +def validate_mic_follow(cfg: dict, rep: Report) -> None: + """Follow-me microphone switching, per client machine. + + The failure this validates against is specific: a client that switches to a + microphone nobody can name, in a room nobody can locate, and is discovered live in + front of five other people. Everything here is checked before that. + """ + section = cfg.get("mic_follow") + if section is None: + return + if not isinstance(section, dict): + rep.error("mic_follow", "must be an object") + return + if not section.get("enabled"): + return + + clients = section.get("clients") + if not isinstance(clients, list) or not clients: + rep.error("mic_follow.clients", + "enabled with no clients — a client is one desktop machine and the " + "one person it follows") + return + + seen_nodes: dict[str, int] = {} + for index, client in enumerate(clients): + where = f"mic_follow.clients[{index}]" + if not isinstance(client, dict): + rep.error(where, "must be an object") + continue + + node_id = client.get("node_id") + if not isinstance(node_id, str) or not NODE_ID_RE.match(node_id or ""): + rep.error(f"{where}.node_id", + f"{node_id!r} must be lowercase letters, digits and underscores — " + "it goes into entity_ids, an MQTT topic and a unit name") + elif node_id in seen_nodes: + rep.error(f"{where}.node_id", + f"'{node_id}' is already used by clients[{seen_nodes[node_id]}] — " + "two clients sharing a node_id share their entities, and each " + "would switch the other's microphone") + else: + seen_nodes[node_id] = index + + if not client.get("friendly_name"): + rep.warn(f"{where}.friendly_name", "missing — the HA device will be named by node_id") + + _check_room(client.get("room"), f"{where}.room", rep) + + presence = client.get("presence_entity") + if not isinstance(presence, str) or not ENTITY_ID_RE.match(presence or ""): + rep.error(f"{where}.presence_entity", + f"{presence!r} is not an entity_id. It must be an entity whose STATE " + "is an HA area_id — see mic_follow._locator_honesty") + elif _looks_like_placeholder(presence): + rep.warn(f"{where}.presence_entity", + f"'{presence}' looks like the template's example — check it against " + "Developer Tools -> States, and check that its state really is an " + "area_id and not a friendly room name") + + if not str(client.get("desk_source") or "").strip(): + rep.error(f"{where}.desk_source", + "missing — this is the microphone the client returns to whenever " + "follow-me is off, which is the one guarantee this component makes") + + for key, low, high, default in (("dwell_seconds", 0, 600, 20), + ("return_dwell_seconds", 0, 600, 5), + ("reconcile_seconds", 1, 300, 10)): + value = client.get(key, default) + if not isinstance(value, (int, float)) or isinstance(value, bool) or not low <= value <= high: + rep.error(f"{where}.{key}", f"must be a number between {low} and {high}, got {value!r}") + + policy = client.get("on_unknown_room", "hold") + if policy not in UNKNOWN_ROOM_POLICIES: + rep.error(f"{where}.on_unknown_room", + f"must be one of {sorted(UNKNOWN_ROOM_POLICIES)}, got {policy!r}") + + streams = client.get("move_streams", []) + if not isinstance(streams, list) or not all(isinstance(s, str) and s.strip() for s in streams): + rep.error(f"{where}.move_streams", "must be a list of application-name strings") + elif not streams: + rep.warn(f"{where}.move_streams", + "empty — only applications set to 'Default' input will follow the " + "switch, and anyone with a studio mic has picked theirs explicitly") + + sources = client.get("sources", []) + if not isinstance(sources, list): + rep.error(f"{where}.sources", "must be a list") + continue + if not sources: + rep.warn(f"{where}.sources", + "no remote microphones — follow-me has nowhere to switch to, so this " + "client can only ever be on its desk mic") + seen_rooms: dict[str, int] = {} + for source_index, source in enumerate(sources): + source_where = f"{where}.sources[{source_index}]" + if not isinstance(source, dict): + rep.error(source_where, "must be an object") + continue + room = _check_room(source.get("room"), f"{source_where}.room", rep) + if room and room in seen_rooms: + rep.error(f"{source_where}.room", + f"'{room}' is already mapped by sources[{seen_rooms[room]}] — " + "one room, one microphone") + elif room: + seen_rooms[room] = source_index + if room == client.get("room"): + rep.warn(f"{source_where}.room", + f"'{room}' is also this client's own room, so following the " + "person there switches away from the desk mic while they sit " + "at the desk") + if not str(source.get("source") or "").strip(): + rep.error(f"{source_where}.source", + "missing — run mic-follow/desktop_agent.py --list-sources on that " + "machine for the real strings") + if source.get("start_command") and not source.get("stop_command"): + rep.warn(f"{source_where}.stop_command", + "a start_command with no stop_command leaves whatever it started " + "running after the switch moves away — for a network microphone " + "that is a hot mic in another room") + + def validate(cfg: dict) -> Report: rep = Report() validate_network(cfg, rep) @@ -738,6 +869,7 @@ def validate(cfg: dict) -> Report: validate_kiosks(cfg, rep) validate_proxy(cfg, rep) validate_stream_dock(cfg, rep) + validate_mic_follow(cfg, rep) return rep