207 lines
8.0 KiB
JavaScript
207 lines
8.0 KiB
JavaScript
// hosts/door-panel/'s dashboard logic. Vanilla JS, no framework, no build step —
|
|
// same "vendored, dependency-free" choice as every other kiosk frontend in this
|
|
// project. See dashboard.html's top comment for the two-backend split.
|
|
"use strict";
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const IDENTITY_API = (params.get("identity_api") || "").replace(/\/$/, "");
|
|
const IDENTITY_TOKEN = params.get("identity_token") || "";
|
|
const PANTRY_API = (params.get("pantry_api") || "").replace(/\/$/, "");
|
|
const PANTRY_TOKEN = params.get("pantry_token") || "";
|
|
|
|
if (!IDENTITY_API || !IDENTITY_TOKEN) {
|
|
document.body.innerHTML =
|
|
'<p class="error" style="padding:24px">identity not configured — missing ' +
|
|
"?identity_api=&identity_token= in the URL.</p>";
|
|
throw new Error("dashboard: missing identity query params");
|
|
}
|
|
|
|
function identityApi(path) {
|
|
return fetch(`${IDENTITY_API}${path}`, { headers: { Authorization: `Bearer ${IDENTITY_TOKEN}` } }).then((res) => {
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
return res.json();
|
|
});
|
|
}
|
|
|
|
function pantryApi(path) {
|
|
return fetch(`${PANTRY_API}${path}`, { headers: { Authorization: `Bearer ${PANTRY_TOKEN}` } }).then((res) => {
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
return res.json();
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
}
|
|
|
|
// --- Weather + clothing --------------------------------------------------------
|
|
// A plain lookup table, not an LLM call — this is a deterministic-enough problem
|
|
// (temperature + a couple of condition keywords) that a round trip to Ollama would
|
|
// only add latency and a failure mode for no real gain. See ../README.md.
|
|
function clothingSuggestion(tempC, condition) {
|
|
const cond = (condition || "").toLowerCase();
|
|
const layers =
|
|
tempC === null
|
|
? null
|
|
: tempC < 0
|
|
? "Heavy coat, hat, gloves"
|
|
: tempC < 10
|
|
? "Warm coat or jacket"
|
|
: tempC < 16
|
|
? "Light jacket or sweater"
|
|
: tempC < 21
|
|
? "Light layers"
|
|
: "T-shirt weather";
|
|
const wet = /rain|drizzle|shower|storm|snow|sleet/.test(cond) ? " — bring an umbrella/waterproofs" : "";
|
|
return layers ? layers + wet : null;
|
|
}
|
|
|
|
function loadWeather() {
|
|
identityApi("/weather")
|
|
.then((data) => {
|
|
const hero = document.getElementById("weather-hero");
|
|
const suggestion = document.getElementById("clothing-suggestion");
|
|
if (!data.available) {
|
|
hero.innerHTML = '<span class="hint">No weather data yet</span>';
|
|
suggestion.hidden = true;
|
|
return;
|
|
}
|
|
hero.innerHTML =
|
|
`<span class="weather-temp">${escapeHtml(data.temperature || "")}</span>` +
|
|
`<span class="weather-condition">${escapeHtml(data.condition || "")}</span>`;
|
|
|
|
const match = /(-?\d+(\.\d+)?)/.exec(data.temperature || "");
|
|
const tempC = match ? parseFloat(match[1]) : null;
|
|
const text = clothingSuggestion(tempC, data.condition);
|
|
if (text) {
|
|
suggestion.textContent = text;
|
|
suggestion.hidden = false;
|
|
} else {
|
|
suggestion.hidden = true;
|
|
}
|
|
})
|
|
.catch(() => {
|
|
document.getElementById("weather-hero").innerHTML = '<span class="hint">Weather unavailable</span>';
|
|
});
|
|
}
|
|
|
|
// --- Who's home ------------------------------------------------------------------
|
|
// Three states, not two: home / away / unknown. "Unknown" covers both an
|
|
// unreachable HA and a device-less person (grandmother, a guest) who was never
|
|
// manually marked either way — see identity/server.py's presence() docstring for
|
|
// why defaulting that case to "away" would be actively wrong, not just vague.
|
|
function statusLabel(p) {
|
|
if (p.home === true) return p.room ? `Home — ${p.room}` : "Home";
|
|
if (p.home === false) return "Away";
|
|
return "Unknown";
|
|
}
|
|
|
|
function statusClass(p) {
|
|
if (p.home === true) return "status-home";
|
|
if (p.home === false) return "status-away";
|
|
return "";
|
|
}
|
|
|
|
function markPresenceRequest(personId, home) {
|
|
return fetch(`${IDENTITY_API}/presence/manual`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${IDENTITY_TOKEN}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ person_id: personId, home }),
|
|
}).then(loadPresence);
|
|
}
|
|
|
|
// Same blob-fetch approach as register.js's loadAvatar — see that file's comment
|
|
// for why a plain <img src="..."> can't be used against a bearer-token-gated endpoint.
|
|
function loadAvatar(imgContainer, personId) {
|
|
fetch(`${IDENTITY_API}/people/${personId}/photo`, { headers: { Authorization: `Bearer ${IDENTITY_TOKEN}` } })
|
|
.then((res) => (res.ok ? res.blob() : Promise.reject()))
|
|
.then((blob) => {
|
|
const img = document.createElement("img");
|
|
img.src = URL.createObjectURL(blob);
|
|
imgContainer.replaceChildren(img);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function loadPresence() {
|
|
const el = document.getElementById("presence-list");
|
|
identityApi("/presence")
|
|
.then((data) => {
|
|
const people = data.people || [];
|
|
if (!people.length) {
|
|
el.innerHTML = '<p class="hint">Nobody registered yet.</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = people
|
|
.map((p) => {
|
|
// Device-less people (has_device === false) get manual Home/Away buttons
|
|
// right here — the only way their presence is ever set, since there's no
|
|
// phone to track. See identity/README.md's "no device" section.
|
|
const manualButtons = p.has_device
|
|
? ""
|
|
: `<button type="button" class="big-btn secondary" data-mark="${p.id}" data-home="1" style="min-height:40px;min-width:0;padding:0 12px;font-size:13px;margin-left:6px">Home</button>
|
|
<button type="button" class="big-btn secondary" data-mark="${p.id}" data-home="0" style="min-height:40px;min-width:0;padding:0 12px;font-size:13px;margin-left:6px">Away</button>`;
|
|
return `<div class="card">
|
|
<span class="avatar" data-person="${p.id}">👤</span>
|
|
<span class="card-name">${escapeHtml(p.name)}</span>
|
|
<span class="card-meta ${statusClass(p)}">${escapeHtml(statusLabel(p))}</span>
|
|
${manualButtons}
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
el.querySelectorAll("[data-mark]").forEach((btn) => {
|
|
btn.addEventListener("click", () => markPresenceRequest(Number(btn.dataset.mark), btn.dataset.home === "1"));
|
|
});
|
|
people.forEach((p) => {
|
|
if (p.has_photo) {
|
|
const avatarEl = el.querySelector(`.avatar[data-person="${p.id}"]`);
|
|
if (avatarEl) loadAvatar(avatarEl, p.id);
|
|
}
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
el.innerHTML = `<p class="error">Could not load presence: ${escapeHtml(err.message)}</p>`;
|
|
});
|
|
}
|
|
|
|
// --- Running low -----------------------------------------------------------------
|
|
function loadShoppingList() {
|
|
const el = document.getElementById("shopping-list");
|
|
if (!PANTRY_API || !PANTRY_TOKEN) {
|
|
el.innerHTML = '<p class="hint">pantry-vision not configured for this display.</p>';
|
|
return;
|
|
}
|
|
pantryApi("/shopping-list")
|
|
.then((data) => {
|
|
const items = data.items || [];
|
|
if (!items.length) {
|
|
el.innerHTML = '<p class="hint">Nothing running low.</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = items
|
|
.map(
|
|
(item) =>
|
|
`<div class="card">
|
|
<span class="card-name">${escapeHtml(item.name)}</span>
|
|
<span class="card-meta">need ${escapeHtml(String(item.amount_missing ?? ""))}</span>
|
|
</div>`
|
|
)
|
|
.join("");
|
|
})
|
|
.catch((err) => {
|
|
el.innerHTML = `<p class="error">Could not load shopping list: ${escapeHtml(err.message)}</p>`;
|
|
});
|
|
}
|
|
|
|
function refreshAll() {
|
|
loadWeather();
|
|
loadPresence();
|
|
loadShoppingList();
|
|
}
|
|
|
|
refreshAll();
|
|
// An ambient always-on display, not an interactive app — poll rather than needing a
|
|
// tap to refresh. 60s matches the weather MQTT topic's own realistic update cadence
|
|
// (an HA automation on a state-change trigger, not a fast poll itself).
|
|
setInterval(refreshAll, 60000);
|