1189 lines
41 KiB
JavaScript
1189 lines
41 KiB
JavaScript
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,
|
||
};
|
||
|
||
const els = {
|
||
loginOverlay: document.getElementById("login-overlay"),
|
||
loginForm: document.getElementById("login-form"),
|
||
loginError: document.getElementById("login-error"),
|
||
localTime: document.getElementById("local-time"),
|
||
tbStatus: document.getElementById("tb-status"),
|
||
mqttStatus: document.getElementById("mqtt-status"),
|
||
deviceList: document.getElementById("device-list"),
|
||
deviceCount: document.getElementById("device-count"),
|
||
addDeviceCount: document.getElementById("add-device-count"),
|
||
emptyState: document.getElementById("empty-state"),
|
||
dashboard: document.getElementById("dashboard"),
|
||
dashName: document.getElementById("dash-name"),
|
||
dashType: document.getElementById("dash-type"),
|
||
dashId: document.getElementById("dash-id"),
|
||
toggleMqtt: document.getElementById("toggle-mqtt"),
|
||
mqttSpinner: document.getElementById("mqtt-spinner"),
|
||
connectionResponse: document.getElementById("connection-response"),
|
||
connectionResponseLabel: document.querySelector(".connection-response"),
|
||
toggleTelemetry: document.getElementById("toggle-telemetry"),
|
||
deleteOne: document.getElementById("delete-one"),
|
||
interval: document.getElementById("interval-input"),
|
||
publishNow: document.getElementById("publish-now"),
|
||
publishSelectAll: document.getElementById("publish-select-all"),
|
||
publishDeselectAll: document.getElementById("publish-deselect-all"),
|
||
lastPublished: document.getElementById("last-published"),
|
||
groups: document.getElementById("telemetry-groups"),
|
||
rpcPre: document.getElementById("rpc-pre"),
|
||
addModal: document.getElementById("add-modal"),
|
||
addForm: document.getElementById("add-form"),
|
||
addError: document.getElementById("add-error"),
|
||
addProfile: document.getElementById("add-profile"),
|
||
addBusy: document.getElementById("add-busy"),
|
||
addBusyText: document.getElementById("add-busy-text"),
|
||
addExisting: document.getElementById("add-existing"),
|
||
addCreateFields: document.getElementById("add-create-fields"),
|
||
addTokenField: document.getElementById("add-token-field"),
|
||
addTitle: document.getElementById("add-title"),
|
||
addHelp: document.getElementById("add-help"),
|
||
addSubmit: document.getElementById("add-submit"),
|
||
confirmModal: document.getElementById("confirm-modal"),
|
||
confirmTitle: document.getElementById("confirm-title"),
|
||
confirmMessage: document.getElementById("confirm-message"),
|
||
confirmOk: document.getElementById("confirm-ok"),
|
||
confirmCancel: document.getElementById("confirm-cancel"),
|
||
bulkStatus: document.getElementById("bulk-status"),
|
||
hwLamp: document.getElementById("hw-lamp"),
|
||
hwLampDetail: document.getElementById("hw-lamp-detail"),
|
||
auxLamp: document.getElementById("aux-lamp"),
|
||
auxLampDetail: document.getElementById("aux-lamp-detail"),
|
||
heatRate: document.getElementById("heat-rate"),
|
||
thermostat: document.getElementById("thermostat"),
|
||
tankTemp: document.getElementById("tank-temp"),
|
||
battLamp: document.getElementById("batt-lamp"),
|
||
battLampDetail: document.getElementById("batt-lamp-detail"),
|
||
chargeRate: document.getElementById("charge-rate"),
|
||
};
|
||
|
||
function headers(extra = {}) {
|
||
const result = { "Content-Type": "application/json", ...extra };
|
||
if (state.auth) result.Authorization = `Basic ${state.auth}`;
|
||
return result;
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const response = await fetch(path, {
|
||
...options,
|
||
headers: headers(options.headers),
|
||
});
|
||
if (response.status === 401) {
|
||
showLogin("Authentication required");
|
||
const error = new Error("Authentication required");
|
||
error.status = 401;
|
||
throw error;
|
||
}
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
const error = new Error(data.error || `Request failed (${response.status})`);
|
||
error.status = response.status;
|
||
error.timedOut = Boolean(data.timedOut);
|
||
throw error;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function showLogin(message) {
|
||
els.loginOverlay.classList.remove("hidden");
|
||
els.loginError.classList.toggle("hidden", !message);
|
||
els.loginError.textContent = message || "";
|
||
}
|
||
|
||
function hideLogin() {
|
||
els.loginOverlay.classList.add("hidden");
|
||
}
|
||
|
||
function askConfirm({ title, message, okLabel = "OK", cancelLabel = "Cancel" }) {
|
||
els.confirmTitle.textContent = title;
|
||
els.confirmMessage.textContent = message;
|
||
els.confirmOk.textContent = okLabel;
|
||
els.confirmCancel.textContent = cancelLabel;
|
||
els.confirmModal.classList.remove("hidden");
|
||
|
||
return new Promise((resolve) => {
|
||
const finish = (value) => {
|
||
els.confirmModal.classList.add("hidden");
|
||
els.confirmOk.removeEventListener("click", onOk);
|
||
els.confirmCancel.removeEventListener("click", onCancel);
|
||
resolve(value);
|
||
};
|
||
const onOk = () => finish(true);
|
||
const onCancel = () => finish(false);
|
||
els.confirmOk.addEventListener("click", onOk);
|
||
els.confirmCancel.addEventListener("click", onCancel);
|
||
});
|
||
}
|
||
|
||
function selectedDevice() {
|
||
return state.devices.find((device) => device.id === state.selectedId) || null;
|
||
}
|
||
|
||
function setSelected(id) {
|
||
state.selectedId = id;
|
||
localStorage.setItem("emu-selected", id || "");
|
||
}
|
||
|
||
function formatTime(ts) {
|
||
if (!ts) return "Never published";
|
||
return `Last publish ${new Date(ts).toLocaleTimeString()}`;
|
||
}
|
||
|
||
function renderHealth() {
|
||
const tb = state.health?.thingsboard;
|
||
els.tbStatus.textContent = tb?.ok ? `ThingsBoard ${tb.host}` : `ThingsBoard ${tb?.error || "offline"}`;
|
||
els.tbStatus.className = `status-pill ${tb?.ok ? "on" : "off"}`;
|
||
els.mqttStatus.textContent = state.health?.mqttUrl || "MQTT";
|
||
els.mqttStatus.className = "status-pill on";
|
||
}
|
||
|
||
function atDeviceLimit() {
|
||
return state.devices.length >= state.deviceLimit;
|
||
}
|
||
|
||
function renderDeviceCount() {
|
||
const label = `${state.devices.length}/${state.deviceLimit}`;
|
||
const full = atDeviceLimit();
|
||
els.deviceCount.textContent = label;
|
||
els.deviceCount.classList.toggle("at-limit", full);
|
||
els.addDeviceCount.textContent = `${label} devices`;
|
||
els.addDeviceCount.classList.toggle("at-limit", full);
|
||
}
|
||
|
||
function renderList() {
|
||
renderDeviceCount();
|
||
if (!state.devices.length) {
|
||
els.deviceList.innerHTML = `<p class="muted">No emulated devices yet.</p>`;
|
||
return;
|
||
}
|
||
|
||
els.deviceList.innerHTML = state.devices
|
||
.map((device) => {
|
||
const checked = state.selectedIds.has(device.id) ? "checked" : "";
|
||
const selected = device.id === state.selectedId ? "selected" : "";
|
||
const live = device.connected && !device.disabled;
|
||
return `
|
||
<div class="device-row ${selected}" data-id="${device.id}">
|
||
<input class="device-check" type="checkbox" data-select="${device.id}" ${checked} aria-label="Select ${escapeHtml(device.name)}" />
|
||
<div>
|
||
<strong>${escapeHtml(device.label || device.name)}</strong>
|
||
<small>${escapeHtml(device.name)}${device.telemetryEnabled ? " · telemetry on" : " · telemetry off"}</small>
|
||
</div>
|
||
<span class="dot ${live ? "live" : ""}" title="${live ? "Connected" : "Disconnected"}"></span>
|
||
</div>
|
||
`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function renderDashboard({ refreshFields = true } = {}) {
|
||
const device = selectedDevice();
|
||
if (!device) {
|
||
state.lastRenderedId = "";
|
||
els.dashboard.classList.add("hidden");
|
||
els.emptyState.classList.remove("hidden");
|
||
return;
|
||
}
|
||
|
||
els.emptyState.classList.add("hidden");
|
||
els.dashboard.classList.remove("hidden");
|
||
els.dashName.textContent = device.label || device.name;
|
||
els.dashType.textContent = device.type || "Device";
|
||
els.dashId.textContent = device.id;
|
||
|
||
const mqttBusy = Boolean(device.connecting);
|
||
els.mqttSpinner.classList.toggle("hidden", !mqttBusy);
|
||
els.toggleMqtt.disabled = mqttBusy;
|
||
if (device.connected) {
|
||
els.toggleMqtt.textContent = "Disconnect";
|
||
els.toggleMqtt.className = "";
|
||
} else {
|
||
els.toggleMqtt.textContent = "Connect";
|
||
els.toggleMqtt.className = "primary";
|
||
}
|
||
const response = device.lastConnectionResponse || device.lastError || "—";
|
||
els.connectionResponse.textContent = response;
|
||
els.connectionResponseLabel.classList.toggle(
|
||
"error",
|
||
Boolean(device.lastError) && !device.connected && !device.connecting,
|
||
);
|
||
|
||
els.toggleTelemetry.textContent = device.telemetryEnabled ? "Stop telemetry" : "Start telemetry";
|
||
els.toggleTelemetry.className = device.telemetryEnabled ? "stop-telemetry" : "primary";
|
||
els.toggleTelemetry.disabled = !device.connected && !device.telemetryEnabled;
|
||
if (document.activeElement !== els.interval) {
|
||
els.interval.value = device.intervalMs;
|
||
}
|
||
els.lastPublished.textContent = formatTime(device.lastPublishedAt);
|
||
renderPlant(device);
|
||
els.rpcPre.textContent = JSON.stringify(
|
||
{ lastRpc: device.lastRpc, sharedAttributes: device.sharedAttributes },
|
||
null,
|
||
2,
|
||
);
|
||
|
||
const editing =
|
||
els.groups.contains(document.activeElement) || Object.keys(state.pendingTelemetry).length > 0;
|
||
const deviceChanged = state.lastRenderedId !== device.id;
|
||
state.lastRenderedId = device.id;
|
||
if ((refreshFields || deviceChanged || !els.groups.childElementCount) && !editing) {
|
||
renderFields(device);
|
||
} else {
|
||
syncFieldValues(device);
|
||
}
|
||
}
|
||
|
||
function renderPlant(device) {
|
||
const view = device.plantView || {};
|
||
const plant = device.plant || {};
|
||
els.hwLamp.classList.toggle("on", Boolean(view.hwOn));
|
||
els.auxLamp.classList.toggle("on", Boolean(view.auxOn));
|
||
const batteryRow = (view.power?.rows || []).find((row) => row.id === "battery");
|
||
els.battLamp.classList.toggle("draw", batteryRow?.state === "draw");
|
||
els.battLamp.classList.toggle("supply", batteryRow?.state === "supply");
|
||
const hwBoost = document.getElementById("hw-boost");
|
||
hwBoost.textContent = view.hwOn ? "Hot Water Boost Stop" : "Hot Water Boost Start";
|
||
hwBoost.classList.toggle("primary", !view.hwOn);
|
||
hwBoost.classList.toggle("danger", Boolean(view.hwOn));
|
||
const auxBoost = document.getElementById("aux-boost");
|
||
auxBoost.textContent = view.auxOn ? "Aux Boost Stop" : "Aux Boost Start";
|
||
auxBoost.classList.toggle("primary", !view.auxOn);
|
||
auxBoost.classList.toggle("danger", Boolean(view.auxOn));
|
||
const battToggle = document.getElementById("batt-toggle");
|
||
battToggle.textContent = plant.batteryOn ? "Battery On" : "Battery Off";
|
||
battToggle.classList.toggle("primary", !plant.batteryOn);
|
||
battToggle.classList.toggle("danger", Boolean(plant.batteryOn));
|
||
const heat = plant.heatRateCpm ?? 1;
|
||
const thermo = plant.thermostatC ?? 65;
|
||
const charge = plant.chargeRateWpm ?? 500;
|
||
const batteryTotal = plant.batteryTotalW ?? 5000;
|
||
document.getElementById("heat-rate-current").textContent = String(heat);
|
||
document.getElementById("thermostat-current").textContent = String(thermo);
|
||
document.getElementById("charge-rate-current").textContent = String(charge);
|
||
document.getElementById("battery-total-current").textContent = String(batteryTotal);
|
||
if (document.activeElement !== els.heatRate) els.heatRate.value = heat;
|
||
if (document.activeElement !== els.thermostat) els.thermostat.value = thermo;
|
||
if (document.activeElement !== els.chargeRate) els.chargeRate.value = charge;
|
||
const batteryTotalInput = document.getElementById("battery-total");
|
||
if (document.activeElement !== batteryTotalInput) batteryTotalInput.value = batteryTotal;
|
||
const batteryActual = Math.max(0, Math.min(batteryTotal, Number(plant.batteryActualW) || 0));
|
||
document.getElementById("battery-actual-current").textContent = String(batteryActual);
|
||
const batteryActualInput = document.getElementById("battery-actual");
|
||
batteryActualInput.max = String(batteryTotal);
|
||
if (document.activeElement !== batteryActualInput) batteryActualInput.value = batteryActual;
|
||
const tank = device.telemetry?.heatsink_s;
|
||
const tankInput = document.getElementById("tank-temp-input");
|
||
els.tankTemp.textContent = tank == null ? "—" : Number(tank).toFixed(1);
|
||
if (tankInput && document.activeElement !== tankInput && tank != null) tankInput.value = Number(tank);
|
||
renderPowerSim(device);
|
||
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);
|
||
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>
|
||
${fieldRowsHtml(fields, values, enabled)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function displayValue(value) {
|
||
if (value === true || value === "1") return "1";
|
||
if (value === false || value === "0") return "0";
|
||
if (value == null) return "—";
|
||
return String(value);
|
||
}
|
||
|
||
function syncFieldValues(device) {
|
||
const pending = state.pendingTelemetry;
|
||
const values = device.telemetry || {};
|
||
const enabled = publishedKeySet(device);
|
||
|
||
for (const cell of els.groups.querySelectorAll("[data-value-key]")) {
|
||
const key = cell.getAttribute("data-value-key");
|
||
if (!key || Object.prototype.hasOwnProperty.call(pending, key)) continue;
|
||
cell.textContent = displayValue(values[key]);
|
||
}
|
||
|
||
for (const toggle of els.groups.querySelectorAll("[data-publish-key]")) {
|
||
const key = toggle.dataset.publishKey;
|
||
const on = enabled.has(key);
|
||
if (document.activeElement !== toggle) toggle.checked = on;
|
||
const row = toggle.closest("tr");
|
||
if (row) row.classList.toggle("inactive", !on);
|
||
}
|
||
}
|
||
|
||
function fieldRowsHtml(fields, values, enabled) {
|
||
const blocks = [];
|
||
for (const field of fields) {
|
||
const last = blocks[blocks.length - 1];
|
||
if (field.share && last?.share === field.share) last.fields.push(field);
|
||
else blocks.push({ share: field.share || "", fields: [field] });
|
||
}
|
||
return blocks
|
||
.map((block) => {
|
||
if (block.fields.length < 2) {
|
||
const field = block.fields[0];
|
||
return fieldRowHtml(field, values[field.key], enabled.has(field.key));
|
||
}
|
||
return block.fields
|
||
.map((field, index) => fieldRowHtml(field, values[field.key], enabled.has(field.key), {
|
||
share: true,
|
||
editor: index === 0,
|
||
span: block.fields.length,
|
||
inputKey: block.fields[0].key,
|
||
edge: index === 0 ? "start" : index === block.fields.length - 1 ? "end" : "mid",
|
||
}))
|
||
.join("");
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function fieldRowHtml(field, value, enabled, share = null) {
|
||
const inactive = enabled ? "" : "inactive";
|
||
const onToggle = `
|
||
<input
|
||
class="device-check key-on-check"
|
||
type="checkbox"
|
||
data-publish-key="${escapeHtml(field.key)}"
|
||
${enabled ? "checked" : ""}
|
||
aria-label="Include ${escapeHtml(field.key)} in telemetry"
|
||
/>
|
||
`;
|
||
const submitKey = share?.inputKey || field.key;
|
||
const submit = `
|
||
<button type="button" data-submit-key="${escapeHtml(submitKey)}">update</button>
|
||
`;
|
||
const title = escapeHtml(field.unit ? `${field.label} (${field.unit})` : field.label);
|
||
|
||
let editor;
|
||
if (field.type === "boolean") {
|
||
const on = value === 1 || value === true || value === "1";
|
||
editor = `
|
||
<select
|
||
data-key="${escapeHtml(submitKey)}"
|
||
aria-label="New value for ${escapeHtml(field.key)}"
|
||
>
|
||
<option value="1" ${on ? "selected" : ""}>true</option>
|
||
<option value="0" ${on ? "" : "selected"}>false</option>
|
||
</select>
|
||
${submit}
|
||
`;
|
||
} else {
|
||
const step = field.step ?? (field.type === "integer" ? 1 : "any");
|
||
editor = `
|
||
<input
|
||
data-key="${escapeHtml(submitKey)}"
|
||
type="number"
|
||
step="${step}"
|
||
${field.min != null ? `min="${field.min}"` : ""}
|
||
${field.max != null ? `max="${field.max}"` : ""}
|
||
value="${value ?? 0}"
|
||
aria-label="New value for ${escapeHtml(field.key)}"
|
||
/>
|
||
${submit}
|
||
`;
|
||
}
|
||
|
||
const shareClass = share ? ` share-group share-${share.edge}` : "";
|
||
const editorCell = !share || share.editor
|
||
? `<td class="new-value"${share ? ` rowspan="${share.span}"` : ""}>${editor}</td>`
|
||
: "";
|
||
return `
|
||
<tr class="field-row ${inactive}${shareClass}">
|
||
<td class="key" title="${title}">${escapeHtml(field.key)}</td>
|
||
<td class="field-value" data-value-key="${escapeHtml(field.key)}">${escapeHtml(displayValue(value))}</td>
|
||
<td class="on-cell">${onToggle}</td>
|
||
${editorCell}
|
||
</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 || [];
|
||
state.deviceLimit = Number(devices.limit) || 100;
|
||
if (state.selectedId && !state.devices.some((device) => device.id === state.selectedId)) {
|
||
setSelected(state.devices[0]?.id || "");
|
||
}
|
||
if (!state.selectedId && state.devices[0]) setSelected(state.devices[0].id);
|
||
renderHealth();
|
||
renderList();
|
||
renderDashboard({ refreshFields: false });
|
||
}
|
||
|
||
async function loadStatic() {
|
||
const [schema, profiles] = await Promise.all([
|
||
api("/api/telemetry-schema"),
|
||
api("/api/device-profiles").catch(() => ({ profiles: [] })),
|
||
]);
|
||
state.schema = schema;
|
||
state.profiles = profiles.profiles || [];
|
||
const preferredId =
|
||
profiles.defaultProfileId ||
|
||
state.profiles.find((profile) => profile.preferred)?.id ||
|
||
"";
|
||
els.addProfile.innerHTML = state.profiles
|
||
.map(
|
||
(profile) =>
|
||
`<option value="${profile.id}">${escapeHtml(profile.name)}${
|
||
profile.preferred ? " (diverter)" : profile.default ? " (TB default)" : ""
|
||
}</option>`,
|
||
)
|
||
.join("");
|
||
if (preferredId) els.addProfile.value = preferredId;
|
||
}
|
||
|
||
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("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 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();
|