430 lines
15 KiB
JavaScript
430 lines
15 KiB
JavaScript
/*
|
|
* 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(
|
|
"<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'>" +
|
|
"<style>body{font-family:system-ui;margin:18px;background:#111;color:#eee}" +
|
|
"label{display:block;margin:14px 0 4px}input{width:100%;padding:10px;font-size:16px;" +
|
|
"border-radius:8px;border:1px solid #555;background:#222;color:#eee}" +
|
|
"button{margin-top:18px;padding:12px 20px;font-size:16px;border:0;border-radius:8px;" +
|
|
"background:#c084fc}small{color:#999;display:block;margin-top:6px}</style>" +
|
|
"<h2>Who's home</h2>" +
|
|
"<label>identity URL</label><input id=u placeholder='http://192.168.30.12:8097' value='" +
|
|
settings.apiUrl +
|
|
"'><small>Reachable from your phone — on the LAN directly, off it over WireGuard. " +
|
|
"Nothing here is exposed to the internet.</small>" +
|
|
"<label>Bearer token</label><input id=t value='" +
|
|
settings.token +
|
|
"'><small>identity's IDENTITY_TOKEN. It can read the household's presence " +
|
|
"history, so treat it as one.</small>" +
|
|
"<label>Level id (optional)</label><input id=l value='" +
|
|
settings.level +
|
|
"'><small>Blank uses the first level.</small>" +
|
|
"<button onclick=\"location.href='pebblejs://close#'+encodeURIComponent(JSON.stringify(" +
|
|
"{apiUrl:document.getElementById('u').value,token:document.getElementById('t').value," +
|
|
"level:document.getElementById('l').value}))\">Save</button>"
|
|
);
|
|
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,
|
|
};
|
|
}
|