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
|
||
|---|---|---|
| .. | ||
| src | ||
| test | ||
| README.md | ||
| WIREFORMAT.md | ||
| package.json | ||
README.md
Who's home — a Pebble watchapp
The household floorplan on your wrist, with who is in each room. Renders the same
GET /floorplan/presence payload as render/floorplan-3d/, so if the two ever
disagree, one of them is lying.
Designed in docs/pebble-presence-watchface.md;
this is the build.
The toggles screen
Long-press Select on the plan screen. It shows one Home Assistant switch per line —
today that means mic-follow's Follow-me microphone switches (../mic-follow/) — with
the live microphone's name in large type, because standing on a balcony the question
is not "is the switch on" but "what am I being heard through right now". Select flips it;
Up/Down cycles if there is more than one client.
It goes through identity's /toggles, not straight at Home Assistant: identity already
holds an HA token and this phone already holds identity's, so one button on a wrist does
not put an HA admin token into a watchapp's settings. identity serves an allowlist —
switch.* entities only, on/off/toggle only, anything else is a 404.
The states are asked for when the screen opens rather than cached, because the switch can
be flipped from the Stream Dock, from a dashboard, or by the automation itself; anything
the watch remembered from last time would be a guess. A press shows … until the phone
confirms, so a button that appears to do nothing does not get pressed twice.
The toggles line is plain text, not the binary plan format — see WIREFORMAT.md's closing section for why, and note that it is round-trip tested the same way.
It is an app, not a face
Watchfaces get no button events (Select opens the app menu, Up/Down are system shortcuts) and Pebble restricts touch to apps as well. Cycling through rooms is the point, so it is a watchapp. What that costs, stated plainly because it is the thing you will notice:
- You launch it from the menu rather than seeing it by raising your wrist.
- Its JS — and therefore its data — only lives while it is open, so every launch begins with a fetch.
So the last plan is persisted and drawn immediately, with its age on screen, 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.
A watchface variant later is a second main() and a build target, not a second
project: the rendering, the parser and all the JS are shared.
Screens
Plan — the floorplan. Occupied rooms are drawn light, empty rooms dark, and rooms HA never reports on get a dotted edge: "nobody is in the study" and "nothing can see the study" are different sentences. Occupancy is carried in lightness, never hue, because colour already means who and the two would compete on a 64-colour panel.
Select→ the room detailUp/Down→ ask the phone for a fresh fetch
Detail — one room at a time, Up/Down cycles and wraps at both ends. Each person
is a coloured dot plus their name; the dot stays even with room for the name, because
it is what ties the line back to the marker on the plan and to the same person in the
admin panel. "Nobody here" is printed — a blank list reads as a fault.
The last entry in the cycle is "Somewhere in the house": everyone who is home but not locatable. They are part of the same cycle deliberately — they are exactly who you picked the watch up to find.
Positions, when there are any
A person fused to a single radar target (see identity's position fusion) is drawn at
their coordinate. An unattributed target is a hollow ring — no colour, no initial,
because every visual language here for a person is reserved for people the system can
name. A radar target lights the room even with nobody named: somebody is in there.
The wire format is the fragile part
WIREFORMAT.md. One packed byte array, written by
src/pkjs/index.js and read by src/c/main.c — two implementations of one format, in
two languages, that never run in the same process. Nothing but a test catches them
drifting, and drift decodes as plausible-looking garbage rather than as an error.
test/run-tests.sh # needs node + cc; no Pebble SDK
It builds a payload with the real writer, compiles the real reader against a stub SDK, and checks the decode — including 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 has already earned itself once: it caught a disagreement about whether a radar target in a room HA doesn't report should read as occupied or unknown. The code was right (a positive detection outranks no-data); the test's expectation was wrong.
Building for a real watch
pebble build && pebble install --phone <ip>
Unverified, in the order it will bite:
- The Pebble Round 2's platform name.
package.jsontargetsbasalt/chalk/diorite/emery;chalkis the old round (180×180) andemerythe old Time 2 (200×228). The new 260×260 Round 2 may need a platform this list does not have. Nothing inmain.chardcodes a resolution — every position comes fromlayer_get_bounds()— so adding the platform should be a one-line change, but the name has to come from whatever SDK Core Devices ships. - Round detection in the JS keys off
platform === "chalk", and will need the new identifier too. Get it wrong and the plan is drawn to the full rectangle on a round screen, so the corners of the house fall off the edge. - Nothing has run on a watch, in an emulator, or against a real
identity. The wire format is round-trip tested and the C type-checks against a stub; that is the whole of it. - The token lives in the phone's app config. It reads the household's presence history — treat it as the credential it is.
And the thing no amount of watch code fixes
This is only as right as room-level presence, which has never been measured in this house. If everyone resolves to "home, room unknown", this is a picture of a floorplan with everybody in the Somewhere in the house list. Test that first: the admin panel's floorplan tab, Live ticked, walk between two rooms, see whether the marker moves.