SmartestHome/docs/pebble-presence-watchface.md

15 KiB
Raw Blame History

A Pebble watchapp that shows who is in which room

Feasibility note. The question: take the floorplan drawn in identity's admin panel, put it on a Pebble, and mark each room with who is standing in it.

Verdict: the watch is the easy part. Drawing the plan is a few hundred bytes and some gpath calls, and the data already exists at GET /floorplan/presence. It is a watchapp, not a watchface — see "Zooming into a room" for why the buttons decide that.

The hard part is upstream and unchanged by any of this: room-level presence itself. Build it if you want it, but do not expect it to be more right than the presence data feeding it, which today is "which HA area a trusted BLE entity reports" and has never been measured for room-level accuracy in this house.

The hardware: a Pebble Round 2

That is the target — it is the watch that exists. 260×260, 64-colour e-paper, touch. For reference, the rest of the line:

Model Screen Colour Notes
Pebble Round 2 260×260 64-colour e-paper, touch The target. The most pixels in the line, and circular — see the next section, which is entirely about that
Pebble Time 2 200×228 64-colour e-paper, touch Rectangular, so a plan fits without the geometry below. Fewer pixels, more usable ones
Pebble 2 Duo 144×168 Black and white The per-person colour collapses to a grey; two people sharing an initial stop being distinguishable

Classic Pebbles (144×168) run the same code, cramped.

Fitting a rectangular plan on a round screen

A floorplan is a rectangle and the screen is a circle, so the plan gets inscribed in the circle and the corners are simply not available. The arithmetic, for a plan of aspect ratio a = w/h on a usable diameter D:

w = D·a / √(a²+1)
h = D   / √(a²+1)

