From 7b330cccecd12848c82731d9a57fc558c82f6ff7 Mon Sep 17 00:00:00 2001 From: Kris MacAir Date: Mon, 19 Oct 2026 17:02:27 +0100 Subject: [PATCH] and another commit --- .cursorignore | 2 + .gitignore | 1 + dvgo-emu-readme.docx | Bin 0 -> 31523 bytes public/app.js | 136 +++++++++++++++++++++++++++++++++++++++++-- public/index.html | 32 +++++++++- public/styles.css | 96 ++++++++++++++++++++++++++++++ src/auth.js | 72 ++++++++++++++++++++++- src/config.js | 20 ++++++- src/deviceManager.js | 30 +++++++++- src/index.js | 16 ++++- src/plant.js | 36 +++++++++++- src/routes.js | 15 ++++- src/telemetry.js | 5 +- src/virtualDevice.js | 58 ++++++++++++++++-- 14 files changed, 494 insertions(+), 25 deletions(-) create mode 100644 .cursorignore create mode 100644 dvgo-emu-readme.docx diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..4ebf887 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,2 @@ +.env +.env.* \ No newline at end of file diff --git a/.gitignore b/.gitignore index bdbd55f..c2c6c43 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules/ data/*.db data/*.db-* .env +.env.* .DS_Store deploy/ \ No newline at end of file diff --git a/dvgo-emu-readme.docx b/dvgo-emu-readme.docx new file mode 100644 index 0000000000000000000000000000000000000000..f348e2edf0158d7818a4416492d14ad5cf3e621b GIT binary patch literal 31523 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 OVP>J7PUyw`tN#bE5LGk) literal 0 HcmV?d00001 diff --git a/public/app.js b/public/app.js index 34dd42e..254006b 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,7 @@ const state = { auth: sessionStorage.getItem("emu-auth") || "", devices: [], + deviceLimit: 100, selectedId: localStorage.getItem("emu-selected") || "", selectedIds: new Set(), schema: { groups: [], fields: [] }, @@ -14,9 +15,12 @@ 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"), @@ -145,7 +149,21 @@ function renderHealth() { 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; @@ -272,6 +290,44 @@ function renderPlant(device) { 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) { @@ -512,6 +568,7 @@ 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 || ""); } @@ -602,6 +659,14 @@ els.addExisting.addEventListener("change", () => { 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", () => { @@ -621,6 +686,12 @@ function setAddBusy(busy, text) { 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 } @@ -686,6 +757,7 @@ els.addForm.addEventListener("submit", async (event) => { els.addError.textContent = error.message; } finally { setAddBusy(false); + if (atDeviceLimit()) els.addSubmit.disabled = true; } }); @@ -831,14 +903,31 @@ els.deleteOne.addEventListener("click", async () => { 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 data = await api(`/api/devices/${device.id}/interval`, { - method: "POST", - body: JSON.stringify({ intervalMs: Number(els.interval.value) }), - }); - upsertDevice(data.device); + 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() { @@ -1044,7 +1133,44 @@ els.groups.addEventListener("keydown", (event) => { 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(); diff --git a/public/index.html b/public/index.html index 712643e..c396ac6 100644 --- a/public/index.html +++ b/public/index.html @@ -36,6 +36,7 @@

DVgo device emulator

+
Current local time: --:--:--
ThingsBoard
MQTT
@@ -44,7 +45,10 @@