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