SmartestHome/pebble-presence/WIREFORMAT.md

3.8 KiB
Raw Blame History

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.01.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".

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.