/* * 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(); } // --- toggles --------------------------------------------------------------------------- // The watch's second screen: flip one Home Assistant switch, and show what it is // currently doing. It goes through identity's /toggles rather than straight at Home // Assistant, because identity already holds an HA token and this phone already holds // identity's — putting an HA admin token in a watchapp's settings to press one button // would be the worst credential trade in the household. identity only serves an // allowlist, so this can never reach anything but the switches somebody configured. // // PACKED AS TEXT, not through the binary plan format: a few short strings with no // geometry gain nothing from a format built to squeeze a floorplan into 2 KB. // // name|1|Loggia\nname|0|Desk // // Field and record separators are stripped from the values below, because a microphone // called "Desk | Loft" would otherwise silently become two fields. The slices match the // C buffers in main.c (TOGGLE_NAME_LEN / TOGGLE_DETAIL_LEN, minus the terminator) so // truncation happens here, once, rather than differently on each side. var MAX_TOGGLES = 4; function packToggles(toggles) { return (toggles || []) .slice(0, MAX_TOGGLES) .map(function (toggle) { var name = String(toggle.name || "").replace(/[|\n\r]/g, " ").slice(0, 21); var detail = String(toggle.detail || "").replace(/[|\n\r]/g, " ").slice(0, 15); return name + "|" + (toggle.on ? "1" : "0") + "|" + detail; }) .join("\n"); } // index -> identity's toggle id. The watch never holds an id: it replies with the row // it was looking at, and the mapping stays here. var toggleIds = []; function sendToggles(packed) { Pebble.sendAppMessage({ TOGGLES: packed }, function () {}, function (e) { console.log("presence: toggle send failed: " + JSON.stringify(e)); }); } function refreshToggles() { var settings = config(); if (!settings.apiUrl || !settings.token) { sendToggles(""); return; } var request = new XMLHttpRequest(); request.open("GET", settings.apiUrl + "/toggles", true); request.setRequestHeader("Authorization", "Bearer " + settings.token); request.timeout = 12000; request.onload = function () { if (request.status !== 200) { sendToggles(""); return; } var toggles = []; try { toggles = (JSON.parse(request.responseText) || {}).toggles || []; } catch (e) { toggles = []; } toggleIds = toggles.map(function (toggle) { return toggle.id; }); sendToggles(packToggles(toggles)); }; request.ontimeout = function () { sendToggles(""); }; request.onerror = function () { sendToggles(""); }; request.send(); } function pressToggle(index) { var settings = config(); var id = toggleIds[index]; if (!settings.apiUrl || !settings.token || !id) { refreshToggles(); return; } var request = new XMLHttpRequest(); request.open("POST", settings.apiUrl + "/toggles/" + encodeURIComponent(id), true); request.setRequestHeader("Authorization", "Bearer " + settings.token); request.setRequestHeader("Content-Type", "application/json"); request.timeout = 12000; // The response already carries the new state, so the watch stops saying "…" as soon // as the switch has actually flipped. The second refresh a moment later is for the // DETAIL: the microphone name only changes once the desktop agent has actually moved // the input, which is a beat behind the switch. request.onload = function () { refreshToggles(); setTimeout(refreshToggles, 2500); }; request.ontimeout = refreshToggles; request.onerror = refreshToggles; request.send(JSON.stringify({ action: "toggle" })); } Pebble.addEventListener("ready", refresh); Pebble.addEventListener("appmessage", function (event) { var payload = (event && event.payload) || {}; if (Object.prototype.hasOwnProperty.call(payload, "TOGGLE_SET")) { // 255 is "just tell me the current states" — sent when the toggles screen opens, // because the switch may have been flipped from the dock or by the automation // since the watch last looked. if (payload.TOGGLE_SET === 255) refreshToggles(); else pressToggle(payload.TOGGLE_SET); return; } refresh(); // the watch asking for a plan 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, packToggles: packToggles, }; }