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 0000000..f348e2e Binary files /dev/null and b/dvgo-emu-readme.docx differ 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 @@