From f4de1e7cec9a08f6887e616c99fe8dd7ceb58154 Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 10 Aug 2026 15:12:38 +0200 Subject: [PATCH] Build the Pebble watchapp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floorplan on a wrist, rendering the same GET /floorplan/presence payload as render/floorplan-3d. If the two ever disagree, one of them is lying. An app rather than a face, because buttons decide it: watchfaces receive no button events and Pebble restricts touch to apps, and cycling through rooms was the point. That costs being the default screen, and it means the JS — therefore the data — only lives while the app is open, so every launch begins with a fetch. The last plan is persisted and drawn immediately WITH ITS AGE, 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. The screens Plan: occupied rooms light, empty dark, and a dotted edge for rooms HA never reports on — "nobody is in the study" and "nothing can see the study" are different sentences. Occupancy reads in lightness, never hue, because colour already means who. Fused positions are drawn where the person is; an unattributed radar target is a hollow ring with no colour and no initial, since every visual language here for a person is reserved for people the system can name. Detail: one room per screen, Up/Down wrapping at both ends, each person a coloured dot plus their name. "Nobody here" is printed rather than left blank, which reads as a fault. The last entry in the cycle is "Somewhere in the house" — the people it cannot place are exactly who you picked the watch up to find. The wire format is the fragile part One packed byte array, ~80 bytes for a three-room plan with names, written in JS and read in C — two implementations of one format, in two languages, that never run in the same process. Drift between them decodes as plausible-looking garbage rather than as an error, so test/run-tests.sh builds a payload with the real writer and decodes it with the real reader compiled against a stub SDK. It also checks 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 earned itself immediately: it caught a disagreement about whether a radar target in a room HA does not report should read as occupied or unknown. The code was right — a positive detection outranks no-data — and the test's expectation was wrong. 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. It has never run on a watch, in an emulator, or against a real identity. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FanS1vyE2gLhGkqKq6HtYj --- README.md | 3 +- docs/pebble-presence-watchface.md | 26 +- pebble-presence/README.md | 103 +++++ pebble-presence/WIREFORMAT.md | 67 ++++ pebble-presence/package.json | 30 ++ pebble-presence/src/c/main.c | 511 +++++++++++++++++++++++++ pebble-presence/src/pkjs/index.js | 312 +++++++++++++++ pebble-presence/test/make-payload.js | 46 +++ pebble-presence/test/pebble.h | 3 + pebble-presence/test/pebble_stub.h | 116 ++++++ pebble-presence/test/run-tests.sh | 13 + pebble-presence/test/wireformat_test.c | 72 ++++ 12 files changed, 1292 insertions(+), 10 deletions(-) create mode 100644 pebble-presence/README.md create mode 100644 pebble-presence/WIREFORMAT.md create mode 100644 pebble-presence/package.json create mode 100644 pebble-presence/src/c/main.c create mode 100644 pebble-presence/src/pkjs/index.js create mode 100644 pebble-presence/test/make-payload.js create mode 100644 pebble-presence/test/pebble.h create mode 100644 pebble-presence/test/pebble_stub.h create mode 100755 pebble-presence/test/run-tests.sh create mode 100644 pebble-presence/test/wireformat_test.c diff --git a/README.md b/README.md index e501de0..69ab7e4 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,8 @@ chores/ Presence/calendar-driven household chore nudging + - [ ] Workshop/office assistant — `docs/workshop-assistant.md`: **read the label, don't recognise the object** (a closed T480 and T490 are the same black rectangle; the identity is in the service tag and the PCB silkscreen, so the pipeline is OCR/barcode-first with the VLM only locating the label). Specs must be **quoted from a fetched document with its URL, never generated** — a hallucinated pinout destroys hardware — which also makes this the first component here that deliberately reaches the open internet (outbound-only, allowlisted, cached). Per-room scoping = the room selects the toolset, riding the `room` plumbing above. An SMB **workspace share** for everything it produces (reusing the existing Samba container — a second one would collide on 445, which the gallery already holds — with its own volume, own account, and read-write where the gallery is read-only). Display widgets collapse to **one `svg` window kind plus server-side renderers** (Graphviz for code-flow/data-structure, netlistsvg for schematics, KiCad export for board plans), because a megabyte of JS on a kiosk buys a picture the server could render once — **IEC/EU notation is a symbol-library decision**, so "can I supply my own symbols?" disqualifies a tool before output quality does. Purple/magenta holo theme is one CSS variable override over the existing `glow.css`, with the drawings left untinted on purpose. Analysis only, nothing built - [x] **Now-playing visualiser + 3D floorplan** — built, in `render/` (shared, vendored, dependency-free, config from `?query=params`). `media-visualiser/`: circular spectrum, album-art palette, LRC lyrics — **two-tier by necessity**, since most endpoints have no local audio (a kitchen panel showing what the living room plays cannot analyse anything), so it is real FFT where audio is local and a tempo-driven ring elsewhere, **which says on screen that it is not an analysis**. Palette rejects near-greys/near-blacks before ranking and lifts each colour until it clears the background — the step whose absence makes art-coloured visualisers invisible on dark covers. Plain lyrics are shown but never auto-scrolled at a guessed rate. `floorplan-3d/`: the same `/floorplan/presence` payload as the Pebble app, extruded — **canvas 2D, not three.js**, a change from the plan made while building it: the scene is prisms on a plane, which an isometric projection with painter's sorting draws in ~200 lines, predictably on weak panels, with the frontend still at zero dependencies. Lit/dark rooms plus the dashed third state for rooms HA never reports on, photos-or-initials in the person's colour ring, and a visible shelf for people who are home but unlocatable. Geometry unit-tested headlessly; **never opened in a real browser** - [ ] ~~Now-playing visualiser + 3D floorplan (design)~~ — `docs/endpoint-surfaces.md`: a circular CAVA-style ring behind every playing screen, coloured from the album art, lyrics under the cover when they exist; and the Pebble app's presence view in 3D on any endpoint. Two findings shape both: **most endpoints have no local audio** (a kitchen panel showing what the living room plays cannot analyse anything), so the visualiser is explicitly two-tier — real FFT where audio is local, tempo-driven "mood light" elsewhere, and the synthetic tier must never claim to be the real one; and the 3D plan is an **extrusion of the existing 2D polygons**, not a hand-authored model, so it can't go stale when a room is redrawn. Three.js is ~1MB and a deliberate break with the dependency-free SDK rule — make it once, explicitly. Analysis only, nothing built -- [ ] Pebble presence **watchapp** feasibility — `docs/pebble-presence-watchface.md`: the floorplan drawn as quantised polygons (~200–250 bytes for a whole floor, against a ~2 KB AppMessage budget), occupants as initial-on-colour rather than photos (a face gets ~20×20px in 64 colours — four skin-toned blobs, and three of them exceed the message budget the entire floor fits in). Targets the **Pebble Round 2** that's actually owned: the plan is inscribed in the circle (`w = D·a/√(a²+1)`, so ~170×170 for a squarish plan at a 240px usable diameter — comparable to a Time 2 after chrome), the projection change is one line of phone-side JS, and the only real loss is corner furniture like a status footer. Occupancy reads as **dark room / lit room** in lightness rather than hue, since colour is already spoken for by *who* — with a third dashed state for rooms HA never reports on, because drawing "no data" as "empty" is a quiet lie. **Button-cycling through rooms forces a watchapp, not a watchface** — Pebble gives watchfaces no button events and restricts touch to apps — and the watchapp is the decision: Up/Down cycles rooms into a plain-text list of who is in each (plus a final "somewhere in the house" entry for people who are home but unlocatable, who are exactly who you picked the watch up to find). The cost is that it's a menu entry rather than your default screen, and its data only lives while it's open; a watchface variant later is a second `main()`, not a second project. Analysis only, nothing built. The finding that actually decides it is upstream: room-level presence has never been measured in this house, and the admin panel's floorplan tab with **Live** ticked tests it today for free +- [x] **Pebble watchapp built** — `pebble-presence/`: the floorplan and who's in each room, plus Up/Down room cycling into a plain-text list (with "Somewhere in the house" as the last entry in the cycle — the people it can't place are exactly who you picked the watch up to find). Lit/dark/dotted room states, positions when fused, hollow rings for radar targets nobody can name. The last plan is persisted and drawn **with its age** on launch, because the app's JS only lives while it's open so every launch starts with a fetch. **The wire format is round-trip tested** between the JS writer and the C reader against a stub SDK — two implementations of one format in two languages that never share a process, where drift decodes as plausible garbage rather than an error; it already caught one real disagreement. Unverified: the Round 2's platform identifier and its round-screen detection (nothing hardcodes a resolution, so both are one-liners once the SDK names them), and it has never run on a watch +- [ ] ~~Pebble presence watchapp (design)~~ — `docs/pebble-presence-watchface.md`: the floorplan drawn as quantised polygons (~200–250 bytes for a whole floor, against a ~2 KB AppMessage budget), occupants as initial-on-colour rather than photos (a face gets ~20×20px in 64 colours — four skin-toned blobs, and three of them exceed the message budget the entire floor fits in). Targets the **Pebble Round 2** that's actually owned: the plan is inscribed in the circle (`w = D·a/√(a²+1)`, so ~170×170 for a squarish plan at a 240px usable diameter — comparable to a Time 2 after chrome), the projection change is one line of phone-side JS, and the only real loss is corner furniture like a status footer. Occupancy reads as **dark room / lit room** in lightness rather than hue, since colour is already spoken for by *who* — with a third dashed state for rooms HA never reports on, because drawing "no data" as "empty" is a quiet lie. **Button-cycling through rooms forces a watchapp, not a watchface** — Pebble gives watchfaces no button events and restricts touch to apps — and the watchapp is the decision: Up/Down cycles rooms into a plain-text list of who is in each (plus a final "somewhere in the house" entry for people who are home but unlocatable, who are exactly who you picked the watch up to find). The cost is that it's a menu entry rather than your default screen, and its data only lives while it's open; a watchface variant later is a second `main()`, not a second project. Analysis only, nothing built. The finding that actually decides it is upstream: room-level presence has never been measured in this house, and the admin panel's floorplan tab with **Live** ticked tests it today for free - [ ] "Put the groceries away" chore — `chores`' new `groceries_out_of_place` watch point: `litter`'s twin (same "whoever the camera just saw" attribution, same exemption-proof, same unassignable) with a **30-minute** neglect fuse instead of four hours, because the failure is spoiled food rather than untidiness. Its prompt names what to *ignore* (fruit bowls, bread bins, coffee, cookware) as well as what to look for — the general question gets a YES for any normal kitchen, and a false chore every two hours is how a household learns to mute the channel (open decision #41). Knows food is out, never what the food is; nothing here touches Grocy. Untested against a real camera or model, like every other watch point - [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as ``" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline** — `TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md` - [ ] `identity` also corroborates presence from Frigate face recognition (Phase 20, Tapo pan/tilt cameras) — an OR-ed-in second signal only, **never** a registration signal; and owns the per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, plus chore assignment) consumed by `chores/`, see `identity/README.md` diff --git a/docs/pebble-presence-watchface.md b/docs/pebble-presence-watchface.md index cde8381..e8db2c7 100644 --- a/docs/pebble-presence-watchface.md +++ b/docs/pebble-presence-watchface.md @@ -3,6 +3,13 @@ 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 @@ -255,15 +262,16 @@ Already there: 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. +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 diff --git a/pebble-presence/README.md b/pebble-presence/README.md new file mode 100644 index 0000000..9efea08 --- /dev/null +++ b/pebble-presence/README.md @@ -0,0 +1,103 @@ +# 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`](../docs/pebble-presence-watchface.md); +this is the build. + +## 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`](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. + +```sh +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 + +```sh +pebble build && pebble install --phone +``` + +**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. diff --git a/pebble-presence/WIREFORMAT.md b/pebble-presence/WIREFORMAT.md new file mode 100644 index 0000000..204581d --- /dev/null +++ b/pebble-presence/WIREFORMAT.md @@ -0,0 +1,67 @@ +# The wire format + +One byte-array tuple over AppMessage, defined here and implemented twice — in +`src/pkjs/index.js` (writer) and `src/c/main.c` (reader). **If you change one, change +the other and the version byte.** A silent mismatch between them decodes into garbage +rooms rather than failing, which is why the version byte exists at all. + +## Why a byte array and not a dictionary + +Pebble's guaranteed AppMessage buffers are small (124 in / 636 out documented minimum, +~2 KB in practice for a JS-backed app), and a dictionary costs ~7 bytes of overhead per +tuple. A ten-room plan as one tuple per room would spend more on keys than on rooms. As +one packed array it is a few hundred bytes with room to spare. + +## Coordinates are quantised to a byte + +Room polygons are already stored normalised 0.0–1.0 by `identity`. Multiply by 255 and +round: a vertex costs 2 bytes and the error is under half a percent of the plan's +width, which on a 170 px inscribed box is sub-pixel. The phone does the projection into +the watch's inscribed box; the watch does no floating point at all. + +## Layout + +``` +header + u8 version 1 + u8 room_count + u8 unplaced_count + u8 flags bit0: positions are available on this level + +per room (room_count times) + u8 name_len + u8[] name UTF-8, truncated to 24 bytes by the writer + u8 state 0 = empty, 1 = occupied, 2 = unknown (drawn, never reported) + u8 vertex_count + u16[] vertices x,y each u8, so 2 bytes per vertex + u8 target_count anonymous radar targets — somebody is there, nobody knows who + u16[] targets x,y each u8 + u8 occupant_count + per occupant + u8 colour_index 0..7 into identity's PERSON_COLORS, 255 = unknown + u8 initial ASCII + u8 occ_flags bit0: has a fused position + u16 position x,y each u8 — PRESENT ONLY when bit0 is set + u8 name_len + u8[] name UTF-8, truncated to 16 bytes + +per unplaced person (unplaced_count times) + u8 colour_index + u8 initial + u8 name_len + u8[] name +``` + +## Truncation, and why the writer does it + +The writer builds the payload and, if it exceeds the inbox size, sheds in this order: + +1. **Occupant names** — the detail screen loses full names and falls back to initials. + Worst outcome of the three and still legible. +2. **Rooms with no occupants** — an empty room is the least informative thing on the + plan. +3. **Whole levels** — refuse, and say so on the watch. + +It never silently sends a partial structure. A payload that decodes half-way is worse +than one that does not arrive, because the watch cannot tell the difference between +"three rooms" and "three rooms and then the buffer ran out". diff --git a/pebble-presence/package.json b/pebble-presence/package.json new file mode 100644 index 0000000..2d58ce7 --- /dev/null +++ b/pebble-presence/package.json @@ -0,0 +1,30 @@ +{ + "name": "whos-home", + "author": "SmartestHome", + "version": "1.0.0", + "private": true, + "keywords": ["pebble-app"], + "dependencies": {}, + "pebble": { + "displayName": "Who's home", + "uuid": "6cbb1f4c-0b6b-46f7-9eef-f1f8db652915", + "sdkVersion": "3", + "enableMultiJS": true, + "watchapp": { + "watchface": false + }, + "messageKeys": [ + "PLAN", + "STATUS" + ], + "resources": { + "media": [] + }, + "targetPlatforms": [ + "basalt", + "chalk", + "diorite", + "emery" + ] + } +} diff --git a/pebble-presence/src/c/main.c b/pebble-presence/src/c/main.c new file mode 100644 index 0000000..b447e70 --- /dev/null +++ b/pebble-presence/src/c/main.c @@ -0,0 +1,511 @@ +/* + * Who's home — a Pebble watchapp showing the household floorplan and who is in each + * room, from identity's GET /floorplan/presence. + * + * IT IS AN APP, NOT A FACE, AND THE BUTTONS ARE WHY + * ------------------------------------------------- + * Watchfaces receive 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 this is a watchapp: launched from the menu rather than shown by raising a + * wrist, and its JS — therefore its data — only lives while it is open. + * + * That last part shapes the UI: every launch begins with a fetch. So the last known + * plan is persisted and drawn IMMEDIATELY with its age, then repainted when the fetch + * lands. A spinner on a screen that already has something true to say is a worse + * answer than slightly stale truth, clearly labelled. + * + * RESOLUTION-INDEPENDENT ON PURPOSE + * ---------------------------------- + * Nothing here hardcodes 260x260 or 200x228. Every position derives from + * layer_get_bounds(), because the target is whatever Pebble is on the wrist and the new + * Round 2's platform identifier is not something this file should be asserting. The + * phone inscribes the plan in a circle for round screens (see index.js); the watch just + * scales 0..255 into its own bounds. + * + * The wire format is WIREFORMAT.md. This file is the reader; src/pkjs/index.js is the + * writer. Change one, change both, and bump the version byte — a silent mismatch + * decodes into garbage rooms rather than failing. + */ + +#include + +#define WIRE_VERSION 1 + +#define MAX_ROOMS 16 +#define MAX_VERTICES 20 +#define MAX_OCCUPANTS 6 +#define MAX_TARGETS 4 +#define MAX_UNPLACED 8 +#define NAME_LEN 25 + +// identity's PERSON_COLORS, same order as the JS table. Every value sits on the +// 2-bit-per-channel lattice a colour Pebble renders natively, so what is drawn here is +// byte-identical to the swatch in the admin panel rather than a dithered approximation. +static GColor person_colour(uint8_t index) { + switch (index) { + case 0: return GColorFromRGB(255, 0, 0); + case 1: return GColorFromRGB(0, 85, 255); + case 2: return GColorFromRGB(255, 170, 0); + case 3: return GColorFromRGB(0, 170, 0); + case 4: return GColorFromRGB(170, 0, 255); + case 5: return GColorFromRGB(0, 170, 170); + case 6: return GColorFromRGB(255, 85, 170); + case 7: return GColorFromRGB(170, 85, 0); + default: return GColorLightGray; + } +} + +typedef struct { + uint8_t colour; + char initial; + bool has_position; + uint8_t px, py; + char name[NAME_LEN]; +} Occupant; + +typedef struct { + char name[NAME_LEN]; + uint8_t state; // 0 empty, 1 occupied, 2 unknown + uint8_t vertex_count; + uint8_t vx[MAX_VERTICES], vy[MAX_VERTICES]; + uint8_t target_count; + uint8_t tx[MAX_TARGETS], ty[MAX_TARGETS]; + uint8_t occupant_count; + Occupant occupants[MAX_OCCUPANTS]; +} Room; + +static Room s_rooms[MAX_ROOMS]; +static uint8_t s_room_count; +static Occupant s_unplaced[MAX_UNPLACED]; +static uint8_t s_unplaced_count; +static bool s_have_plan; +static time_t s_updated_at; +static char s_status[64]; + +// Which room the detail window is showing. s_room_count means the "somewhere in the +// house" entry, which is deliberately part of the same cycle — the people the system +// cannot place are exactly who you picked the watch up to find. +static int s_detail_index; + +static Window *s_plan_window; +static Layer *s_plan_layer; +static Window *s_detail_window; +static Layer *s_detail_layer; + +static const uint32_t PERSIST_PLAN = 1; + +// --- parsing -------------------------------------------------------------------------- +typedef struct { + const uint8_t *data; + uint16_t length; + uint16_t pos; + bool overrun; +} Reader; + +static uint8_t r_u8(Reader *r) { + if (r->pos >= r->length) { + // Every read past the end sets this once and keeps returning zero. The parse is + // abandoned wholesale afterwards: a half-decoded plan looks like a real plan with + // rooms missing, which is the one failure a person could not detect by looking. + r->overrun = true; + return 0; + } + return r->data[r->pos++]; +} + +static void r_str(Reader *r, char *out, size_t cap) { + uint8_t len = r_u8(r); + size_t copied = 0; + for (uint8_t i = 0; i < len; i++) { + uint8_t c = r_u8(r); + if (copied + 1 < cap) out[copied++] = (char)c; + } + out[copied] = '\0'; +} + +static bool parse_plan(const uint8_t *data, uint16_t length) { + Reader r = { .data = data, .length = length, .pos = 0, .overrun = false }; + + uint8_t version = r_u8(&r); + if (version != WIRE_VERSION) { + snprintf(s_status, sizeof(s_status), "Phone app is a different version"); + return false; + } + + uint8_t rooms = r_u8(&r); + uint8_t unplaced = r_u8(&r); + r_u8(&r); // flags — positions_available, currently implied by the targets themselves + + s_room_count = rooms > MAX_ROOMS ? MAX_ROOMS : rooms; + s_unplaced_count = unplaced > MAX_UNPLACED ? MAX_UNPLACED : unplaced; + + for (uint8_t i = 0; i < rooms; i++) { + Room scratch; + Room *room = (i < s_room_count) ? &s_rooms[i] : &scratch; + memset(room, 0, sizeof(Room)); + + r_str(&r, room->name, NAME_LEN); + room->state = r_u8(&r); + + uint8_t vertices = r_u8(&r); + room->vertex_count = vertices > MAX_VERTICES ? MAX_VERTICES : vertices; + for (uint8_t v = 0; v < vertices; v++) { + uint8_t x = r_u8(&r), y = r_u8(&r); + if (v < room->vertex_count) { room->vx[v] = x; room->vy[v] = y; } + } + + uint8_t targets = r_u8(&r); + room->target_count = targets > MAX_TARGETS ? MAX_TARGETS : targets; + for (uint8_t t = 0; t < targets; t++) { + uint8_t x = r_u8(&r), y = r_u8(&r); + if (t < room->target_count) { room->tx[t] = x; room->ty[t] = y; } + } + + uint8_t occupants = r_u8(&r); + room->occupant_count = occupants > MAX_OCCUPANTS ? MAX_OCCUPANTS : occupants; + for (uint8_t o = 0; o < occupants; o++) { + Occupant person; + memset(&person, 0, sizeof(person)); + person.colour = r_u8(&r); + person.initial = (char)r_u8(&r); + uint8_t flags = r_u8(&r); + person.has_position = (flags & 1) != 0; + if (person.has_position) { person.px = r_u8(&r); person.py = r_u8(&r); } + r_str(&r, person.name, NAME_LEN); + if (o < room->occupant_count) room->occupants[o] = person; + } + } + + for (uint8_t i = 0; i < unplaced; i++) { + Occupant person; + memset(&person, 0, sizeof(person)); + person.colour = r_u8(&r); + person.initial = (char)r_u8(&r); + r_str(&r, person.name, NAME_LEN); + if (i < s_unplaced_count) s_unplaced[i] = person; + } + + if (r.overrun) { + s_room_count = 0; + s_unplaced_count = 0; + snprintf(s_status, sizeof(s_status), "Truncated data — nothing shown"); + return false; + } + + s_have_plan = true; + s_updated_at = time(NULL); + return true; +} + +// --- drawing -------------------------------------------------------------------------- +static GPoint to_screen(GRect bounds, uint8_t x, uint8_t y) { + return GPoint(bounds.origin.x + (bounds.size.w * x) / 255, + bounds.origin.y + (bounds.size.h * y) / 255); +} + +static void draw_marker(GContext *ctx, GPoint at, uint8_t colour, char initial, uint8_t radius) { + graphics_context_set_fill_color(ctx, person_colour(colour)); + graphics_fill_circle(ctx, at, radius); + graphics_context_set_stroke_color(ctx, GColorBlack); + graphics_draw_circle(ctx, at, radius); + + // Dark glyph on the person's colour. Every value in the palette is mid-to-bright, so + // a white letter would vanish on the amber — the same reason the admin panel and the + // 3D plan both draw the initial in near-black. + char text[2] = { initial ? initial : '?', '\0' }; + graphics_context_set_text_color(ctx, GColorBlack); + graphics_draw_text(ctx, text, fonts_get_system_font(FONT_KEY_GOTHIC_14_BOLD), + GRect(at.x - radius, at.y - radius + 1, radius * 2, radius * 2), + GTextOverflowModeFill, GTextAlignmentCenter, NULL); +} + +static void draw_plan(Layer *layer, GContext *ctx) { + GRect bounds = layer_get_bounds(layer); + + graphics_context_set_fill_color(ctx, GColorBlack); + graphics_fill_rect(ctx, bounds, 0, GCornerNone); + + if (!s_have_plan || s_room_count == 0) { + graphics_context_set_text_color(ctx, GColorWhite); + graphics_draw_text(ctx, s_status[0] ? s_status : "Loading…", + fonts_get_system_font(FONT_KEY_GOTHIC_18), + GRect(bounds.origin.x + 10, bounds.origin.y + bounds.size.h / 2 - 30, + bounds.size.w - 20, 60), + GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); + return; + } + + // A little inset so a room edge never lands on the bezel — on a round screen the + // outermost pixels are exactly where you least want a wall. The extra room at the + // bottom is for the status line: on a circle a full-width footer would be clipped, + // so it is centred and short. + GRect area = grect_inset(bounds, GEdgeInsets(6)); + area.size.h -= 18; + + for (uint8_t i = 0; i < s_room_count; i++) { + Room *room = &s_rooms[i]; + if (room->vertex_count < 3) continue; + + GPoint points[MAX_VERTICES]; + for (uint8_t v = 0; v < room->vertex_count; v++) { + points[v] = to_screen(area, room->vx[v], room->vy[v]); + } + GPathInfo info = { .num_points = room->vertex_count, .points = points }; + GPath *path = gpath_create(&info); + + // OCCUPANCY READS IN LIGHTNESS, NOT HUE. The occupant markers already use colour to + // mean *who*; if the room fill competed on the same channel neither would be + // legible on a 64-colour panel. Lit rooms are the bright shapes. + graphics_context_set_fill_color(ctx, room->state == 1 ? GColorWhite : GColorOxfordBlue); + gpath_draw_filled(ctx, path); + + graphics_context_set_stroke_color(ctx, room->state == 1 ? GColorWhite : GColorLightGray); + gpath_draw_outline(ctx, path); + gpath_destroy(path); + + // The third state — drawn, but HA never reports this area — gets a dotted edge. + // "Nobody is in the study" and "nothing can see the study" are different sentences, + // and rendering the second as the first is a quiet lie. + if (room->state == 2) { + graphics_context_set_stroke_color(ctx, GColorDarkGray); + for (uint8_t v = 0; v < room->vertex_count; v++) { + GPoint a = points[v]; + GPoint b = points[(v + 1) % room->vertex_count]; + graphics_draw_line(ctx, a, GPoint((a.x + b.x) / 2, (a.y + b.y) / 2)); + } + } + } + + // The age of what is on screen, always. The app's JS only lives while it is open, so + // a launch draws the last known plan before the fetch lands — and a stale plan that + // does not say it is stale is the one way this app could mislead somebody. + char footer[48]; + if (s_status[0]) { + snprintf(footer, sizeof(footer), "%s", s_status); + } else { + int minutes = (int)((time(NULL) - s_updated_at) / 60); + if (minutes < 1) snprintf(footer, sizeof(footer), "just now"); + else if (minutes < 60) snprintf(footer, sizeof(footer), "%d min ago", minutes); + else snprintf(footer, sizeof(footer), "%dh ago", minutes / 60); + } + graphics_context_set_text_color(ctx, GColorLightGray); + graphics_draw_text(ctx, footer, fonts_get_system_font(FONT_KEY_GOTHIC_14_BOLD), + GRect(bounds.origin.x + 8, bounds.origin.y + bounds.size.h - 20, + bounds.size.w - 16, 18), + GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); + + // Markers after every room, so one is never painted over by a later room's fill. + for (uint8_t i = 0; i < s_room_count; i++) { + Room *room = &s_rooms[i]; + if (room->vertex_count < 3) continue; + + int32_t cx = 0, cy = 0; + for (uint8_t v = 0; v < room->vertex_count; v++) { cx += room->vx[v]; cy += room->vy[v]; } + GPoint centre = to_screen(area, cx / room->vertex_count, cy / room->vertex_count); + + // Anonymous radar targets: somebody is there and nobody knows who. A hollow ring, + // never an initial — every visual language this app has for a PERSON is reserved + // for people it can name. + for (uint8_t t = 0; t < room->target_count; t++) { + GPoint at = to_screen(area, room->tx[t], room->ty[t]); + graphics_context_set_stroke_color(ctx, GColorDarkGray); + graphics_draw_circle(ctx, at, 5); + } + + uint8_t placed = 0; + for (uint8_t o = 0; o < room->occupant_count; o++) { + Occupant *person = &room->occupants[o]; + if (person->has_position) { + draw_marker(ctx, to_screen(area, person->px, person->py), person->colour, person->initial, 8); + } else { + // Fan the unpositioned ones around the room's centre so two people in one room + // do not draw on top of each other. + int16_t offset = (int16_t)(placed * 17) - 8; + draw_marker(ctx, GPoint(centre.x + offset, centre.y), person->colour, person->initial, 8); + placed++; + } + } + } +} + +static void draw_detail(Layer *layer, GContext *ctx) { + GRect bounds = layer_get_bounds(layer); + graphics_context_set_fill_color(ctx, GColorBlack); + graphics_fill_rect(ctx, bounds, 0, GCornerNone); + graphics_context_set_text_color(ctx, GColorWhite); + + const char *title; + Occupant *people; + uint8_t count; + if (s_detail_index >= s_room_count) { + title = "Somewhere in the house"; + people = s_unplaced; + count = s_unplaced_count; + } else { + title = s_rooms[s_detail_index].name; + people = s_rooms[s_detail_index].occupants; + count = s_rooms[s_detail_index].occupant_count; + } + + // Round screens clip the corners, so everything is centred and inset rather than + // left-aligned against an edge that may not exist. + GRect area = grect_inset(bounds, GEdgeInsets(10)); + graphics_draw_text(ctx, title, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD), + GRect(area.origin.x, area.origin.y + 6, area.size.w, 30), + GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); + + int16_t y = area.origin.y + 42; + if (count == 0) { + // "Nobody here" is a finding, not an empty screen. A blank list reads as a fault. + graphics_draw_text(ctx, s_detail_index >= s_room_count ? "Everyone is placed" : "Nobody here", + fonts_get_system_font(FONT_KEY_GOTHIC_18), + GRect(area.origin.x, y, area.size.w, 24), + GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); + return; + } + + for (uint8_t i = 0; i < count && y < area.origin.y + area.size.h - 20; i++) { + GPoint dot = GPoint(area.origin.x + 14, y + 10); + graphics_context_set_fill_color(ctx, person_colour(people[i].colour)); + graphics_fill_circle(ctx, dot, 6); + + // The dot stays even though there is room for the name: it is what ties this line + // back to the marker on the plan, and to the same person in the admin panel. + char line[NAME_LEN + 2]; + if (people[i].name[0]) { + snprintf(line, sizeof(line), "%s", people[i].name); + } else { + snprintf(line, sizeof(line), "%c", people[i].initial ? people[i].initial : '?'); + } + graphics_context_set_text_color(ctx, GColorWhite); + graphics_draw_text(ctx, line, fonts_get_system_font(FONT_KEY_GOTHIC_18), + GRect(area.origin.x + 26, y, area.size.w - 30, 22), + GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL); + y += 22; + } +} + +// --- windows -------------------------------------------------------------------------- +static void detail_cycle(int delta) { + int total = s_room_count + (s_unplaced_count ? 1 : 0); + if (total <= 0) return; + s_detail_index = (s_detail_index + delta + total) % total; // wraps at both ends + layer_mark_dirty(s_detail_layer); +} + +static void detail_up(ClickRecognizerRef recognizer, void *context) { detail_cycle(-1); } +static void detail_down(ClickRecognizerRef recognizer, void *context) { detail_cycle(1); } + +static void detail_click_config(void *context) { + window_single_click_subscribe(BUTTON_ID_UP, detail_up); + window_single_click_subscribe(BUTTON_ID_DOWN, detail_down); +} + +static void detail_load(Window *window) { + s_detail_layer = layer_create(layer_get_bounds(window_get_root_layer(window))); + layer_set_update_proc(s_detail_layer, draw_detail); + layer_add_child(window_get_root_layer(window), s_detail_layer); +} + +static void detail_unload(Window *window) { layer_destroy(s_detail_layer); } + +static void plan_select(ClickRecognizerRef recognizer, void *context) { + if (!s_have_plan || s_room_count == 0) return; + s_detail_index = 0; + window_stack_push(s_detail_window, true); +} + +// Up/Down on the plan screen ask the phone for a refresh. Cheap, and it is what a +// person reaches for when the age line says the data is old. +static void plan_refresh(ClickRecognizerRef recognizer, void *context) { + DictionaryIterator *out; + if (app_message_outbox_begin(&out) == APP_MSG_OK) { + dict_write_uint8(out, MESSAGE_KEY_STATUS, 1); + app_message_outbox_send(); + } + snprintf(s_status, sizeof(s_status), "Refreshing…"); + layer_mark_dirty(s_plan_layer); +} + +static void plan_click_config(void *context) { + window_single_click_subscribe(BUTTON_ID_SELECT, plan_select); + window_single_click_subscribe(BUTTON_ID_UP, plan_refresh); + window_single_click_subscribe(BUTTON_ID_DOWN, plan_refresh); +} + +static void plan_load(Window *window) { + s_plan_layer = layer_create(layer_get_bounds(window_get_root_layer(window))); + layer_set_update_proc(s_plan_layer, draw_plan); + layer_add_child(window_get_root_layer(window), s_plan_layer); +} + +static void plan_unload(Window *window) { layer_destroy(s_plan_layer); } + +// --- app message ---------------------------------------------------------------------- +static void inbox_received(DictionaryIterator *iterator, void *context) { + Tuple *status = dict_find(iterator, MESSAGE_KEY_STATUS); + if (status && status->type == TUPLE_CSTRING) { + snprintf(s_status, sizeof(s_status), "%s", status->value->cstring); + } + + Tuple *plan = dict_find(iterator, MESSAGE_KEY_PLAN); + if (plan && plan->type == TUPLE_BYTE_ARRAY) { + if (parse_plan(plan->value->data, plan->length)) { + persist_write_data(PERSIST_PLAN, plan->value->data, plan->length); + } + } + + layer_mark_dirty(s_plan_layer); + if (s_detail_layer) layer_mark_dirty(s_detail_layer); +} + +static void inbox_dropped(AppMessageResult reason, void *context) { + snprintf(s_status, sizeof(s_status), "Message dropped"); + layer_mark_dirty(s_plan_layer); +} + +static void load_persisted(void) { + if (!persist_exists(PERSIST_PLAN)) return; + // Drawn immediately, before the fetch lands. The app's JS only lives while it is + // open, so every launch starts with a round trip — showing the last known plan beats + // showing a spinner, as long as it is labelled, which the status line does. + uint8_t buffer[2048]; + int length = persist_read_data(PERSIST_PLAN, buffer, sizeof(buffer)); + if (length > 0) { + parse_plan(buffer, (uint16_t)length); + snprintf(s_status, sizeof(s_status), "Last known — refreshing…"); + } +} + +static void init(void) { + snprintf(s_status, sizeof(s_status), "Loading…"); + load_persisted(); + + s_plan_window = window_create(); + window_set_window_handlers(s_plan_window, (WindowHandlers){ .load = plan_load, .unload = plan_unload }); + window_set_click_config_provider(s_plan_window, plan_click_config); + + s_detail_window = window_create(); + window_set_window_handlers(s_detail_window, (WindowHandlers){ .load = detail_load, .unload = detail_unload }); + window_set_click_config_provider(s_detail_window, detail_click_config); + + app_message_register_inbox_received(inbox_received); + app_message_register_inbox_dropped(inbox_dropped); + // Inbox large enough for the whole plan; outbox tiny, because the only thing this + // ever sends is "please refresh". + app_message_open(2048, 64); + + window_stack_push(s_plan_window, true); +} + +static void deinit(void) { + window_destroy(s_plan_window); + window_destroy(s_detail_window); +} + +int main(void) { + init(); + app_event_loop(); + deinit(); + return 0; +} diff --git a/pebble-presence/src/pkjs/index.js b/pebble-presence/src/pkjs/index.js new file mode 100644 index 0000000..e296f14 --- /dev/null +++ b/pebble-presence/src/pkjs/index.js @@ -0,0 +1,312 @@ +/* + * PebbleKit JS — runs on the phone, inside the Pebble app, only while the watchapp is + * open. It fetches identity's /floorplan/presence, projects the plan into the watch's + * usable box, packs it per WIREFORMAT.md, and sends one AppMessage. + * + * THE PROJECTION HAPPENS HERE, NOT ON THE WATCH + * ---------------------------------------------- + * The watch has 64 KB of RAM and no floating point worth using. Everything that needs + * arithmetic — the inscribed box for a round screen, scaling normalised polygons, + * dropping rooms too small to draw — happens on the phone, and the watch receives + * integers it can draw directly. + * + * THE ROUND SCREEN IS THE REASON THIS IS NOT TRIVIAL + * --------------------------------------------------- + * A floorplan is a rectangle; the Round 2's screen is a circle. The plan is inscribed + * in it, so for a plan of aspect ratio a on usable diameter D: + * + * w = D*a / sqrt(a^2+1) h = D / sqrt(a^2+1) + * + * The corners of the screen are simply not available, which is why nothing important + * (no status footer, no legend) is placed there. + */ + +var CONFIG_KEY = "presenceConfig"; + +function config() { + var stored = localStorage.getItem(CONFIG_KEY); + var parsed = {}; + try { + parsed = stored ? JSON.parse(stored) : {}; + } catch (e) { + parsed = {}; + } + return { + apiUrl: (parsed.apiUrl || "").replace(/\/$/, ""), + token: parsed.token || "", + level: parsed.level || "", + }; +} + +// identity's PERSON_COLORS, in order. Sent as an index rather than as RGB: one byte +// instead of three, and the watch holds the same table so the colour on the wrist is +// byte-identical to the one in the admin panel. Every value sits on the 2-bit-per- +// channel lattice a colour Pebble renders natively, which is why they survive the trip. +var PERSON_COLORS = [ + "#FF0000", "#0055FF", "#FFAA00", "#00AA00", + "#AA00FF", "#00AAAA", "#FF55AA", "#AA5500", +]; + +function colourIndex(hex) { + var upper = String(hex || "").toUpperCase(); + var index = PERSON_COLORS.indexOf(upper); + return index === -1 ? 255 : index; +} + +// --- byte writer ---------------------------------------------------------------------- +function Writer() { + this.bytes = []; +} +Writer.prototype.u8 = function (value) { + this.bytes.push(Math.max(0, Math.min(255, value | 0))); + return this; +}; +Writer.prototype.str = function (text, cap) { + // Truncated to whole BYTES, not characters — a UTF-8 sequence cut in half would + // decode to a replacement glyph on the watch, so the cut is made on a code-point + // boundary by shortening the string until it fits. + var s = String(text || ""); + var encoded = utf8(s); + while (encoded.length > cap) { + s = s.slice(0, -1); + encoded = utf8(s); + } + this.u8(encoded.length); + for (var i = 0; i < encoded.length; i++) this.bytes.push(encoded[i]); + return this; +}; + +function utf8(str) { + var out = []; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c < 0x80) out.push(c); + else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + return out; +} + +// --- projection ----------------------------------------------------------------------- +/** + * The box a rectangular plan gets on this watch, as fractions of the screen, then + * quantised to 0..255. Returns a function mapping normalised plan coords into it. + */ +function projector(aspect, round) { + if (!round) { + // Rectangular screens use the whole area; the caller's margin is already applied. + return function (x, y) { + return [Math.round(x * 255), Math.round(y * 255)]; + }; + } + var denom = Math.sqrt(aspect * aspect + 1); + var w = aspect / denom; + var h = 1 / denom; + var ox = (1 - w) / 2; + var oy = (1 - h) / 2; + return function (x, y) { + return [Math.round((ox + x * w) * 255), Math.round((oy + y * h) * 255)]; + }; +} + +// --- payload -------------------------------------------------------------------------- +function build(plan, level, round, nameCap) { + var writer = new Writer(); + var rooms = (level.rooms || []).filter(function (r) { + return (r.points || []).length >= 3; + }); + var unplaced = plan.unplaced || []; + + // A plan drawn wider than tall has aspect > 1. The polygons are already normalised to + // the level's own extent, so the aspect is that extent's — which identity does not + // report, and 1:1 is the honest assumption rather than a guess at the house's shape. + var project = projector(1, round); + + writer.u8(1).u8(rooms.length).u8(unplaced.length).u8(plan.positions_available ? 1 : 0); + + rooms.forEach(function (room) { + writer.str(room.name, 24); + + var occupants = room.occupants || []; + var targets = room.targets || []; + // Matches floorplan3d.js's rule exactly, and for the same reason: a radar target + // means somebody IS in there. That the house cannot name them is a fact about the + // house, not about the room being empty. + var state = occupants.length || targets.length ? 1 : room.ha_area_id ? 0 : 2; + writer.u8(state); + + writer.u8(room.points.length); + room.points.forEach(function (p) { + var xy = project(p[0], p[1]); + writer.u8(xy[0]).u8(xy[1]); + }); + + writer.u8(targets.length); + targets.forEach(function (t) { + var xy = project(t.x, t.y); + writer.u8(xy[0]).u8(xy[1]); + }); + + writer.u8(occupants.length); + occupants.forEach(function (person) { + writer.u8(colourIndex(person.color)); + writer.u8((person.initial || "?").charCodeAt(0)); + var hasPosition = !!person.position; + writer.u8(hasPosition ? 1 : 0); + if (hasPosition) { + var xy = project(person.position.x, person.position.y); + writer.u8(xy[0]).u8(xy[1]); + } + writer.str(nameCap ? person.name : "", nameCap); + }); + }); + + unplaced.forEach(function (person) { + writer.u8(colourIndex(person.color)); + writer.u8((person.initial || "?").charCodeAt(0)); + writer.str(nameCap ? person.name : "", nameCap); + }); + + return writer.bytes; +} + +/** + * Build, and shed detail until it fits. Order is deliberate — see WIREFORMAT.md. It + * never sends a truncated structure: a payload that decodes half-way is worse than one + * that does not arrive, because the watch cannot tell "three rooms" from "three rooms + * and then the buffer ran out". + */ +function buildWithinBudget(plan, level, round, budget) { + var bytes = build(plan, level, round, 16); + if (bytes.length <= budget) return { bytes: bytes, degraded: null }; + + bytes = build(plan, level, round, 0); + if (bytes.length <= budget) return { bytes: bytes, degraded: "names dropped" }; + + var trimmed = { + rooms: (level.rooms || []).filter(function (r) { + return (r.occupants || []).length || (r.targets || []).length; + }), + }; + bytes = build(plan, trimmed, round, 0); + if (bytes.length <= budget) return { bytes: bytes, degraded: "empty rooms dropped" }; + + return { bytes: null, degraded: "plan too large for this watch" }; +} + +// --- fetch ---------------------------------------------------------------------------- +function send(bytes, status) { + var message = { STATUS: status || "" }; + if (bytes) message.PLAN = bytes; + Pebble.sendAppMessage(message, function () {}, function (e) { + console.log("presence: send failed: " + JSON.stringify(e)); + }); +} + +function refresh() { + var settings = config(); + if (!settings.apiUrl || !settings.token) { + send(null, "Not set up — open the app's settings on your phone."); + return; + } + + var request = new XMLHttpRequest(); + request.open("GET", settings.apiUrl + "/floorplan/presence", true); + request.setRequestHeader("Authorization", "Bearer " + settings.token); + request.timeout = 12000; + + request.onload = function () { + if (request.status !== 200) { + send(null, "identity said " + request.status); + return; + } + var plan; + try { + plan = JSON.parse(request.responseText); + } catch (e) { + send(null, "Bad response from identity"); + return; + } + var levels = plan.levels || []; + if (!levels.length) { + send(null, "No floorplan drawn yet"); + return; + } + var level = + levels.filter(function (l) { + return String(l.id) === String(settings.level); + })[0] || levels[0]; + + var round = Pebble.getActiveWatchInfo + ? ["chalk"].indexOf(Pebble.getActiveWatchInfo().platform) !== -1 + : false; + + var result = buildWithinBudget(plan, level, round, 1800); + if (!result.bytes) { + send(null, result.degraded); + return; + } + send(result.bytes, result.degraded || level.name || ""); + }; + + request.ontimeout = function () { + // Off the home network without the tunnel up is the common case, and it deserves + // its own sentence rather than a generic failure — the watch shows its last state + // with an age on it either way. + send(null, "No answer — is the WireGuard tunnel up?"); + }; + request.onerror = function () { + send(null, "Could not reach identity"); + }; + request.send(); +} + +Pebble.addEventListener("ready", refresh); +Pebble.addEventListener("appmessage", refresh); // the watch asking for a refresh + +Pebble.addEventListener("showConfiguration", function () { + var settings = config(); + var html = + "data:text/html," + + encodeURIComponent( + "" + + "" + + "

