Commit Graph

2 Commits (cebdc9529bd367bd0d82d102f6287289c4d1ff7e)

Author SHA1 Message Date
Amir Alexander Abdelbaki cebdc9529b Follow-me microphone: the mic switches to the room you walked into
A voice call that survives a smoke break. The person leaves their desk, the
locator says which room they are in, their machine's live microphone switches
to one that can hear them there, and back to the studio mic when they sit down
again. Written per CLIENT, so a second person with a second desktop is one
more entry in CoreSystemConfig.json and nothing else changes.

ONE RULE MAKES IT PREDICTABLE: off means the desk mic. The Follow-me switch
being off is not "ignore me", it is an active guarantee that the machine is on
its own microphone — reachable from the dock, the watch or MQTT, honoured when
the agent shuts down, and independent of whether presence is right or the
remote machine is up. Being live on the wrong mic is the failure you notice in
front of five other people, so the safe state has to be the cheap one.

Home Assistant decides where the person is; the desktop agent only knows how
to change the input. That split is the same one every other agent here uses —
the inbound control surface is MQTT discovery entities and nothing else, and
no presence logic runs on a desktop.

The agent sets the default source AND moves the already-running capture
streams of the configured applications. Anyone who owns a studio mic has
picked it explicitly in Discord, and an explicitly-picked device does not
follow the default; without the second half the switch would appear to do
nothing in the one application it exists for.

Three surfaces, one entity to read. sensor.mic_follow_<node>_status has as its
STATE the name of the microphone that is live right now — "Desk", "Loggia" —
so nothing has to reimplement the same three-way template:

  Stream Dock  a key showing that sensor, calling switch.toggle. The HA plugin
               subscribes to the websocket, so it updates on state change
               rather than on a timer, including when the watch moved it.
               Leave the key title empty: "Loggia" in large type is readable
               across a room, "Follow-me mic" over a small "Loggia" is not.
  Pebble       a new toggles screen, long-press Select on the plan. The live
               microphone in large type, FOLLOWING / DESK ONLY as a coloured
               pill, Select flips it, Up/Down cycles clients.
  HA           the switch and the select, like anything else.

The watch reaches HA through a new allowlist in identity (/toggles), not
directly: identity already holds an HA token and the phone already holds
identity's, so one button on a wrist does not put an HA admin token into a
watchapp's settings. Only switch.* entities that are named in
TOGGLE_ALLOWLIST_JSON, only on/off/toggle, anything else is a 404 — and each
entry may name a detail_entity whose state is served alongside, which is how
the watch displays the live microphone instead of deriving it. Every response
re-reads the state rather than assuming it: "I sent the command" is not the
same fact as "the switch is on".

start_command/stop_command on a source are the hook for a microphone that is
not simply plugged into the machine. The STOP hook is the important one: a room
microphone still streaming after the switch left it is a hot mic in somebody's
flat. It runs on every transition away and on shutdown, and the validator warns
about a start with no stop.

Tested, and it runs anywhere: 17 fixture cases over source selection — a
monitor source can never be selected (picking one transmits what the desktop is
PLAYING, the worst outcome available), exact names beat substrings, an
ambiguous pattern resolves the same way after a reboot instead of coin-flipping,
and only the configured applications' streams move. The new watch message is
round-tripped through the real JS packer and the real C parser, including the
two cases that decode as plausible garbage otherwise: a value containing the
field separator, and a record truncated mid-way. The generated HA package
parses as YAML for one client and for several.

Untested, and it needs the actual machines: every command that changes state
(pactl set-default-source, move-source-output), the pactl JSON shapes the
fixtures imitate, the discovery payloads against a real HA — and the one most
likely to bite, whether BLE presence reports rooms fast and accurately enough
to be worth wiring to a microphone at all. Room-level presence has never been
measured in this flat. RuView can say a room is occupied but not by whom, and
Frigate recognises faces at the door, not per room, so this rides on BLE with
both of those as corroboration. mic-follow/README.md sections 3 and 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7yp4NcmX4zqja1MKRjeWJ
2026-08-31 13:41:33 +02:00
Amir Alexander Abdelbaki f4de1e7cec Build the Pebble watchapp
The floorplan on a wrist, rendering the same GET /floorplan/presence payload as
render/floorplan-3d. If the two ever disagree, one of them is lying.

An app rather than a face, because buttons decide it: watchfaces receive no
button events and Pebble restricts touch to apps, and cycling through rooms was
the point. That costs being the default screen, and it means the JS — therefore
the data — only lives while the app is open, so every launch begins with a
fetch. The last plan is persisted and drawn immediately WITH ITS AGE, then
repainted when the fetch lands: a spinner on a screen that already has something
true to say is worse than slightly stale truth, clearly labelled.

The screens
  Plan: occupied rooms light, empty dark, and a dotted edge for rooms HA never
  reports on — "nobody is in the study" and "nothing can see the study" are
  different sentences. Occupancy reads in lightness, never hue, because colour
  already means who. Fused positions are drawn where the person is; an
  unattributed radar target is a hollow ring with no colour and no initial,
  since every visual language here for a person is reserved for people the
  system can name.

  Detail: one room per screen, Up/Down wrapping at both ends, each person a
  coloured dot plus their name. "Nobody here" is printed rather than left blank,
  which reads as a fault. The last entry in the cycle is "Somewhere in the
  house" — the people it cannot place are exactly who you picked the watch up
  to find.

The wire format is the fragile part
  One packed byte array, ~80 bytes for a three-room plan with names, written in
  JS and read in C — two implementations of one format, in two languages, that
  never run in the same process. Drift between them decodes as plausible-looking
  garbage rather than as an error, so test/run-tests.sh builds a payload with the
  real writer and decodes it with the real reader compiled against a stub SDK. It
  also checks that a truncated payload is refused wholesale (half a plan looks
  exactly like a whole one) and that a version mismatch is refused rather than
  decoded.

  That test earned itself immediately: it caught a disagreement about whether a
  radar target in a room HA does not report should read as occupied or unknown.
  The code was right — a positive detection outranks no-data — and the test's
  expectation was wrong.

Unverified, and both are about the new hardware rather than the code: the Round
2's platform identifier, and its round-screen detection in the JS. Nothing
hardcodes a resolution, so both are one-line changes once the SDK names them.
It has never run on a watch, in an emulator, or against a real identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FanS1vyE2gLhGkqKq6HtYj
2026-08-10 15:12:38 +02:00