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 = `

No emulated devices yet.

`; 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 `
${escapeHtml(device.label || device.name)} ${escapeHtml(device.name)}${device.telemetryEnabled ? " · telemetry on" : " · telemetry off"}
`; }) .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 `

${escapeHtml(group.title)}

${escapeHtml(group.subtitle || "")}

${showNewValue ? "" : ""} ${fieldRowsHtml(fields, values, enabled)}
key value Onnew value
`; }) .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 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 = ` `; const submitKey = share?.inputKey || field.key; const submit = ` `; const title = escapeHtml(field.unit ? `${field.label} (${field.unit})` : field.label); let editor; let valueCell = `${escapeHtml(displayValue(value))}`; if (field.type === "enum") { valueCell = `${enumSelectHtml(field, value, "data-enum-key", { raw: true })}`; editor = ""; } else if (field.type === "boolean") { const on = value === 1 || value === true || value === "1"; editor = ` ${submit} `; } else { const step = field.step ?? (field.type === "integer" ? 1 : "any"); editor = ` ${submit} `; } const shareClass = share ? ` share-group share-${share.edge}` : ""; const editorCell = field.group === "faults" ? "" : field.type === "enum" ? `` : !share || share.editor ? `${editor}` : ""; return ` ${escapeHtml(field.key)} ${valueCell} ${onToggle} ${editorCell} `; } 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 ``; }) .join(""); return ``; } 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) => `
${escapeHtml(field.key)} ${enumSelectHtml(field, null, "data-fault-key")}
`, ) .join(""); } function renderFaultSelects(device) { const values = device.telemetry || {}; for (const select of document.querySelectorAll("[data-fault-key]")) { if (document.activeElement === select) continue; const key = select.dataset.faultKey; if (values[key] != null) select.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 select = document.querySelector(`[data-fault-key="${CSS.escape(key)}"]`); body[key] = Number(select?.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("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } 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) => ``, ) .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 select = event.target.closest("[data-fault-key]"); if (!select) return; await saveFaultValue(select.dataset.faultKey, Number(select.value)); }); 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 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();