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 `
`;
})
.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 || "")}
| key |
value |
On |
${showNewValue ? "new value | " : ""}
${fieldRowsHtml(fields, values, enabled)}
`;
})
.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 = `
`;
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.group === "faults") {
valueCell = `${faultIntegerHtml(field, value, "data-int-key")} | `;
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 faultIntegerHtml(field, value, attr) {
const shown = value == null || value === "" ? "" : String(value);
return ``;
}
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) => `
${escapeHtml(field.key)}
${faultControlHtml(field, null)}
`,
)
.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("&", "&")
.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 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();