With D = 240 (260 less a ~10 px margin, because a circular screen's outermost pixels are where you least want a room boundary):

Plan aspect Usable box
1:1 170 × 170
4:3 192 × 144
16:9 209 × 118

So a squarish plan gets 170×170 — comparable to what a Time 2 gives after chrome, and more than the 144×168 classics ever had. The Round is not a downgrade for this; it just has to be told the truth about its shape.

Three consequences to design to:

  • Compute the box in PebbleKit JS, not on the watch. The projection step already planned for (normalised polygons → pixels) simply takes the inscribed box instead of the full screen. No watch-side change at all.
  • No corner furniture. The "3 of 4 home placed" footer has nowhere to live on a circle — a line of text at the bottom gets clipped by the bezel radius. Put it at the vertical centre-bottom, short, or move it into the room-detail screen where there is a full-width line to use.
  • Round screens have less usable area than their diameter suggests, and a ten-room plan at 170×170 gives each room roughly 40×35 px. That is still enough for a 16 px occupant dot with an initial in it — which is the number that decided against profile pictures above, and it does not change here.

The data path

identity  ──HTTP──>  PebbleKit JS          ──AppMessage/BT──>  watchapp (C)
(container host)     (runs on the Android      ~2 KB budget      gpath + text
 /floorplan/presence  phone, inside the                          rendering
                      Pebble app)

Three consequences, all of which shape the design:

  • The watch has no network of its own. Everything goes through PebbleKit JS, which runs on the phone, inside the Pebble app, and — this is the important part — only while the app is open. It cannot poll in the background — which bounds the battery cost, and also means every launch begins with a fetch.
  • The phone has to be able to reach identity. On the home network that is direct. Off it, that is the WireGuard split tunnel this project already uses for exactly this class of problem — nothing here justifies putting identity on the WAN. If the tunnel is down it shows its last state with an age on it, not a blank plan.
  • Android only, which this household is anyway, so no iOS-side caveats apply.

The payload budget, and why it is not a problem

Pebble's guaranteed AppMessage buffers are small — 124 bytes in / 636 out at the documented minimum, ~2 KB each way in practice for a JS-backed app. That sounds alarming until you count what a floorplan actually is:

  • Room polygons are already stored normalised 0.01.0 (floorplan_rooms.points). Quantise each coordinate to one byte and a vertex costs 2 bytes.
  • A ten-room plan at eight vertices a room: 10 × (1 + 8×2) = 170 bytes.
  • An occupant marker is 3 bytes: room index, colour index, initial.

So a whole floor with everybody on it lands around 200250 bytes — comfortably inside one message, with room to spare for a level name and a timestamp. Send it as a single byte-array tuple rather than one tuple per room; the dictionary overhead is what would actually cost you.

Do the projection on the phone, not the watch. PebbleKit JS picks the level, scales the normalised polygons to the watch's pixel box — the inscribed box, on the Round — drops rooms too small to draw, and sends integers. The watch does gpath_creategpath_draw_filled → outline → text. No floating point, no layout logic, no second copy of the floorplan model on a device with 64 KB to its name.

Empty rooms dark, occupied rooms lit

The plan should read as a state at a glance, not as a drawing you have to search. So occupancy is carried by the room fill itself, before you look at any marker:

Fill Outline Contents
Empty near-black, barely above the background dim nothing, or the room's initial letter in the dim outline colour
Occupied light — a pale warm grey/white bright the occupant dots, in their own colours

Three details that decide whether this works:

  • Carry it in lightness, not hue. The occupant dots are already using colour to mean who; if the room fill also used colour to mean occupied, the two would compete on the one channel that matters most on a 64-colour panel. Dark-vs-light is the strongest signal e-paper has and it costs nothing.
  • A lit room must be lighter than any occupant dot is dark, or the dot vanishes into its own room. With PERSON_COLORS being mid-to-bright, a near-white fill and a dark dot outline keeps every one of the eight readable — this is the same reason the admin panel draws initials in near-black on the person's colour.
  • "Unknown" is not "empty". A room the plan has drawn but whose area HA never reports is neither occupied nor confirmed-empty, and rendering it as empty is a quiet lie. Give it the empty fill with a dashed or dotted outline — a third state that costs one drawing call and is the difference between "nobody is in the study" and "nothing can see the study".

E-paper is reflective, so "lit" here means a lighter fill, not a backlight. The effect is exactly the one you want in a dark hallway at 2am: the rooms with people in them are the bright shapes.

Profile pictures: no, and the arithmetic says so

On the Round 2, a ten-room plan inscribed at 170×170 gives each room about 40×35 px. A face inside one, with the room outline still visible, gets about 18×18 px, in 64 colours, on e-paper. That is not a picture of a person; it is four skin-toned blobs. And each one costs ~330 bytes to ship, so three of them exceed the entire message budget that currently carries the whole floor. The rectangular Time 2 is no better — 45×35 px rooms, a 20×20 face — so this is a conclusion about the class of device, not about the shape of this one.

Initial plus colour is the right answer, and it is why the colour exists. A filled 16 px circle in the person's own colour with their initial in GOTHIC_14_BOLD reads at arm's length, costs 3 bytes, and degrades honestly: on a black-and-white Pebble the colour becomes a grey and the letter still works.

identity now serves both fields ready-made — color and initial on /people, /presence, and each occupant in /floorplan/presence — so no consumer has to derive an initial from a name or invent a palette. The eight palette colours are chosen on the 2-bits-per-channel lattice (00/55/AA/FF) that a colour Pebble renders natively, precisely so the colour on the watch is the colour in the admin panel and not a dithered approximation of it.

Zooming into a room: why this is an app and not a face

The wanted behaviour — press a button, cycle to the next room, see a plain list of who is in it — is easy to draw and cheap in bytes. The catch is that it forces a watchapp instead of a watchface, and that is a real product decision, not a detail:

  • Watchfaces do not receive button events. The buttons belong to the system there (Select opens the app menu, Up/Down are the system shortcuts), and Pebble's own docs say touch is deliberately restricted to watchapps too — "easier to allow it later than to take it away once apps depend on it."
  • A watchapp gets buttons and touch, and can do exactly the requested cycling. What it does not get is being your default screen: you launch it from the menu, look, and leave.

So pick which the thing actually is:

Watchface Watchapp
Shows without launching yes — this is the whole glance-at-wrist value no, it's a menu entry
Button cycling through rooms no yes
Shake to cycle yes (accel_tap_service_subscribe, the long-standing shake-to-reveal trick — worth confirming on your firmware) yes
Practical shape the plan, occupant dots, nothing else the plan plus the per-room drill-down

Decided: build the watchapp. Button cycling is the point, so the drill-down wins over being the default screen. What that costs, stated plainly so it isn't a surprise later: you launch it from the app menu rather than seeing it by raising your wrist, and its JS — and therefore its data — only lives while it is open, so every launch starts with one fetch and a moment of "loading". Design for that: draw the last-known state immediately with its age on it, then repaint when the fetch lands, rather than showing a spinner on a screen that already has something true to say.

One package is either a face or an app — no binary is both — but the rendering, the AppMessage handler and the JS are all shared, so a watchface variant later is a second main() and a build target, not a second project. Worth keeping that seam clean while writing it, in case the glance turns out to be what you actually reach for.

What the drill-down shows

Rooms in the order they are drawn, wrapping at both ends, with Back leaving. (On the Round 2, confirm which of buttons and touch you actually want to drive this — it has both, and a circular screen makes a swipe more natural than a button press for "next". The click config provider and a touch handler are the same twenty lines either way.)

   Kitchen              <- room name, Up/Down cycles
   ─────────────
   ● Amir               <- the person's colour, then their full name
   ● Anna
                        <- "Nobody here" when empty, never a blank screen
   3 of 4 home placed   <- the honest footer, see below

Full names cost bytes the overview does not need — ten people at ~12 bytes is ~120, still nothing against the ~2 KB budget, so send them with the plan rather than making a second request per room. The colour dot stays even though there is room for the name: it is what ties this screen back to the marker on the plan.

Give the empty and the unknown cases real text. "Nobody here" is a finding. "3 of 4 home placed" is the truth that the overview can only gesture at — and the unplaced list from /floorplan/presence deserves its own entry at the end of the cycle ("Somewhere in the house: Bibi"), because a person the system cannot locate is exactly who you were looking for when you picked up the watch.

Where a photo does belong: nowhere here either. Once you have room to print "Amir" you have already solved the problem the picture was for.

Update cadence

The JS only lives while the face is displayed, so "polling" means "while you are looking at it":

  • Fetch once on load.
  • Refresh on a tick_timer every 25 minutes while visible.
  • Refresh on tap/shake, for the "who's home right now" glance that is the actual use case.

Do not refresh every second, and do not attempt a background service to keep it warm: Bluetooth wakeups are the battery cost on both devices, and a presence display that is four minutes stale is not wrong in any way that matters.

What is already in place, and what is missing

Already there:

  • GET /floorplan/presence — the drawn plan joined to who is in each room, with unplaced for people who are home but not locatable and unmapped_areas for areas HA reports that nothing on the plan claims. Both matter on a small screen: "3 home, 1 not locatable" is honest, and quietly dropping two people is not.
  • color and initial on every person, in every presence payload.
  • Bearer-token auth on the whole API — the token would have to live in the app's Clay settings, which is a real consideration: it is stored on the phone in the Pebble app's config, and it is a token that reads the household's presence history.

Missing, in the order it would need doing:

  1. A compact serialisation. Doing the quantisation in PebbleKit JS keeps the server general and is the right first move. If polygon vertex counts turn out to be large (a hand-drawn plan can easily have 20-vertex rooms), a ?format=compact on /floorplan/presence that simplifies and quantises server-side is the fallback.
  2. The watchapp itselfgpath rendering for the overview, a click config provider, the room-detail window, an AppMessage handler, and a Clay settings page for the API URL and token. This is a weekend, not a project.
  3. A level picker, because the plan is per-level and a watch shows one at a time. Another button, or fold the levels into the same Up/Down cycle after the last room.

The thing that decides whether this is worth building

Room-level presence. identity resolves a person's room from whatever AREA_ATTRIBUTE holds on their trusted BLE entity — which is as good as the BLE proxy layout and HA's area assignment, and this project has never measured it. If in practice everyone resolves to "home, room unknown", the app is a picture of a floorplan with everybody sitting in the unplaced list at the bottom, and no amount of watch-side work fixes that.

That is testable today, without buying anything: open the admin panel's floorplan tab with Live ticked, walk between two rooms, and see whether the marker moves. If it does, the app is a weekend. If it doesn't, the work is in the BLE proxies, and the watch is a distraction from it.