SmartestHome/mic-follow
Amir Alexander Abdelbaki cebdc9529b Follow-me microphone: the mic switches to the room you walked into
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_<node>_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7yp4NcmX4zqja1MKRjeWJ
2026-08-31 13:41:33 +02:00
..
README.md Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00
audio_sources.py Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00
desktop_agent.py Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00
generate.py Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00
setup-client.sh Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00
test_selection.py Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00

README.md

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_<node>_input
        │                (only while switch.mic_follow_<node>_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_<node>_status, whose state is the name of the microphone that is live right nowDesk, 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 <node_id>.
  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).