SmartestHome/pebble-presence/src/pkjs/index.js

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