289 lines
15 KiB
Markdown
289 lines
15 KiB
Markdown
# 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.
|
||
|
||
> **Status: built** — `pebble-presence/`. The payload came out at ~80 bytes for a
|
||
> three-room test plan including names, well inside the budget below, and the format is
|
||
> round-trip tested between the JS writer and the C reader. Two things remain 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.
|
||
|
||
**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.0–1.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 **200–250 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_create` → `gpath_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 2–5 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** — built, in the JS as planned, so the server stays
|
||
general. See `pebble-presence/WIREFORMAT.md`. The fallback if hand-drawn plans turn
|
||
out to have 20-vertex rooms is unchanged: a `?format=compact` on
|
||
`/floorplan/presence` that simplifies server-side.
|
||
2. **The watchapp itself** — built. `gpath` rendering, a click config provider, the
|
||
room-detail window, an AppMessage handler, and a settings page for the URL and
|
||
token.
|
||
3. **A level picker** — not built. The settings page takes a level id and defaults to
|
||
the first; folding levels into the same Up/Down cycle after the last room is the
|
||
obvious next move, and costs one wrap-around.
|
||
|
||
## 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.
|