diverter-device-emulator/public/app.js
2026-09-24 10:57:06 +01:00

898 lines
30 KiB
JavaScript

const state = {
auth: sessionStorage.getItem("emu-auth") || "",
devices: [],
selectedId: localStorage.getItem("emu-selected") || "",
selectedIds: new Set(),
schema: { groups: [], fields: [] },
profiles: [],
health: null,
pendingTelemetry: {},
applyingRemote: false,
};
const els = {
loginOverlay: document.getElementById("login-overlay"),
loginForm: document.getElementById("login-form"),
loginError: document.getElementById("login-error"),
tbStatus: document.getElementById("tb-status"),
mqttStatus: document.getElementById("mqtt-status"),
deviceList: document.getElementById("device-list"),
emptyState: document.getElementById("empty-state"),
dashboard: document.getElementById("dashboard"),
dashName: document.getElementById("dash-name"),
dashType: document.getElementById("dash-type"),
dashId: document.getElementById("dash-id"),
toggleMqtt: document.getElementById("toggle-mqtt"),
mqttSpinner: document.getElementById("mqtt-spinner"),
connectionResponse: document.getElementById("connection-response"),
connectionResponseLabel: document.querySelector(".connection-response"),
toggleTelemetry: document.getElementById("toggle-telemetry"),
deleteOne: document.getElementById("delete-one"),
interval: document.getElementById("interval-input"),
publishNow: document.getElementById("publish-now"),
publishSelectAll: document.getElementById("publish-select-all"),
publishDeselectAll: document.getElementById("publish-deselect-all"),
lastPublished: document.getElementById("last-published"),
groups: document.getElementById("telemetry-groups"),
rpcPre: document.getElementById("rpc-pre"),
addModal: document.getElementById("add-modal"),
addForm: document.getElementById("add-form"),
addError: document.getElementById("add-error"),
addProfile: document.getElementById("add-profile"),
addBusy: document.getElementById("add-busy"),
addBusyText: document.getElementById("add-busy-text"),
addExisting: document.getElementById("add-existing"),
addCreateFields: document.getElementById("add-create-fields"),
addTokenField: document.getElementById("add-token-field"),
addTitle: document.getElementById("add-title"),
addHelp: document.getElementById("add-help"),
addSubmit: document.getElementById("add-submit"),
confirmModal: document.getElementById("confirm-modal"),
confirmTitle: document.getElementById("confirm-title"),
confirmMessage: document.getElementById("confirm-message"),
confirmOk: document.getElementById("confirm-ok"),
confirmCancel: document.getElementById("confirm-cancel"),
bulkStatus: document.getElementById("bulk-status"),
hwLamp: document.getElementById("hw-lamp"),
hwLampDetail: document.getElementById("hw-lamp-detail"),
auxLamp: document.getElementById("aux-lamp"),
auxLampDetail: document.getElementById("aux-lamp-detail"),
heatRate: document.getElementById("heat-rate"),
thermostat: document.getElementById("thermostat"),
tankTemp: document.getElementById("tank-temp"),
battLamp: document.getElementById("batt-lamp"),
battLampDetail: document.getElementById("batt-lamp-detail"),
chargeRate: document.getElementById("charge-rate"),
};
function headers(extra = {}) {
const result = { "Content-Type": "application/json", ...extra };
if (state.auth) result.Authorization = `Basic ${state.auth}`;
return result;
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: headers(options.headers),
});
if (response.status === 401) {
showLogin("Authentication required");
const error = new Error("Authentication required");
error.status = 401;
throw error;
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error || `Request failed (${response.status})`);
error.status = response.status;
error.timedOut = Boolean(data.timedOut);
throw error;
}
return data;
}
function showLogin(message) {
els.loginOverlay.classList.remove("hidden");
els.loginError.classList.toggle("hidden", !message);
els.loginError.textContent = message || "";
}
function hideLogin() {
els.loginOverlay.classList.add("hidden");
}
function askConfirm({ title, message, okLabel = "OK", cancelLabel = "Cancel" }) {
els.confirmTitle.textContent = title;
els.confirmMessage.textContent = message;
els.confirmOk.textContent = okLabel;
els.confirmCancel.textContent = cancelLabel;
els.confirmModal.classList.remove("hidden");
return new Promise((resolve) => {
const finish = (value) => {
els.confirmModal.classList.add("hidden");
els.confirmOk.removeEventListener("click", onOk);
els.confirmCancel.removeEventListener("click", onCancel);
resolve(value);
};
const onOk = () => finish(true);
const onCancel = () => finish(false);
els.confirmOk.addEventListener("click", onOk);
els.confirmCancel.addEventListener("click", onCancel);
});
}
function selectedDevice() {
return state.devices.find((device) => device.id === state.selectedId) || null;
}
function setSelected(id) {
state.selectedId = id;
localStorage.setItem("emu-selected", id || "");
}
function formatTime(ts) {
if (!ts) return "Never published";
return `Last publish ${new Date(ts).toLocaleTimeString()}`;
}
function renderHealth() {
const tb = state.health?.thingsboard;
els.tbStatus.textContent = tb?.ok ? `ThingsBoard ${tb.host}` : `ThingsBoard ${tb?.error || "offline"}`;
els.tbStatus.className = `status-pill ${tb?.ok ? "on" : "off"}`;
els.mqttStatus.textContent = state.health?.mqttUrl || "MQTT";
els.mqttStatus.className = "status-pill on";
}
function renderList() {
if (!state.devices.length) {
els.deviceList.innerHTML = `<p class="muted">No emulated devices yet.</p>`;
return;
}
els.deviceList.innerHTML = state.devices
.map((device) => {
const checked = state.selectedIds.has(device.id) ? "checked" : "";
const selected = device.id === state.selectedId ? "selected" : "";
const live = device.connected && !device.disabled;
return `
<div class="device-row ${selected}" data-id="${device.id}">
<input class="device-check" type="checkbox" data-select="${device.id}" ${checked} aria-label="Select ${escapeHtml(device.name)}" />
<div>
<strong>${escapeHtml(device.label || device.name)}</strong>
<small>${escapeHtml(device.name)}${device.telemetryEnabled ? " · telemetry on" : " · telemetry off"}</small>
</div>
<span class="dot ${live ? "live" : ""}" title="${live ? "Connected" : "Disconnected"}"></span>
</div>
`;
})
.join("");
}
function renderDashboard({ refreshFields = true } = {}) {
const device = selectedDevice();
if (!device) {
state.lastRenderedId = "";
els.dashboard.classList.add("hidden");
els.emptyState.classList.remove("hidden");
return;
}
els.emptyState.classList.add("hidden");
els.dashboard.classList.remove("hidden");
els.dashName.textContent = device.label || device.name;
els.dashType.textContent = device.type || "Device";
els.dashId.textContent = device.id;
const mqttBusy = Boolean(device.connecting);
els.mqttSpinner.classList.toggle("hidden", !mqttBusy);
els.toggleMqtt.disabled = mqttBusy;
if (device.connected) {
els.toggleMqtt.textContent = "Disconnect";
els.toggleMqtt.className = "";
} else {
els.toggleMqtt.textContent = "Connect";
els.toggleMqtt.className = "primary";
}
const response = device.lastConnectionResponse || device.lastError || "—";
els.connectionResponse.textContent = response;
els.connectionResponseLabel.classList.toggle(
"error",
Boolean(device.lastError) && !device.connected && !device.connecting,
);
els.toggleTelemetry.textContent = device.telemetryEnabled ? "Stop telemetry" : "Start telemetry";
els.toggleTelemetry.className = device.telemetryEnabled ? "stop-telemetry" : "primary";
els.toggleTelemetry.disabled = !device.connected && !device.telemetryEnabled;
if (document.activeElement !== els.interval) {
els.interval.value = device.intervalMs;
}
els.lastPublished.textContent = formatTime(device.lastPublishedAt);
renderPlant(device);
els.rpcPre.textContent = JSON.stringify(
{ lastRpc: device.lastRpc, sharedAttributes: device.sharedAttributes },
null,
2,
);
const editing =
els.groups.contains(document.activeElement) || Object.keys(state.pendingTelemetry).length > 0;
const deviceChanged = state.lastRenderedId !== device.id;
state.lastRenderedId = device.id;
if ((refreshFields || deviceChanged || !els.groups.childElementCount) && !editing) {
renderFields(device);
} else {
syncFieldValues(device);
}
}
function renderPlant(device) {
const view = device.plantView || {};
const plant = device.plant || {};
els.hwLamp.classList.toggle("on", Boolean(view.hwOn));
els.auxLamp.classList.toggle("on", Boolean(view.auxOn));
els.battLamp.classList.toggle("on", Boolean(view.battCharging));
els.hwLampDetail.textContent = view.hwDetail || "Off";
els.auxLampDetail.textContent = view.auxDetail || "Off";
els.battLampDetail.textContent = view.battDetail || "Off";
const heat = plant.heatRateCpm ?? 1;
const thermo = plant.thermostatC ?? 65;
const charge = plant.chargeRatePpm ?? 10;
document.getElementById("heat-rate-current").textContent = String(heat);
document.getElementById("thermostat-current").textContent = String(thermo);
document.getElementById("charge-rate-current").textContent = String(charge);
if (document.activeElement !== els.heatRate) els.heatRate.value = heat;
if (document.activeElement !== els.thermostat) els.thermostat.value = thermo;
if (document.activeElement !== els.chargeRate) els.chargeRate.value = charge;
const tank = device.telemetry?.heatsink_s;
els.tankTemp.textContent = tank == null ? "—" : `${Number(tank).toFixed(1)} °C`;
renderPowerSim(device);
}
const POWER_FIELDS = {
solar: "solarW",
home: "homeW",
aux: "auxW",
battery: "batteryW",
hotWater: "hotWaterW",
};
function renderPowerSim(device) {
const rows = device.plantView?.power?.rows || [];
for (const row of rows) {
const lamp = document.querySelector(`[data-power-lamp="${row.id}"]`);
const value = document.querySelector(`[data-power-value="${row.id}"]`);
if (lamp) {
lamp.classList.toggle("draw", row.state === "draw");
lamp.classList.toggle("supply", row.state === "supply");
}
if (value) {
const direction = row.id === "grid" ? row.label : row.state === "supply" ? "supply" : row.state === "draw" ? "draw" : "";
const suffix = row.id === "grid" || row.id === "battery" ? ` ${direction}` : "";
value.textContent = `${row.watts} W${suffix}`.trim();
}
const input = document.querySelector(`[data-power-input="${row.id}"]`);
if (input && document.activeElement !== input && row.setpoint != null) {
input.value = row.setpoint;
}
const mode = document.querySelector(`[data-power-mode="${row.id}"]`);
if (mode && document.activeElement !== mode && row.mode) mode.value = row.mode;
}
}
async function savePowerRow(id) {
const device = selectedDevice();
if (!device) return;
const field = POWER_FIELDS[id];
if (!field) return;
const input = document.querySelector(`[data-power-input="${id}"]`);
const body = { [field]: Number(input.value) };
if (id === "battery") {
body.batteryMode = document.getElementById("power-battery-mode").value === "supply" ? "supply" : "draw";
}
const data = await api(`/api/devices/${device.id}/plant`, {
method: "PATCH",
body: JSON.stringify(body),
});
upsertDevice(data.device);
}
function publishedKeySet(device) {
if (Array.isArray(device?.publishedKeys)) return new Set(device.publishedKeys);
return new Set(state.schema.fields.map((field) => field.key));
}
function renderFields(device) {
const values = { ...device.telemetry, ...state.pendingTelemetry };
const enabled = publishedKeySet(device);
els.groups.innerHTML = state.schema.groups
.map((group) => {
const fields = state.schema.fields.filter((field) => field.group === group.id);
return `
<section class="group">
<h3>${escapeHtml(group.title)}</h3>
<p class="muted">${escapeHtml(group.subtitle || "")}</p>
<div class="table-wrap">
<table class="telemetry-table">
<thead>
<tr>
<th>key</th>
<th>value</th>
<th>On</th>
<th>new value</th>
</tr>
</thead>
<tbody>
${fields.map((field) => fieldRowHtml(field, values[field.key], enabled.has(field.key))).join("")}
</tbody>
</table>
</div>
</section>
`;
})
.join("");
}
function displayValue(value) {
if (value === true || value === "1") return "1";
if (value === false || value === "0") return "0";
if (value == null) return "—";
return String(value);
}
function syncFieldValues(device) {
const pending = state.pendingTelemetry;
const values = device.telemetry || {};
const enabled = publishedKeySet(device);
for (const cell of els.groups.querySelectorAll("[data-value-key]")) {
const key = cell.getAttribute("data-value-key");
if (!key || Object.prototype.hasOwnProperty.call(pending, key)) continue;
cell.textContent = displayValue(values[key]);
}
for (const toggle of els.groups.querySelectorAll("[data-publish-key]")) {
const key = toggle.dataset.publishKey;
const on = enabled.has(key);
if (document.activeElement !== toggle) toggle.checked = on;
const row = toggle.closest("tr");
if (row) row.classList.toggle("inactive", !on);
}
}
function fieldRowHtml(field, value, enabled) {
const inactive = enabled ? "" : "inactive";
const onToggle = `
<input
class="device-check key-on-check"
type="checkbox"
data-publish-key="${escapeHtml(field.key)}"
${enabled ? "checked" : ""}
aria-label="Include ${escapeHtml(field.key)} in telemetry"
/>
`;
const submit = `
<button type="button" data-submit-key="${escapeHtml(field.key)}">update</button>
`;
const title = escapeHtml(field.unit ? `${field.label} (${field.unit})` : field.label);
let editor;
if (field.type === "boolean") {
const on = value === 1 || value === true || value === "1";
editor = `
<select
data-key="${escapeHtml(field.key)}"
aria-label="New value for ${escapeHtml(field.key)}"
>
<option value="1" ${on ? "selected" : ""}>true</option>
<option value="0" ${on ? "" : "selected"}>false</option>
</select>
${submit}
`;
} else {
const step = field.step ?? (field.type === "integer" ? 1 : "any");
editor = `
<input
data-key="${escapeHtml(field.key)}"
type="number"
step="${step}"
${field.min != null ? `min="${field.min}"` : ""}
${field.max != null ? `max="${field.max}"` : ""}
value="${value ?? 0}"
aria-label="New value for ${escapeHtml(field.key)}"
/>
${submit}
`;
}
return `
<tr class="field-row ${inactive}">
<td class="key" title="${title}">${escapeHtml(field.key)}</td>
<td class="field-value" data-value-key="${escapeHtml(field.key)}">${escapeHtml(displayValue(value))}</td>
<td class="on-cell">${onToggle}</td>
<td class="new-value">${editor}</td>
</tr>
`;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
async function flushTelemetry() {
const device = selectedDevice();
const payload = { ...state.pendingTelemetry };
if (!device || !Object.keys(payload).length) return;
state.pendingTelemetry = {};
const data = await api(`/api/devices/${device.id}/telemetry`, {
method: "PATCH",
body: JSON.stringify(payload),
});
upsertDevice(data.device);
}
function upsertDevice(device) {
const index = state.devices.findIndex((item) => item.id === device.id);
if (index === -1) state.devices.unshift(device);
else state.devices[index] = device;
renderList();
renderDashboard();
}
async function refresh() {
const [health, devices] = await Promise.all([api("/api/health"), api("/api/devices")]);
state.health = health;
state.devices = devices.devices || [];
if (state.selectedId && !state.devices.some((device) => device.id === state.selectedId)) {
setSelected(state.devices[0]?.id || "");
}
if (!state.selectedId && state.devices[0]) setSelected(state.devices[0].id);
renderHealth();
renderList();
renderDashboard({ refreshFields: false });
}
async function loadStatic() {
const [schema, profiles] = await Promise.all([
api("/api/telemetry-schema"),
api("/api/device-profiles").catch(() => ({ profiles: [] })),
]);
state.schema = schema;
state.profiles = profiles.profiles || [];
const preferredId =
profiles.defaultProfileId ||
state.profiles.find((profile) => profile.preferred)?.id ||
"";
els.addProfile.innerHTML = state.profiles
.map(
(profile) =>
`<option value="${profile.id}">${escapeHtml(profile.name)}${
profile.preferred ? " (diverter)" : profile.default ? " (TB default)" : ""
}</option>`,
)
.join("");
if (preferredId) els.addProfile.value = preferredId;
}
els.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
const user = document.getElementById("login-user").value;
const pass = document.getElementById("login-pass").value;
state.auth = btoa(`${user}:${pass}`);
sessionStorage.setItem("emu-auth", state.auth);
try {
await refresh();
await loadStatic();
hideLogin();
} catch (error) {
showLogin(error.message);
}
});
const deviceMenuToggle = document.getElementById("device-menu-toggle");
const deviceMenu = document.getElementById("device-menu");
function setDeviceMenuOpen(open) {
deviceMenu.classList.toggle("hidden", !open);
deviceMenuToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
deviceMenuToggle.addEventListener("click", (event) => {
event.stopPropagation();
setDeviceMenuOpen(deviceMenu.classList.contains("hidden"));
});
deviceMenu.addEventListener("click", (event) => {
if (event.target.closest("button")) setDeviceMenuOpen(false);
});
document.addEventListener("click", (event) => {
if (!event.target.closest(".device-menu")) setDeviceMenuOpen(false);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") setDeviceMenuOpen(false);
});
function applyAddMode() {
const existing = els.addExisting.checked;
els.addCreateFields.classList.toggle("hidden", existing);
els.addTokenField.classList.toggle("hidden", !existing);
els.addTitle.textContent = existing ? "Add existing device" : "Add to emulator";
els.addHelp.textContent = existing
? "Paste the MQTT access token to load a ThingsBoard device into this emulator."
: "Creates a new device in ThingsBoard and this emulator.";
els.addSubmit.textContent = existing ? "Add device" : "Create device";
}
els.addExisting.addEventListener("change", () => {
els.addError.classList.add("hidden");
applyAddMode();
});
document.getElementById("add-open").addEventListener("click", () => {
els.addError.classList.add("hidden");
applyAddMode();
els.addModal.classList.remove("hidden");
});
document.getElementById("add-cancel").addEventListener("click", () => {
els.addModal.classList.add("hidden");
});
function setAddBusy(busy, text) {
els.addBusy.classList.toggle("hidden", !busy);
els.addBusy.setAttribute("aria-busy", busy ? "true" : "false");
if (text) els.addBusyText.textContent = text;
const submitButton = els.addForm.querySelector("button[type='submit']");
const cancelButton = document.getElementById("add-cancel");
submitButton.disabled = busy;
cancelButton.disabled = busy;
}
els.addForm.addEventListener("submit", async (event) => {
event.preventDefault();
els.addError.classList.add("hidden");
const existing = els.addExisting.checked;
const payload = existing
? { accessToken: document.getElementById("add-token").value || undefined }
: {
name: document.getElementById("add-name").value,
label: document.getElementById("add-label").value,
type: document.getElementById("add-type").value,
deviceProfileId: document.getElementById("add-profile").value || undefined,
};
if (existing && !String(payload.accessToken || "").trim()) {
els.addError.classList.remove("hidden");
els.addError.textContent = "Paste the MQTT access token of the ThingsBoard device to add.";
return;
}
if (!existing && !String(payload.name || "").trim()) {
els.addError.classList.remove("hidden");
els.addError.textContent = "Enter a name to create a device.";
return;
}
setAddBusy(true, payload.accessToken ? "Looking up ThingsBoard device…" : "Waiting for ThingsBoard…");
try {
const data = await api("/api/devices", {
method: "POST",
body: JSON.stringify(payload),
});
finishAdd(data.device);
} catch (error) {
if (error.timedOut && !payload.accessToken) {
setAddBusy(false);
const saveAnyway = await askConfirm({
title: "ThingsBoard timed out",
message: `No response while creating "${payload.name}". The device may already exist in ThingsBoard. Save it to the emulator database anyway?`,
okLabel: "Save to emulator",
cancelLabel: "Don't save",
});
if (!saveAnyway) {
els.addError.classList.remove("hidden");
els.addError.textContent = error.message;
return;
}
setAddBusy(true, "Looking up the ThingsBoard device…");
try {
const adopted = await api("/api/devices/adopt", {
method: "POST",
body: JSON.stringify({
name: payload.name,
label: payload.label,
type: payload.type,
}),
});
finishAdd(adopted.device);
return;
} catch (adoptError) {
els.addError.classList.remove("hidden");
els.addError.textContent = adoptError.timedOut
? `${adoptError.message} Paste the MQTT access token from ThingsBoard and try Create again.`
: adoptError.message;
return;
}
}
els.addError.classList.remove("hidden");
els.addError.textContent = error.message;
} finally {
setAddBusy(false);
}
});
function finishAdd(device) {
upsertDevice(device);
setSelected(device.id);
els.addModal.classList.add("hidden");
els.addForm.reset();
const preferredId =
state.profiles.find((profile) => profile.preferred)?.id ||
els.addProfile.querySelector("option")?.value ||
"";
if (preferredId) els.addProfile.value = preferredId;
applyAddMode();
renderDashboard();
}
els.deviceList.addEventListener("click", (event) => {
const checkbox = event.target.closest("input[data-select]");
if (checkbox) {
const id = checkbox.getAttribute("data-select");
if (checkbox.checked) state.selectedIds.add(id);
else state.selectedIds.delete(id);
return;
}
const row = event.target.closest(".device-row");
if (!row) return;
setSelected(row.dataset.id);
renderList();
renderDashboard();
});
function targetDeviceIds() {
if (state.selectedIds.size) return [...state.selectedIds];
if (state.selectedId) return [state.selectedId];
return [];
}
function setBulkStatus(message) {
if (!els.bulkStatus) return;
els.bulkStatus.textContent = message || "";
}
async function runBulk(action) {
const ids = targetDeviceIds();
if (!ids.length) {
setBulkStatus("Tick a checkbox, or open a device, then try again.");
return;
}
if (action === "delete") {
const ok = await askConfirm({
title: "Remove from emulator?",
message: `Remove ${ids.length} device(s) from this emulator? If they still exist in ThingsBoard they will be deleted there too.`,
okLabel: "Delete",
cancelLabel: "Cancel",
});
if (!ok) return;
setBulkStatus("Deleting…");
ids.forEach((id) => dropDeviceLocally(id));
renderList();
renderDashboard();
} else {
setBulkStatus("");
}
const path =
action === "delete"
? "/api/devices/delete"
: action === "enable"
? "/api/devices/enable"
: "/api/devices/disable";
try {
await api(path, { method: "POST", body: JSON.stringify({ ids }) });
if (action === "delete") {
ids.forEach((id) => state.selectedIds.delete(id));
if (ids.includes(state.selectedId)) setSelected("");
}
await refresh();
setBulkStatus(
action === "delete"
? `Deleted ${ids.length} device(s).`
: action === "enable"
? `Enabled ${ids.length} device(s).`
: `Disabled ${ids.length} device(s).`,
);
} catch (error) {
setBulkStatus(error.message);
await refresh().catch(() => {});
}
}
document.getElementById("select-all").addEventListener("click", () => {
if (!state.devices.length) {
setBulkStatus("No devices to select.");
return;
}
state.selectedIds = new Set(state.devices.map((device) => device.id));
renderList();
});
document.getElementById("deselect-all").addEventListener("click", () => {
state.selectedIds.clear();
renderList();
});
document.getElementById("bulk-disable").addEventListener("click", () => runBulk("disable"));
document.getElementById("bulk-enable").addEventListener("click", () => runBulk("enable"));
document.getElementById("bulk-delete").addEventListener("click", () => runBulk("delete"));
els.toggleTelemetry.addEventListener("click", async () => {
const device = selectedDevice();
if (!device) return;
if (!device.telemetryEnabled && !device.connected) return;
const path = device.telemetryEnabled
? `/api/devices/${device.id}/telemetry/stop`
: `/api/devices/${device.id}/telemetry/start`;
const data = await api(path, { method: "POST" });
upsertDevice(data.device);
});
els.toggleMqtt.addEventListener("click", async () => {
const device = selectedDevice();
if (!device || device.connecting) return;
const path = device.connected
? `/api/devices/${device.id}/disable`
: `/api/devices/${device.id}/enable`;
const data = await api(path, { method: "POST" });
upsertDevice(data.device);
});
function dropDeviceLocally(id) {
state.selectedIds.delete(id);
state.devices = state.devices.filter((device) => device.id !== id);
if (state.selectedId === id) setSelected(state.devices[0]?.id || "");
}
els.deleteOne.addEventListener("click", async () => {
const device = selectedDevice();
if (!device) return;
state.selectedIds = new Set([device.id]);
await runBulk("delete");
});
els.interval.addEventListener("change", async () => {
const device = selectedDevice();
if (!device) return;
const data = await api(`/api/devices/${device.id}/interval`, {
method: "POST",
body: JSON.stringify({ intervalMs: Number(els.interval.value) }),
});
upsertDevice(data.device);
});
async function savePlant() {
const device = selectedDevice();
if (!device) return;
const data = await api(`/api/devices/${device.id}/plant`, {
method: "PATCH",
body: JSON.stringify({
heatRateCpm: Number(els.heatRate.value),
thermostatC: Number(els.thermostat.value),
chargeRatePpm: Number(els.chargeRate.value),
}),
});
upsertDevice(data.device);
}
document.getElementById("dashboard").addEventListener("click", (event) => {
const powerButton = event.target.closest("[data-power-update]");
if (powerButton) {
savePowerRow(powerButton.getAttribute("data-power-update"));
return;
}
const plantButton = event.target.closest("[data-plant-update]");
if (plantButton) savePlant();
});
els.publishNow.addEventListener("click", async () => {
await flushTelemetry();
const device = selectedDevice();
if (!device) return;
const data = await api(`/api/devices/${device.id}/telemetry/publish`, {
method: "POST",
body: JSON.stringify(device.telemetry),
});
upsertDevice(data.device);
});
async function savePublishedKeys(keys) {
const device = selectedDevice();
if (!device) return;
const data = await api(`/api/devices/${device.id}/published-keys`, {
method: "PATCH",
body: JSON.stringify({ keys }),
});
upsertDevice(data.device);
}
els.publishSelectAll.addEventListener("click", async () => {
await savePublishedKeys(state.schema.fields.map((field) => field.key));
const device = selectedDevice();
if (device) renderFields(device);
});
els.publishDeselectAll.addEventListener("click", async () => {
await savePublishedKeys([]);
const device = selectedDevice();
if (device) renderFields(device);
});
els.groups.addEventListener("change", async (event) => {
const publishToggle = event.target.closest("[data-publish-key]");
if (!publishToggle) return;
const device = selectedDevice();
if (!device) return;
const key = publishToggle.dataset.publishKey;
const next = publishedKeySet(device);
if (publishToggle.checked) next.add(key);
else next.delete(key);
const row = publishToggle.closest("tr");
if (row) row.classList.toggle("inactive", !publishToggle.checked);
await savePublishedKeys(state.schema.fields.map((item) => item.key).filter((item) => next.has(item)));
});
function readNewValue(key) {
const field = state.schema.fields.find((item) => item.key === key);
const input = els.groups.querySelector(`[data-key="${CSS.escape(key)}"]`);
if (!field || !input) return null;
if (field.type === "boolean") {
return input.value === "1" || input.value === "true" ? 1 : 0;
}
return Number(input.value);
}
async function submitNewValue(key) {
const value = readNewValue(key);
if (value == null || Number.isNaN(value)) return;
state.pendingTelemetry[key] = value;
await flushTelemetry();
}
els.groups.addEventListener("click", (event) => {
const button = event.target.closest("[data-submit-key]");
if (!button) return;
submitNewValue(button.dataset.submitKey);
});
els.groups.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
const input = event.target.closest("[data-key]");
if (!input) return;
event.preventDefault();
submitNewValue(input.dataset.key);
});
async function boot() {
try {
await loadStatic();
await refresh();
hideLogin();
setInterval(() => {
refresh().catch(() => {});
}, 2000);
} catch (error) {
if (error.status !== 401) {
showLogin(error.message);
}
}
}
boot();