SmartestHome/pebble-presence
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
..
src Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00
test Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00
README.md Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00
WIREFORMAT.md Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00
package.json Follow-me microphone: the mic switches to the room you walked into 2026-08-31 13:41:33 +02:00

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 allowlistswitch.* 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 detail
  • Up/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:

  1. The Pebble Round 2's platform name. package.json targets basalt/chalk/diorite/emery; chalk is the old round (180×180) and emery the old Time 2 (200×228). The new 260×260 Round 2 may need a platform this list does not have. Nothing in main.c hardcodes a resolution — every position comes from layer_get_bounds() — so adding the platform should be a one-line change, but the name has to come from whatever SDK Core Devices ships.
  2. 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.
  3. 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.
  4. 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.