diverter-device-emulator/public/app.js

1389 lines
48 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const state = {
auth: sessionStorage.getItem("emu-auth") || "",
devices: [],
deviceLimit: 100,
selectedId: localStorage.getItem("emu-selected") || "",
selectedIds: new Set(),
schema: { groups: [], fields: [] },
profiles: [],
health: null,
pendingTelemetry: {},
applyingRemote: false,
faultLogs: {},
};
const els = {
loginOverlay: document.getElementById("login-overlay"),
loginForm: document.getElementById("login-form"),
loginError: document.getElementById("login-error"),
localTime: document.getElementById("local-time"),
tbStatus: document.getElementById("tb-status"),
mqttStatus: document.getElementById("mqtt-status"),
deviceList: document.getElementById("device-list"),
deviceCount: document.getElementById("device-count"),
addDeviceCount: document.getElementById("add-device-count"),
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 atDeviceLimit() {
return state.devices.length >= state.deviceLimit;
}
function renderDeviceCount() {
const label = `${state.devices.length}/${state.deviceLimit}`;
const full = atDeviceLimit();
els.deviceCount.textContent = label;
els.deviceCount.classList.toggle("at-limit", full);
els.addDeviceCount.textContent = `${label} devices`;
els.addDeviceCount.classList.toggle("at-limit", full);
}
function renderList() {
renderDeviceCount();
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));
const batteryRow = (view.power?.rows || []).find((row) => row.id === "battery");
els.battLamp.classList.toggle("draw", batteryRow?.state === "draw");
els.battLamp.classList.toggle("supply", batteryRow?.state === "supply");
const hwBoost = document.getElementById("hw-boost");
hwBoost.textContent = view.hwOn ? "Hot Water Boost Stop" : "Hot Water Boost Start";
hwBoost.classList.toggle("primary", !view.hwOn);
hwBoost.classList.toggle("danger", Boolean(view.hwOn));
const auxBoost = document.getElementById("aux-boost");
auxBoost.textContent = view.auxOn ? "Aux Boost Stop" : "Aux Boost Start";
auxBoost.classList.toggle("primary", !view.auxOn);
auxBoost.classList.toggle("danger", Boolean(view.auxOn));
const battToggle = document.getElementById("batt-toggle");
battToggle.textContent = plant.batteryOn ? "Battery On" : "Battery Off";
battToggle.classList.toggle("primary", !plant.batteryOn);
battToggle.classList.toggle("danger", Boolean(plant.batteryOn));
const heat = plant.heatRateCpm ?? 1;
const thermo = plant.thermostatC ?? 65;
const charge = plant.chargeRateWpm ?? 500;
const batteryTotal = plant.batteryTotalW ?? 5000;
document.getElementById("heat-rate-current").textContent = String(heat);
document.getElementById("thermostat-current").textContent = String(thermo);
document.getElementById("charge-rate-current").textContent = String(charge);
document.getElementById("battery-total-current").textContent = String(batteryTotal);
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 batteryTotalInput = document.getElementById("battery-total");
if (document.activeElement !== batteryTotalInput) batteryTotalInput.value = batteryTotal;
const batteryActual = Math.max(0, Math.min(batteryTotal, Number(plant.batteryActualW) || 0));
document.getElementById("battery-actual-current").textContent = String(batteryActual);
const batteryActualInput = document.getElementById("battery-actual");
batteryActualInput.max = String(batteryTotal);
if (document.activeElement !== batteryActualInput) batteryActualInput.value = batteryActual;
const tank = device.telemetry?.heatsink_s;
const tankInput = document.getElementById("tank-temp-input");
els.tankTemp.textContent = tank == null ? "—" : Number(tank).toFixed(1);
if (tankInput && document.activeElement !== tankInput && tank != null) tankInput.value = Number(tank);
renderPowerSim(device);
renderFaultSelects(device);
setPlantStatus("hotWater", view.hwOn ? "Boost On" : "");
setPlantStatus("aux", view.auxOn ? "Boost On" : "");
renderScheduleBoard("hw", view.hwSchedules, view.hwScheduleMode);
renderScheduleBoard("aux", view.auxSchedules, view.auxScheduleMode);
}
const scheduleSignatures = { hw: null, aux: null };
const scheduleFreshTimers = { hw: 0, aux: 0 };
function formatScheduleMinute(value) {
if (value == null || !Number.isFinite(Number(value))) return "—";
const total = ((Math.trunc(Number(value)) % 1440) + 1440) % 1440;
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
}
function renderScheduleBoard(channel, slots, modeOn) {
const board = document.getElementById(`${channel}-schedule`);
if (!board) return;
const rows = Array.isArray(slots) ? slots : [];
rows.forEach((slot, index) => {
const line = board.querySelector(`[data-schedule-line="${channel}-${index}"]`);
const times = board.querySelector(`[data-schedule-times="${channel}-${index}"]`);
if (!line || !times) return;
const on = formatScheduleMinute(slot?.start);
const off = formatScheduleMinute(slot?.stop);
times.textContent = `${on} – ${off}`;
line.classList.toggle("is-active", Boolean(slot?.active));
});
const note = board.querySelector(`[data-schedule-note="${channel}"]`);
if (note) {
const received = rows.some((slot) => slot?.start != null || slot?.stop != null);
note.textContent = received && !modeOn ? "Schedule mode is off" : "";
}
const signature = JSON.stringify(rows.map((slot) => [slot?.start ?? null, slot?.stop ?? null, Boolean(modeOn)]));
if (scheduleSignatures[channel] != null && scheduleSignatures[channel] !== signature) {
board.classList.add("is-fresh");
window.clearTimeout(scheduleFreshTimers[channel]);
scheduleFreshTimers[channel] = window.setTimeout(() => board.classList.remove("is-fresh"), 1600);
}
scheduleSignatures[channel] = signature;
}
function setPlantStatus(id, text) {
const box = document.querySelector(`[data-plant-status="${id}"]`);
if (box) box.textContent = text;
}
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) value.textContent = String(row.watts);
if (row.id === "solar") setPlantStatus("solar", row.state === "supply" ? "Generating" : "");
if (row.id === "grid") {
setPlantStatus("grid", row.state === "draw" ? "Importing" : row.state === "supply" ? "Exporting" : "");
}
if (row.id === "home") setPlantStatus("home", row.state === "draw" ? "Drawing" : "");
if (row.id === "battery") {
setPlantStatus(
"battery",
row.flowState === "draw" ? "Charging" : row.flowState === "supply" ? "Discharging" : "",
);
}
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) };
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);
const showNewValue = group.id !== "faults";
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>
${showNewValue ? "<th>new value</th>" : ""}
</tr>
</thead>
<tbody>
${fieldRowsHtml(fields, values, enabled)}
</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 select of els.groups.querySelectorAll("[data-enum-key]")) {
const key = select.dataset.enumKey;
if (!key || document.activeElement === select || Object.prototype.hasOwnProperty.call(pending, key)) continue;
select.value = String(values[key] ?? "");
}
for (const input of els.groups.querySelectorAll("[data-int-key]")) {
const key = input.dataset.intKey;
if (!key || document.activeElement === input || Object.prototype.hasOwnProperty.call(pending, key)) continue;
input.value = values[key] != null ? String(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 fieldRowsHtml(fields, values, enabled) {
const blocks = [];
for (const field of fields) {
const last = blocks[blocks.length - 1];
if (field.share && last?.share === field.share) last.fields.push(field);
else blocks.push({ share: field.share || "", fields: [field] });
}
return blocks
.map((block) => {
if (block.fields.length < 2) {
const field = block.fields[0];
return fieldRowHtml(field, values[field.key], enabled.has(field.key));
}
return block.fields
.map((field, index) => fieldRowHtml(field, values[field.key], enabled.has(field.key), {
share: true,
editor: index === 0,
span: block.fields.length,
inputKey: block.fields[0].key,
edge: index === 0 ? "start" : index === block.fields.length - 1 ? "end" : "mid",
}))
.join("");
})
.join("");
}
function fieldRowHtml(field, value, enabled, share = null) {
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 submitKey = share?.inputKey || field.key;
const submit = `
<button type="button" data-submit-key="${escapeHtml(submitKey)}">update</button>
`;
const title = escapeHtml(field.unit ? `${field.label} (${field.unit})` : field.label);
let editor;
let valueCell = `<td class="field-value" data-value-key="${escapeHtml(field.key)}">${escapeHtml(displayValue(value))}</td>`;
if (field.type === "enum") {
valueCell = `<td class="field-value">${enumSelectHtml(field, value, "data-enum-key", { raw: true })}</td>`;
editor = "";
} else if (field.group === "faults") {
valueCell = `<td class="field-value">${faultIntegerHtml(field, value, "data-int-key")}</td>`;
editor = "";
} else if (field.type === "boolean") {
const on = value === 1 || value === true || value === "1";
editor = `
<select
data-key="${escapeHtml(submitKey)}"
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(submitKey)}"
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}
`;
}
const shareClass = share ? ` share-group share-${share.edge}` : "";
const editorCell = field.group === "faults"
? ""
: field.type === "enum"
? `<td class="new-value"></td>`
: !share || share.editor
? `<td class="new-value"${share ? ` rowspan="${share.span}"` : ""}>${editor}</td>`
: "";
return `
<tr class="field-row ${inactive}${shareClass}">
<td class="key" title="${title}">${escapeHtml(field.key)}</td>
${valueCell}
<td class="on-cell">${onToggle}</td>
${editorCell}
</tr>
`;
}
const ENUM_PREFIXES = [
"AcBuckFaultNoBitShift",
"SensorFaultNoBitShift",
"SystemFaultNoBitShift",
"enumAcBuckState",
];
const ENUM_WORDS = {
Pwm: "PWM",
Adc: "ADC",
Zcd: "ZCD",
Ctrl: "control",
};
function enumLabel(name) {
let rest = String(name);
for (const prefix of ENUM_PREFIXES) {
if (rest.startsWith(prefix)) {
rest = rest.slice(prefix.length);
break;
}
}
const words = rest
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.trim()
.split(/\s+/)
.filter(Boolean);
return words
.map((word, index) => {
if (ENUM_WORDS[word]) {
const mapped = ENUM_WORDS[word];
return index === 0 ? mapped.charAt(0).toUpperCase() + mapped.slice(1) : mapped;
}
if (/[0-9]/.test(word)) return word;
const lower = word.toLowerCase();
return index === 0 ? lower.charAt(0).toUpperCase() + lower.slice(1) : lower;
})
.join(" ");
}
function enumSelectHtml(field, value, attr, { raw = false } = {}) {
const options = (field.options || [])
.map((option) => {
const label = raw ? `${option.name} (${option.value})` : `${enumLabel(option.name)} (${option.value})`;
return `<option value="${option.value}"${Number(value) === option.value ? " selected" : ""}>${escapeHtml(label)}</option>`;
})
.join("");
return `<select ${attr}="${escapeHtml(field.key)}" aria-label="${escapeHtml(field.key)}">${options}</select>`;
}
function faultIntegerHtml(field, value, attr) {
const shown = value == null || value === "" ? "" : String(value);
return `<input type="number" step="1" ${attr}="${escapeHtml(field.key)}" value="${escapeHtml(shown)}" aria-label="${escapeHtml(field.key)}" />`;
}
function faultControlHtml(field, value) {
if (field.type === "enum") return enumSelectHtml(field, value, "data-fault-key");
return faultIntegerHtml(field, value, "data-fault-key");
}
function faultFields() {
return state.schema.fields.filter((field) => field.group === "faults");
}
function buildFaultRows() {
const root = document.getElementById("fault-rows");
if (!root) return;
root.innerHTML = faultFields()
.map(
(field) => `
<div class="fault-row">
<span class="fault-key">${escapeHtml(field.key)}</span>
${faultControlHtml(field, null)}
<button type="button" data-fault-publish="${escapeHtml(field.key)}">Publish</button>
<input class="fault-log" data-fault-log="${escapeHtml(field.key)}" readonly />
</div>
`,
)
.join("");
}
function renderFaultSelects(device) {
const values = device.telemetry || {};
for (const control of document.querySelectorAll("[data-fault-key]")) {
if (document.activeElement === control) continue;
const key = control.dataset.faultKey;
if (values[key] != null) control.value = String(values[key]);
}
const logs = state.faultLogs[device.id] || {};
for (const input of document.querySelectorAll("[data-fault-log]")) {
input.value = logs[input.dataset.faultLog] || "";
}
}
function faultOptionName(key, value) {
const field = state.schema.fields.find((item) => item.key === key);
const option = (field?.options || []).find((item) => item.value === Number(value));
return option ? `${enumLabel(option.name)} (${option.value})` : String(value);
}
function rememberFaultLog(deviceId, slot, text) {
if (!state.faultLogs[deviceId]) state.faultLogs[deviceId] = {};
state.faultLogs[deviceId][slot] = text;
const input = document.querySelector(`[data-fault-log="${CSS.escape(slot)}"]`);
if (input) input.value = text;
}
async function saveFaultValue(key, value) {
const device = selectedDevice();
if (!device) return;
const data = await api(`/api/devices/${device.id}/telemetry?publish=0`, {
method: "PATCH",
body: JSON.stringify({ [key]: value }),
});
upsertDevice(data.device);
}
async function publishFaultKeys(keys) {
const device = selectedDevice();
if (!device) return;
const body = {};
for (const key of keys) {
const control = document.querySelector(`[data-fault-key="${CSS.escape(key)}"]`);
body[key] = Number(control?.value);
}
const data = await api(`/api/devices/${device.id}/faults/publish`, {
method: "POST",
body: JSON.stringify(body),
});
upsertDevice(data.device);
const time = new Date().toLocaleTimeString();
const sent = data.sent ? time : `${time} not connected`;
const detail = keys.map((key) => `${key} ${faultOptionName(key, body[key])}`).join(" · ");
const line = data.sent ? `${time} ${detail}` : `${sent} — ${detail}`;
if (keys.length === 1) rememberFaultLog(device.id, keys[0], line);
else {
rememberFaultLog(device.id, "all", line);
for (const key of keys) rememberFaultLog(device.id, key, `${data.sent ? time : sent} ${faultOptionName(key, body[key])}`);
}
}
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 || [];
state.deviceLimit = Number(devices.limit) || 100;
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;
buildFaultRows();
}
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();
renderDeviceCount();
if (atDeviceLimit()) {
els.addError.classList.remove("hidden");
els.addError.textContent = `Device limit reached (${state.devices.length}/${state.deviceLimit}).`;
els.addSubmit.disabled = true;
} else {
els.addSubmit.disabled = false;
}
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");
if (atDeviceLimit()) {
els.addError.classList.remove("hidden");
els.addError.textContent = `Device limit reached (${state.devices.length}/${state.deviceLimit}).`;
els.addSubmit.disabled = true;
return;
}
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);
if (atDeviceLimit()) els.addSubmit.disabled = true;
}
});
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");
});
async function showNotice(title, message) {
els.confirmCancel.classList.add("hidden");
await askConfirm({ title, message, okLabel: "OK" });
els.confirmCancel.classList.remove("hidden");
}
els.interval.addEventListener("change", async () => {
const device = selectedDevice();
if (!device) return;
const intervalMs = Number(els.interval.value);
if (!Number.isFinite(intervalMs) || intervalMs < 5000) {
els.interval.value = device.intervalMs;
await showNotice("Publish interval", "Can't go lower than 5000.");
return;
}
try {
const data = await api(`/api/devices/${device.id}/interval`, {
method: "POST",
body: JSON.stringify({ intervalMs }),
});
upsertDevice(data.device);
} catch (error) {
els.interval.value = device.intervalMs;
await showNotice("Publish interval", error.message || "Can't go lower than 5000.");
}
});
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),
chargeRateWpm: Number(els.chargeRate.value),
batteryTotalW: Number(document.getElementById("battery-total").value),
batteryActualW: Number(document.getElementById("battery-actual").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) {
if (plantButton.getAttribute("data-plant-update") === "tank") saveTankTemp();
else savePlant();
}
});
document.getElementById("dashboard").addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
const input = event.target.closest(".plant-setting input");
if (!input) return;
const button = input.closest(".plant-setting")?.querySelector("[data-power-update], [data-plant-update]");
if (!button) return;
event.preventDefault();
button.click();
});
async function confirmIfDisconnected(device) {
if (device.connected) return true;
return askConfirm({
title: "MQTT",
message: "Device isn't connected. Turn on anyway?",
okLabel: "Turn on anyway",
cancelLabel: "Cancel",
});
}
document.getElementById("batt-toggle").addEventListener("click", async () => {
const device = selectedDevice();
if (!device) return;
const turningOn = !device.plant?.batteryOn;
if (turningOn && !(await confirmIfDisconnected(device))) return;
const data = await api(`/api/devices/${device.id}/plant`, {
method: "PATCH",
body: JSON.stringify({ batteryOn: turningOn }),
});
upsertDevice(data.device);
});
document.getElementById("aux-boost").addEventListener("click", async () => {
const device = selectedDevice();
if (!device) return;
const turningOn = !device.plantView?.auxOn;
if (turningOn && !(await confirmIfDisconnected(device))) return;
const data = await api(`/api/devices/${device.id}/plant`, {
method: "PATCH",
body: JSON.stringify({ auxManual: turningOn }),
});
upsertDevice(data.device);
});
document.getElementById("fault-card").addEventListener("change", async (event) => {
const control = event.target.closest("[data-fault-key]");
if (!control) return;
const numeric = Number(control.value);
if (!Number.isFinite(numeric)) return;
await saveFaultValue(control.dataset.faultKey, Math.trunc(numeric));
});
document.getElementById("fault-card").addEventListener("click", async (event) => {
const button = event.target.closest("[data-fault-publish]");
if (button) {
await publishFaultKeys([button.dataset.faultPublish]);
return;
}
if (event.target.id === "fault-publish-all") {
await publishFaultKeys(faultFields().map((field) => field.key));
}
});
document.getElementById("hw-boost").addEventListener("click", async () => {
const device = selectedDevice();
if (!device) return;
const turningOn = !device.plantView?.hwOn;
if (turningOn) {
const tank = Number(device.telemetry?.heatsink_s);
const max = Number(device.plant?.thermostatC ?? 65);
if (Number.isFinite(tank) && tank >= max) {
els.confirmCancel.classList.add("hidden");
await askConfirm({
title: "Hot water",
message: "Max temp already reached",
okLabel: "OK",
});
els.confirmCancel.classList.remove("hidden");
return;
}
if (!(await confirmIfDisconnected(device))) return;
}
const data = await api(`/api/devices/${device.id}/plant`, {
method: "PATCH",
body: JSON.stringify({ hwManual: turningOn }),
});
upsertDevice(data.device);
});
async function saveTankTemp() {
const device = selectedDevice();
if (!device) return;
const data = await api(`/api/devices/${device.id}/telemetry`, {
method: "PATCH",
body: JSON.stringify({ heatsink_s: Number(document.getElementById("tank-temp-input").value) }),
});
upsertDevice(data.device);
}
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 enumSelect = event.target.closest("[data-enum-key]");
if (enumSelect) {
await saveFaultValue(enumSelect.dataset.enumKey, Number(enumSelect.value));
return;
}
const intInput = event.target.closest("[data-int-key]");
if (intInput) {
const numeric = Number(intInput.value);
if (!Number.isFinite(numeric)) return;
await saveFaultValue(intInput.dataset.intKey, Math.trunc(numeric));
return;
}
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 field = state.schema.fields.find((item) => item.key === key);
const value = readNewValue(key);
if (value == null || Number.isNaN(value)) return;
const shared = field?.share
? state.schema.fields.filter((item) => item.share === field.share)
: [field || { key }];
if (field?.share === "batteryLevel") {
const device = selectedDevice();
const capacity = Number(device?.plant?.batteryTotalW) || 0;
const actual = Math.max(0, Math.min(capacity, Math.round((value / 100) * capacity)));
if (device) {
const data = await api(`/api/devices/${device.id}/plant`, {
method: "PATCH",
body: JSON.stringify({ batteryActualW: actual }),
});
upsertDevice(data.device);
}
}
for (const item of shared) state.pendingTelemetry[item.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);
});
const hostClock = {
skewMs: 0,
timezoneOffsetMinutes: new Date().getTimezoneOffset(),
};
function pad2(value) {
return String(value).padStart(2, "0");
}
function formatHostLocal(epochMs, timezoneOffsetMinutes) {
const shifted = new Date(epochMs - timezoneOffsetMinutes * 60_000);
return `${pad2(shifted.getUTCHours())}:${pad2(shifted.getUTCMinutes())}:${pad2(shifted.getUTCSeconds())}`;
}
function renderLocalTime() {
const epochMs = Date.now() + hostClock.skewMs;
els.localTime.textContent = `Current local time: ${formatHostLocal(epochMs, hostClock.timezoneOffsetMinutes)}`;
}
async function syncHostClock() {
const data = await api("/api/clock");
if (!Number.isFinite(data?.epochMs) || !Number.isFinite(data?.timezoneOffsetMinutes)) return;
hostClock.skewMs = data.epochMs - Date.now();
hostClock.timezoneOffsetMinutes = data.timezoneOffsetMinutes;
renderLocalTime();
}
function startLocalClock() {
renderLocalTime();
setInterval(renderLocalTime, 250);
syncHostClock().catch(() => {});
setInterval(() => {
syncHostClock().catch(() => {});
}, 30_000);
}
async function boot() {
startLocalClock();
try {
await loadStatic();
await refresh();
hideLogin();
setInterval(() => {
refresh().catch(() => {});
}, 2000);
} catch (error) {
if (error.status !== 401) {
showLogin(error.message);
}
}
}
boot();