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; +}