SmartestHome/docs/pebble-presence-watchface.md

281 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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_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 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 itself**`gpath` 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.