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"), 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"), }; 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 = `

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)); els.hwLampDetail.textContent = view.hwDetail || "Off"; els.auxLampDetail.textContent = view.auxDetail || "Off"; if (document.activeElement !== els.heatRate) { els.heatRate.value = plant.heatRateCpm ?? 1; } if (document.activeElement !== els.thermostat) { els.thermostat.value = plant.thermostatC ?? 65; } const tank = device.telemetry?.heatsink_s; els.tankTemp.textContent = tank == null ? "—" : `${Number(tank).toFixed(1)} °C`; } 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 `

${escapeHtml(group.title)}

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

${fields.map((field) => fieldRowHtml(field, values[field.key], enabled.has(field.key))).join("")}
key value On new 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 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 = ` `; const submit = ` `; 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 = ` ${submit} `; } else { const step = field.step ?? (field.type === "integer" ? 1 : "any"); editor = ` ${submit} `; } return ` ${escapeHtml(field.key)} ${escapeHtml(displayValue(value))} ${onToggle} ${editor} `; } 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 || []; 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; } 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); }); document.getElementById("add-open").addEventListener("click", () => { els.addError.classList.add("hidden"); 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 payload = { 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, accessToken: document.getElementById("add-token").value || undefined, }; setAddBusy(true, "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; 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), }), }); upsertDevice(data.device); } els.heatRate.addEventListener("change", () => savePlant()); els.thermostat.addEventListener("change", () => 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();