3.8 KiB
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:
- Occupant names — the detail screen loses full names and falls back to initials. Worst outcome of the three and still legible.
- Rooms with no occupants — an empty room is the least informative thing on the plan.
- 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".
What is NOT in this format: the toggles line
The toggles screen (README.md) sends its own message, as plain text:
name|1|Loggia\nname|0|Desk
One record per line, three fields: name, 1/0 for on, and the detail string the watch
displays. The watch replies with the row INDEX it was looking at, never an id, so no
identifier string has to be stored on the watch or copied back.
It is deliberately not squeezed into the binary format above. That format exists because
a floorplan does not otherwise fit in a ~2 KB AppMessage; a handful of short strings with
no geometry gains nothing from it and would lose the readability that makes the format
worth auditing. What the two share is the discipline: the packer (src/pkjs/index.js)
and the parser (src/c/main.c) are round-tripped against each other in
test/run-tests.sh, including the two cases that would otherwise decode as plausible
garbage — a value containing the | separator, and a record truncated mid-way.
Field lengths are capped by the writer to the C buffers' sizes (TOGGLE_NAME_LEN,
TOGGLE_DETAIL_LEN), so truncation happens once, on the phone, rather than differently
on each side.