Who's home

" + + "Reachable from your phone — on the LAN directly, off it over WireGuard. " + + "Nothing here is exposed to the internet." + + "identity's IDENTITY_TOKEN. It can read the household's presence " + + "history, so treat it as one." + + "Blank uses the first level." + + "" + ); + Pebble.openURL(html); +}); + +Pebble.addEventListener("webviewclosed", function (event) { + if (!event.response) return; + try { + localStorage.setItem(CONFIG_KEY, JSON.stringify(JSON.parse(decodeURIComponent(event.response)))); + refresh(); + } catch (e) { + console.log("presence: bad settings payload"); + } +}); + +// Exported for the headless test in tools/. Harmless on the phone, where `module` is +// undefined — and worth having, because the writer and the C reader agreeing is the one +// thing in this app that cannot be checked by looking at it. +if (typeof module !== "undefined") { + module.exports = { build: build, buildWithinBudget: buildWithinBudget, projector: projector, utf8: utf8 }; +} diff --git a/pebble-presence/test/make-payload.js b/pebble-presence/test/make-payload.js new file mode 100644 index 0000000..d969012 --- /dev/null +++ b/pebble-presence/test/make-payload.js @@ -0,0 +1,46 @@ +/* Builds a payload with the REAL writer from src/pkjs/index.js and prints it as hex. */ +const path = require("path"); +global.Pebble = { addEventListener() {} }; +global.localStorage = { getItem: () => null, setItem() {} }; +global.XMLHttpRequest = function () {}; +const writer = require(path.join(__dirname, "..", "src", "pkjs", "index.js")); + +const plan = { + positions_available: true, + unplaced: [{ name: "Bibi", initial: "B", color: "#AA00FF" }], +}; +const level = { + name: "Ground", + rooms: [ + { + name: "Kitchen", + ha_area_id: "kitchen", + points: [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5]], + targets: [], + occupants: [ + { id: 1, name: "Amir", initial: "A", color: "#FF0000", position: { x: 0.25, y: 0.25 } }, + ], + }, + { + // A radar target and nobody named. This must read as OCCUPIED even though HA + // never reports the area: somebody is in there, and a positive detection + // outranks "no area data". + name: "Loggia", + ha_area_id: null, + points: [[0.6, 0], [1, 0], [1, 0.4], [0.6, 0.4]], + targets: [{ x: 0.8, y: 0.2 }], + occupants: [], + }, + { + // Genuinely unknown: drawn, no area, nothing detected. The third state. + name: "Store", + ha_area_id: null, + points: [[0.6, 0.6], [1, 0.6], [1, 1], [0.6, 1]], + targets: [], + occupants: [], + }, + ], +}; + +const bytes = writer.build(plan, level, false, 16); +process.stdout.write(bytes.map((b) => b.toString(16).padStart(2, "0")).join(" ")); diff --git a/pebble-presence/test/pebble.h b/pebble-presence/test/pebble.h new file mode 100644 index 0000000..bd4cc83 --- /dev/null +++ b/pebble-presence/test/pebble.h @@ -0,0 +1,3 @@ +/* Shim so src/c/main.c's `#include ` resolves during the headless test. + The real SDK provides this; here it forwards to the stub. */ +#include "pebble_stub.h" diff --git a/pebble-presence/test/pebble_stub.h b/pebble-presence/test/pebble_stub.h new file mode 100644 index 0000000..d0107c5 --- /dev/null +++ b/pebble-presence/test/pebble_stub.h @@ -0,0 +1,116 @@ +/* + * A stub of the Pebble SDK, just large enough to compile src/c/main.c on a normal + * machine so the parser can be tested against the JS writer's real output. + * + * NOT a simulator and not a substitute for building with the real SDK — it type-checks + * the file and lets the wire format be verified end to end, which is the one property + * of this app that cannot be checked by reading it. Every drawing call is a no-op. + */ +#ifndef PEBBLE_STUB_H +#define PEBBLE_STUB_H + +#include +#include +#include +#include +#include +#include + +typedef struct { int16_t x, y; } GPoint; +typedef struct { int16_t w, h; } GSize; +typedef struct { GPoint origin; GSize size; } GRect; +typedef struct { uint8_t argb; } GColor; +typedef struct { int16_t top, right, bottom, left; } GEdgeInsets_t; + +#define GPoint(a, b) ((GPoint){ .x = (a), .y = (b) }) +#define GRect(a, b, c, d) ((GRect){ .origin = { (a), (b) }, .size = { (c), (d) } }) +#define GEdgeInsets(v) ((GEdgeInsets_t){ (v), (v), (v), (v) }) + +static inline GColor GColorFromRGB(int r, int g, int b) { (void)r; (void)g; (void)b; return (GColor){0}; } +#define GColorBlack ((GColor){0}) +#define GColorWhite ((GColor){1}) +#define GColorLightGray ((GColor){2}) +#define GColorDarkGray ((GColor){3}) +#define GColorOxfordBlue ((GColor){4}) + +typedef void GContext; +typedef struct Layer Layer; +typedef struct Window Window; +typedef void *ClickRecognizerRef; +typedef struct { void (*load)(Window *); void (*unload)(Window *); } WindowHandlers; + +typedef enum { GTextOverflowModeFill, GTextOverflowModeWordWrap, GTextOverflowModeTrailingEllipsis } GTextOverflowMode; +typedef enum { GTextAlignmentLeft, GTextAlignmentCenter } GTextAlignment; +typedef enum { GCornerNone } GCornerMask; +typedef enum { BUTTON_ID_UP, BUTTON_ID_DOWN, BUTTON_ID_SELECT } ButtonId; +typedef enum { APP_MSG_OK = 0 } AppMessageResult; + +#define FONT_KEY_GOTHIC_14_BOLD "g14b" +#define FONT_KEY_GOTHIC_18 "g18" +#define FONT_KEY_GOTHIC_24_BOLD "g24b" +typedef void *GFont; +static inline GFont fonts_get_system_font(const char *k) { (void)k; return NULL; } + +typedef struct { uint32_t num_points; GPoint *points; } GPathInfo; +typedef struct { int unused; } GPath; +static inline GPath *gpath_create(GPathInfo *i) { (void)i; static GPath p; return &p; } +static inline void gpath_draw_filled(GContext *c, GPath *p) { (void)c; (void)p; } +static inline void gpath_draw_outline(GContext *c, GPath *p) { (void)c; (void)p; } +static inline void gpath_destroy(GPath *p) { (void)p; } + +static inline GRect layer_get_bounds(Layer *l) { (void)l; return GRect(0, 0, 180, 180); } +static inline GRect grect_inset(GRect r, GEdgeInsets_t i) { + r.origin.x += i.left; r.origin.y += i.top; + r.size.w -= (i.left + i.right); r.size.h -= (i.top + i.bottom); + return r; +} +static inline void graphics_context_set_fill_color(GContext *c, GColor g) { (void)c; (void)g; } +static inline void graphics_context_set_stroke_color(GContext *c, GColor g) { (void)c; (void)g; } +static inline void graphics_context_set_text_color(GContext *c, GColor g) { (void)c; (void)g; } +static inline void graphics_fill_rect(GContext *c, GRect r, int radius, GCornerMask m) { (void)c; (void)r; (void)radius; (void)m; } +static inline void graphics_fill_circle(GContext *c, GPoint p, int r) { (void)c; (void)p; (void)r; } +static inline void graphics_draw_circle(GContext *c, GPoint p, int r) { (void)c; (void)p; (void)r; } +static inline void graphics_draw_line(GContext *c, GPoint a, GPoint b) { (void)c; (void)a; (void)b; } +static inline void graphics_draw_text(GContext *c, const char *t, GFont f, GRect r, + GTextOverflowMode o, GTextAlignment a, void *attr) { + (void)c; (void)t; (void)f; (void)r; (void)o; (void)a; (void)attr; +} + +static inline Layer *layer_create(GRect r) { (void)r; return (Layer *)malloc(1); } +static inline void layer_destroy(Layer *l) { free(l); } +static inline void layer_set_update_proc(Layer *l, void (*p)(Layer *, GContext *)) { (void)l; (void)p; } +static inline void layer_add_child(Layer *p, Layer *c) { (void)p; (void)c; } +static inline void layer_mark_dirty(Layer *l) { (void)l; } + +static inline Window *window_create(void) { return (Window *)malloc(1); } +static inline void window_destroy(Window *w) { free(w); } +static inline Layer *window_get_root_layer(Window *w) { (void)w; static char l; return (Layer *)&l; } +static inline void window_set_window_handlers(Window *w, WindowHandlers h) { (void)w; (void)h; } +static inline void window_set_click_config_provider(Window *w, void (*p)(void *)) { (void)w; (void)p; } +static inline void window_stack_push(Window *w, bool a) { (void)w; (void)a; } +static inline void window_single_click_subscribe(ButtonId b, void (*h)(ClickRecognizerRef, void *)) { (void)b; (void)h; } + +typedef struct { int unused; } DictionaryIterator; +typedef struct { + int type; uint16_t length; + union { const char *cstring; const uint8_t *data; } *value; +} Tuple; +#define TUPLE_CSTRING 1 +#define TUPLE_BYTE_ARRAY 2 +#define MESSAGE_KEY_PLAN 1 +#define MESSAGE_KEY_STATUS 2 +static inline Tuple *dict_find(DictionaryIterator *i, uint32_t k) { (void)i; (void)k; return NULL; } +static inline int dict_write_uint8(DictionaryIterator *i, uint32_t k, uint8_t v) { (void)i; (void)k; (void)v; return 0; } +static inline int app_message_outbox_begin(DictionaryIterator **i) { (void)i; return APP_MSG_OK; } +static inline int app_message_outbox_send(void) { return 0; } +static inline void app_message_register_inbox_received(void (*h)(DictionaryIterator *, void *)) { (void)h; } +static inline void app_message_register_inbox_dropped(void (*h)(AppMessageResult, void *)) { (void)h; } +static inline void app_message_open(uint32_t in, uint32_t out) { (void)in; (void)out; } + +static inline bool persist_exists(uint32_t k) { (void)k; return false; } +static inline int persist_read_data(uint32_t k, void *b, size_t s) { (void)k; (void)b; (void)s; return 0; } +static inline int persist_write_data(uint32_t k, const void *b, size_t s) { (void)k; (void)b; (void)s; return (int)s; } + +static inline void app_event_loop(void) {} + +#endif diff --git a/pebble-presence/test/run-tests.sh b/pebble-presence/test/run-tests.sh new file mode 100755 index 0000000..7d40fd0 --- /dev/null +++ b/pebble-presence/test/run-tests.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Round-trip the JS writer through the C reader. No Pebble SDK required — see +# pebble_stub.h for what is stubbed and what that does and does not prove. +set -eu +cd "$(dirname "$0")" + +echo "--- generating a payload with the real JS writer ---" +node make-payload.js > /tmp/pebble-payload.hex +echo "--- compiling the C reader against the stub SDK ---" +cc -std=c11 -I. -Wall -Wextra -Wno-unused-parameter -Wno-unused-function \ + -o /tmp/wireformat_test wireformat_test.c +echo "--- decoding ---" +/tmp/wireformat_test < /tmp/pebble-payload.hex diff --git a/pebble-presence/test/wireformat_test.c b/pebble-presence/test/wireformat_test.c new file mode 100644 index 0000000..645eea1 --- /dev/null +++ b/pebble-presence/test/wireformat_test.c @@ -0,0 +1,72 @@ +/* + * Round-trips the JS writer's real output through the C reader. + * + * The writer (src/pkjs/index.js) and the reader (src/c/main.c) are two implementations + * of one format, in two languages, that never run in the same process. Nothing but a + * test like this can catch them drifting apart — and drift decodes as plausible-looking + * garbage rather than as an error, which is the failure worth spending a harness on. + * + * Build: see run-tests.sh. Reads the payload as hex on stdin. + */ +#include "pebble_stub.h" + +/* main.c includes ; the build passes -include pebble_stub.h and -Dpebble_h + so that include resolves to nothing. See run-tests.sh. */ +#define main pebble_main /* keep main.c's entry point out of the way */ +#include "../src/c/main.c" +#undef main + +static int failures = 0; + +static void check(const char *label, bool ok) { + printf(" %s %s\n", ok ? "PASS" : "FAIL", label); + if (!ok) failures++; +} + +int main(void) { + static uint8_t buffer[4096]; + uint16_t length = 0; + unsigned int byte; + while (scanf("%2x", &byte) == 1 && length < sizeof(buffer)) buffer[length++] = (uint8_t)byte; + + printf("payload: %u bytes\n", length); + bool ok = parse_plan(buffer, length); + check("parses", ok); + if (!ok) { printf(" status: %s\n", s_status); return 1; } + + check("room count is 3", s_room_count == 3); + check("room 0 is named Kitchen", strcmp(s_rooms[0].name, "Kitchen") == 0); + check("room 0 is occupied", s_rooms[0].state == 1); + check("room 0 has 4 vertices", s_rooms[0].vertex_count == 4); + check("room 0 has 1 occupant", s_rooms[0].occupant_count == 1); + check("occupant initial is A", s_rooms[0].occupants[0].initial == 'A'); + check("occupant colour index 0", s_rooms[0].occupants[0].colour == 0); + check("occupant has a position", s_rooms[0].occupants[0].has_position); + check("occupant name survived", strcmp(s_rooms[0].occupants[0].name, "Amir") == 0); + + check("room 1 is named Loggia", strcmp(s_rooms[1].name, "Loggia") == 0); + /* A radar target outranks "HA never reports this area": somebody IS in there, and a + positive detection beats no data. The first draft of this test asserted the + opposite and was wrong. */ + check("room 1 is occupied (radar, nobody named)", s_rooms[1].state == 1); + check("room 1 has an anonymous target", s_rooms[1].target_count == 1); + check("room 1 has no occupants", s_rooms[1].occupant_count == 0); + + check("room 2 is the unknown state", s_rooms[2].state == 2); + check("room 2 has no targets", s_rooms[2].target_count == 0); + + check("1 unplaced person", s_unplaced_count == 1); + check("unplaced is Bibi", strcmp(s_unplaced[0].name, "Bibi") == 0); + + /* A truncated payload must be refused wholesale, never half-decoded: a plan missing + its last rooms looks exactly like a real plan. */ + bool truncated = parse_plan(buffer, length / 2); + check("truncated payload refused", !truncated && s_room_count == 0); + + /* A version bump on the phone must not decode as garbage here. */ + buffer[0] = 99; + check("wrong version refused", !parse_plan(buffer, length)); + + printf(failures ? "\nFAILURES: %d\n" : "\nall wire-format checks passed\n", failures); + return failures ? 1 : 0; +}