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
main
Amir Alexander Abdelbaki 2026-08-31 13:41:33 +02:00
parent 86fe981d7c
commit cebdc9529b
22 changed files with 2065 additions and 7 deletions

4
.gitignore vendored
View File

@ -125,3 +125,7 @@ proxy/ca/
# stream-dock/generate.py's output. led-sync.env holds the Home Assistant token, and # 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. # bindings.md names every light entity in the room — regenerate it, never commit it.
stream-dock/generated/ 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/

View File

@ -214,6 +214,42 @@
"wake_word": "ok_nabu" "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": { "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.", "_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.", "_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.",

View File

@ -83,6 +83,10 @@ chores/ Presence/calendar-driven household chore nudging +
passive fairness tally + camera-verified trash-bin/ passive fairness tally + camera-verified trash-bin/
dishes/litter checks (systemd-timed oneshot, no dishes/litter checks (systemd-timed oneshot, no
long-lived service, no LLM-picked assignment) 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 stream-dock/ Desk-side MiraBox N4 Pro (10 keys, 4 encoders) driving
one room's colour lamps through OpenDeck: an HA script one room's colour lamps through OpenDeck: an HA script
package for the relative-colour maths, generated dial/key 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 ## Status
- [x] Project plan drafted - [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_<node>_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 13 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` §57 - [ ] **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 13 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` §57
- [x] Container host setup script v1 (HA, Mosquitto, Zigbee2MQTT USB, Frigate, Grocy) - [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 - [x] Node-RED + monitoring (Netdata) + dashboard (Homepage) + ntfy + Portainer added to compose stack

View File

@ -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 `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 sections a run actually generates, and what a stopped `identity` does to a run) is
on that component's own verification list. on that component's own verification list.
## Watch-reachable toggles
`GET /toggles` and `POST /toggles/<id>` (`{"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.

View File

@ -119,3 +119,19 @@ IDENTITY_PHOTO_DIR=/data/photos
IDENTITY_FLOORPLAN_DIR=/data/floorplans IDENTITY_FLOORPLAN_DIR=/data/floorplans
IDENTITY_MAX_IMAGE_MB=15 IDENTITY_MAX_IMAGE_MB=15
LOG_LEVEL=INFO 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/<id>). 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=

View File

@ -144,6 +144,49 @@ PRESENT_STATES = {"home"}
# project; there's no floor plan or room list to design against yet. # project; there's no floor plan or room list to design against yet.
AREA_ATTRIBUTE = os.environ.get("AREA_ATTRIBUTE", "area_id") 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_IMAGE_BYTES = int(os.environ.get("IDENTITY_MAX_IMAGE_MB", "15")) * 1024 * 1024
MAX_JSON_BYTES = 32 * 1024 MAX_JSON_BYTES = 32 * 1024
@ -576,6 +619,85 @@ def _ha_get(path: str):
return json.loads(resp.read()) 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]: def _trusted_present_candidates() -> list[dict]:
"""Entity_ids matching TRUSTED_ENTITY_PREFIXES whose current HA state indicates """Entity_ids matching TRUSTED_ENTITY_PREFIXES whose current HA state indicates
presence right now. This is the ONLY source of registration candidates see the 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()) self._respond(HTTPStatus.OK, area_suggestions())
elif path == "/floorplan/sensors": elif path == "/floorplan/sensors":
self._respond(HTTPStatus.OK, list_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": elif path == "/person-colors":
# Served rather than duplicated in the admin panel's JS, so the palette # Served rather than duplicated in the admin panel's JS, so the palette
# has one definition — see PERSON_COLORS for what makes these eight # 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/<id> edit route so it is # /people/prune is checked before the bare /people/<id> edit route so it is
# never parsed as a person id (it can't be — it's not digits — but the ordering # never parsed as a person id (it can't be — it's not digits — but the ordering
# makes the intent explicit rather than incidental). # 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() self._handle_register_photo()
elif path == "/register": elif path == "/register":
self._handle_register() self._handle_register()
@ -3039,6 +3168,31 @@ class Handler(BaseHTTPRequestHandler):
ok = set_manual_presence(person_id, home) ok = set_manual_presence(person_id, home)
self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, {"ok": ok}) self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND, {"ok": ok})
def _handle_set_toggle(self, toggle_id: str) -> None:
"""POST /toggles/<id> {"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: def _handle_chore_settings(self, person_id: int) -> None:
payload = self._json_body() payload = self._json_body()
if payload is None: if payload is None:

133
mic-follow/README.md Normal file
View File

@ -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_<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 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 <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).

224
mic-follow/audio_sources.py Executable file
View File

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

329
mic-follow/desktop_agent.py Executable file
View File

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

331
mic-follow/generate.py Executable file
View File

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

75
mic-follow/setup-client.sh Executable file
View File

@ -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 <node_id>
#
# 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 <node_id> (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 <<EOF
Left to do, and none of it is on this machine:
[ ] Home Assistant: install the generated package
${DIR}/generated/ha-package/mic_follow.yaml
next to stream-dock's, in <HA config>/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

141
mic-follow/test_selection.py Executable file
View File

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

View File

@ -7,6 +7,28 @@ disagree, one of them is lying.
Designed in [`docs/pebble-presence-watchface.md`](../docs/pebble-presence-watchface.md); Designed in [`docs/pebble-presence-watchface.md`](../docs/pebble-presence-watchface.md);
this is the build. 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 ## It is an app, not a face
Watchfaces get **no button events** (Select opens the app menu, Up/Down are system Watchfaces get **no button events** (Select opens the app menu, Up/Down are system

View File

@ -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 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 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". "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.

View File

@ -15,7 +15,9 @@
}, },
"messageKeys": [ "messageKeys": [
"PLAN", "PLAN",
"STATUS" "STATUS",
"TOGGLES",
"TOGGLE_SET"
], ],
"resources": { "resources": {
"media": [] "media": []

View File

@ -38,6 +38,22 @@
#define MAX_UNPLACED 8 #define MAX_UNPLACED 8
#define NAME_LEN 25 #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 // 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 // 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. // 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 time_t s_updated_at;
static char s_status[64]; 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 // 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 // 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. // 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 Layer *s_plan_layer;
static Window *s_detail_window; static Window *s_detail_window;
static Layer *s_detail_layer; static Layer *s_detail_layer;
static Window *s_toggle_window;
static Layer *s_toggle_layer;
static const uint32_t PERSIST_PLAN = 1; 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); } 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) { static void plan_select(ClickRecognizerRef recognizer, void *context) {
if (!s_have_plan || s_room_count == 0) return; if (!s_have_plan || s_room_count == 0) return;
s_detail_index = 0; s_detail_index = 0;
@ -427,8 +604,15 @@ static void plan_refresh(ClickRecognizerRef recognizer, void *context) {
layer_mark_dirty(s_plan_layer); 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) { static void plan_click_config(void *context) {
window_single_click_subscribe(BUTTON_ID_SELECT, plan_select); 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_UP, plan_refresh);
window_single_click_subscribe(BUTTON_ID_DOWN, 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); layer_mark_dirty(s_plan_layer);
if (s_detail_layer) layer_mark_dirty(s_detail_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) { 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_window_handlers(s_detail_window, (WindowHandlers){ .load = detail_load, .unload = detail_unload });
window_set_click_config_provider(s_detail_window, detail_click_config); 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_received(inbox_received);
app_message_register_inbox_dropped(inbox_dropped); app_message_register_inbox_dropped(inbox_dropped);
// Inbox large enough for the whole plan; outbox tiny, because the only thing this // 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) { static void deinit(void) {
window_destroy(s_plan_window); window_destroy(s_plan_window);
window_destroy(s_detail_window); window_destroy(s_detail_window);
window_destroy(s_toggle_window);
} }
int main(void) { int main(void) {

View File

@ -261,8 +261,119 @@ function refresh() {
request.send(); 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("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 () { Pebble.addEventListener("showConfiguration", function () {
var settings = config(); 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 // 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. // thing in this app that cannot be checked by looking at it.
if (typeof module !== "undefined") { 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,
};
} }

View File

@ -32,6 +32,7 @@ static inline GColor GColorFromRGB(int r, int g, int b) { (void)r; (void)g; (voi
#define GColorLightGray ((GColor){2}) #define GColorLightGray ((GColor){2})
#define GColorDarkGray ((GColor){3}) #define GColorDarkGray ((GColor){3})
#define GColorOxfordBlue ((GColor){4}) #define GColorOxfordBlue ((GColor){4})
#define GColorGreen ((GColor){5})
typedef void GContext; typedef void GContext;
typedef struct Layer Layer; 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 { GTextOverflowModeFill, GTextOverflowModeWordWrap, GTextOverflowModeTrailingEllipsis } GTextOverflowMode;
typedef enum { GTextAlignmentLeft, GTextAlignmentCenter } GTextAlignment; 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 { BUTTON_ID_UP, BUTTON_ID_DOWN, BUTTON_ID_SELECT } ButtonId;
typedef enum { APP_MSG_OK = 0 } AppMessageResult; typedef enum { APP_MSG_OK = 0 } AppMessageResult;
#define FONT_KEY_GOTHIC_14_BOLD "g14b" #define FONT_KEY_GOTHIC_14_BOLD "g14b"
#define FONT_KEY_GOTHIC_18 "g18" #define FONT_KEY_GOTHIC_18 "g18"
#define FONT_KEY_GOTHIC_24_BOLD "g24b" #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; typedef void *GFont;
static inline GFont fonts_get_system_font(const char *k) { (void)k; return NULL; } 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_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_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_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 { int unused; } DictionaryIterator;
typedef struct { typedef struct {
@ -99,6 +108,8 @@ typedef struct {
#define TUPLE_BYTE_ARRAY 2 #define TUPLE_BYTE_ARRAY 2
#define MESSAGE_KEY_PLAN 1 #define MESSAGE_KEY_PLAN 1
#define MESSAGE_KEY_STATUS 2 #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 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 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; } static inline int app_message_outbox_begin(DictionaryIterator **i) { (void)i; return APP_MSG_OK; }

View File

@ -9,5 +9,17 @@ node make-payload.js > /tmp/pebble-payload.hex
echo "--- compiling the C reader against the stub SDK ---" echo "--- compiling the C reader against the stub SDK ---"
cc -std=c11 -I. -Wall -Wextra -Wno-unused-parameter -Wno-unused-function \ cc -std=c11 -I. -Wall -Wextra -Wno-unused-parameter -Wno-unused-function \
-o /tmp/wireformat_test wireformat_test.c -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 ---" echo "--- decoding ---"
/tmp/wireformat_test < /tmp/pebble-payload.hex /tmp/wireformat_test /tmp/pebble-toggles.txt < /tmp/pebble-payload.hex

View File

@ -23,7 +23,46 @@ static void check(const char *label, bool ok) {
if (!ok) failures++; 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]; static uint8_t buffer[4096];
uint16_t length = 0; uint16_t length = 0;
unsigned int byte; unsigned int byte;
@ -67,6 +106,11 @@ int main(void) {
buffer[0] = 99; buffer[0] = 99;
check("wrong version refused", !parse_plan(buffer, length)); 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); printf(failures ? "\nFAILURES: %d\n" : "\nall wire-format checks passed\n", failures);
return failures ? 1 : 0; return failures ? 1 : 0;
} }

View File

@ -173,6 +173,31 @@ def main(argv: list[str]) -> int:
emit("CORE_FREEIPA_VERIFY_TLS", ipa.get("verify_tls", True)) emit("CORE_FREEIPA_VERIFY_TLS", ipa.get("verify_tls", True))
emit("CORE_FREEIPA_SYNC_INTERVAL_MINUTES", ipa.get("sync_interval_minutes", 60)) 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 --- # --- Enable flags ---
for flag, value in (cfg.get("container_host", {}).get("enable", {}) or {}).items(): for flag, value in (cfg.get("container_host", {}).get("enable", {}) or {}).items():
if not flag.startswith("_"): if not flag.startswith("_"):

View File

@ -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)") "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: def validate(cfg: dict) -> Report:
rep = Report() rep = Report()
validate_network(cfg, rep) validate_network(cfg, rep)
@ -738,6 +869,7 @@ def validate(cfg: dict) -> Report:
validate_kiosks(cfg, rep) validate_kiosks(cfg, rep)
validate_proxy(cfg, rep) validate_proxy(cfg, rep)
validate_stream_dock(cfg, rep) validate_stream_dock(cfg, rep)
validate_mic_follow(cfg, rep)
return rep return rep