and another commit
This commit is contained in:
parent
95998a0d75
commit
7b330cccec
2
.cursorignore
Normal file
2
.cursorignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,5 +2,6 @@ node_modules/
|
|||||||
data/*.db
|
data/*.db
|
||||||
data/*.db-*
|
data/*.db-*
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
deploy/
|
deploy/
|
||||||
BIN
dvgo-emu-readme.docx
Normal file
BIN
dvgo-emu-readme.docx
Normal file
Binary file not shown.
136
public/app.js
136
public/app.js
@ -1,6 +1,7 @@
|
|||||||
const state = {
|
const state = {
|
||||||
auth: sessionStorage.getItem("emu-auth") || "",
|
auth: sessionStorage.getItem("emu-auth") || "",
|
||||||
devices: [],
|
devices: [],
|
||||||
|
deviceLimit: 100,
|
||||||
selectedId: localStorage.getItem("emu-selected") || "",
|
selectedId: localStorage.getItem("emu-selected") || "",
|
||||||
selectedIds: new Set(),
|
selectedIds: new Set(),
|
||||||
schema: { groups: [], fields: [] },
|
schema: { groups: [], fields: [] },
|
||||||
@ -14,9 +15,12 @@ const els = {
|
|||||||
loginOverlay: document.getElementById("login-overlay"),
|
loginOverlay: document.getElementById("login-overlay"),
|
||||||
loginForm: document.getElementById("login-form"),
|
loginForm: document.getElementById("login-form"),
|
||||||
loginError: document.getElementById("login-error"),
|
loginError: document.getElementById("login-error"),
|
||||||
|
localTime: document.getElementById("local-time"),
|
||||||
tbStatus: document.getElementById("tb-status"),
|
tbStatus: document.getElementById("tb-status"),
|
||||||
mqttStatus: document.getElementById("mqtt-status"),
|
mqttStatus: document.getElementById("mqtt-status"),
|
||||||
deviceList: document.getElementById("device-list"),
|
deviceList: document.getElementById("device-list"),
|
||||||
|
deviceCount: document.getElementById("device-count"),
|
||||||
|
addDeviceCount: document.getElementById("add-device-count"),
|
||||||
emptyState: document.getElementById("empty-state"),
|
emptyState: document.getElementById("empty-state"),
|
||||||
dashboard: document.getElementById("dashboard"),
|
dashboard: document.getElementById("dashboard"),
|
||||||
dashName: document.getElementById("dash-name"),
|
dashName: document.getElementById("dash-name"),
|
||||||
@ -145,7 +149,21 @@ function renderHealth() {
|
|||||||
els.mqttStatus.className = "status-pill on";
|
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() {
|
function renderList() {
|
||||||
|
renderDeviceCount();
|
||||||
if (!state.devices.length) {
|
if (!state.devices.length) {
|
||||||
els.deviceList.innerHTML = `<p class="muted">No emulated devices yet.</p>`;
|
els.deviceList.innerHTML = `<p class="muted">No emulated devices yet.</p>`;
|
||||||
return;
|
return;
|
||||||
@ -272,6 +290,44 @@ function renderPlant(device) {
|
|||||||
renderPowerSim(device);
|
renderPowerSim(device);
|
||||||
setPlantStatus("hotWater", view.hwOn ? "Boost On" : "");
|
setPlantStatus("hotWater", view.hwOn ? "Boost On" : "");
|
||||||
setPlantStatus("aux", view.auxOn ? "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) {
|
function setPlantStatus(id, text) {
|
||||||
@ -512,6 +568,7 @@ async function refresh() {
|
|||||||
const [health, devices] = await Promise.all([api("/api/health"), api("/api/devices")]);
|
const [health, devices] = await Promise.all([api("/api/health"), api("/api/devices")]);
|
||||||
state.health = health;
|
state.health = health;
|
||||||
state.devices = devices.devices || [];
|
state.devices = devices.devices || [];
|
||||||
|
state.deviceLimit = Number(devices.limit) || 100;
|
||||||
if (state.selectedId && !state.devices.some((device) => device.id === state.selectedId)) {
|
if (state.selectedId && !state.devices.some((device) => device.id === state.selectedId)) {
|
||||||
setSelected(state.devices[0]?.id || "");
|
setSelected(state.devices[0]?.id || "");
|
||||||
}
|
}
|
||||||
@ -602,6 +659,14 @@ els.addExisting.addEventListener("change", () => {
|
|||||||
document.getElementById("add-open").addEventListener("click", () => {
|
document.getElementById("add-open").addEventListener("click", () => {
|
||||||
els.addError.classList.add("hidden");
|
els.addError.classList.add("hidden");
|
||||||
applyAddMode();
|
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");
|
els.addModal.classList.remove("hidden");
|
||||||
});
|
});
|
||||||
document.getElementById("add-cancel").addEventListener("click", () => {
|
document.getElementById("add-cancel").addEventListener("click", () => {
|
||||||
@ -621,6 +686,12 @@ function setAddBusy(busy, text) {
|
|||||||
els.addForm.addEventListener("submit", async (event) => {
|
els.addForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
els.addError.classList.add("hidden");
|
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 existing = els.addExisting.checked;
|
||||||
const payload = existing
|
const payload = existing
|
||||||
? { accessToken: document.getElementById("add-token").value || undefined }
|
? { accessToken: document.getElementById("add-token").value || undefined }
|
||||||
@ -686,6 +757,7 @@ els.addForm.addEventListener("submit", async (event) => {
|
|||||||
els.addError.textContent = error.message;
|
els.addError.textContent = error.message;
|
||||||
} finally {
|
} finally {
|
||||||
setAddBusy(false);
|
setAddBusy(false);
|
||||||
|
if (atDeviceLimit()) els.addSubmit.disabled = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -831,14 +903,31 @@ els.deleteOne.addEventListener("click", async () => {
|
|||||||
await runBulk("delete");
|
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 () => {
|
els.interval.addEventListener("change", async () => {
|
||||||
const device = selectedDevice();
|
const device = selectedDevice();
|
||||||
if (!device) return;
|
if (!device) return;
|
||||||
const data = await api(`/api/devices/${device.id}/interval`, {
|
const intervalMs = Number(els.interval.value);
|
||||||
method: "POST",
|
if (!Number.isFinite(intervalMs) || intervalMs < 5000) {
|
||||||
body: JSON.stringify({ intervalMs: Number(els.interval.value) }),
|
els.interval.value = device.intervalMs;
|
||||||
});
|
await showNotice("Publish interval", "Can't go lower than 5000.");
|
||||||
upsertDevice(data.device);
|
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() {
|
async function savePlant() {
|
||||||
@ -1044,7 +1133,44 @@ els.groups.addEventListener("keydown", (event) => {
|
|||||||
submitNewValue(input.dataset.key);
|
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() {
|
async function boot() {
|
||||||
|
startLocalClock();
|
||||||
try {
|
try {
|
||||||
await loadStatic();
|
await loadStatic();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
<h1>DVgo device emulator</h1>
|
<h1>DVgo device emulator</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-meta">
|
<div class="topbar-meta">
|
||||||
|
<div id="local-time" class="local-time">Current local time: --:--:--</div>
|
||||||
<div id="tb-status" class="status-pill off">ThingsBoard</div>
|
<div id="tb-status" class="status-pill off">ThingsBoard</div>
|
||||||
<div id="mqtt-status" class="status-pill off">MQTT</div>
|
<div id="mqtt-status" class="status-pill off">MQTT</div>
|
||||||
</div>
|
</div>
|
||||||
@ -44,7 +45,10 @@
|
|||||||
<main class="layout">
|
<main class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Devices</h2>
|
<div class="panel-title">
|
||||||
|
<h2>Devices</h2>
|
||||||
|
<span id="device-count" class="device-count">0/100</span>
|
||||||
|
</div>
|
||||||
<div class="device-menu">
|
<div class="device-menu">
|
||||||
<button
|
<button
|
||||||
id="device-menu-toggle"
|
id="device-menu-toggle"
|
||||||
@ -159,6 +163,17 @@
|
|||||||
<span id="hw-lamp" class="lamp" aria-hidden="true"></span>
|
<span id="hw-lamp" class="lamp" aria-hidden="true"></span>
|
||||||
<output class="plant-status" data-plant-status="hotWater"></output>
|
<output class="plant-status" data-plant-status="hotWater"></output>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="schedule-board" id="hw-schedule">
|
||||||
|
<div class="schedule-line" data-schedule-line="hw-0">
|
||||||
|
<span class="schedule-label">Schedule 0</span>
|
||||||
|
<span class="schedule-times" data-schedule-times="hw-0">— – —</span>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-line" data-schedule-line="hw-1">
|
||||||
|
<span class="schedule-label">Schedule 1</span>
|
||||||
|
<span class="schedule-times" data-schedule-times="hw-1">— – —</span>
|
||||||
|
</div>
|
||||||
|
<p class="schedule-note" data-schedule-note="hw"></p>
|
||||||
|
</div>
|
||||||
<button type="button" id="hw-boost" class="plant-boost primary">Hot Water Boost Start</button>
|
<button type="button" id="hw-boost" class="plant-boost primary">Hot Water Boost Start</button>
|
||||||
<div class="plant-setting">
|
<div class="plant-setting">
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
@ -211,6 +226,17 @@
|
|||||||
<span id="aux-lamp" class="lamp" aria-hidden="true"></span>
|
<span id="aux-lamp" class="lamp" aria-hidden="true"></span>
|
||||||
<output class="plant-status" data-plant-status="aux"></output>
|
<output class="plant-status" data-plant-status="aux"></output>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="schedule-board" id="aux-schedule">
|
||||||
|
<div class="schedule-line" data-schedule-line="aux-0">
|
||||||
|
<span class="schedule-label">Schedule 0</span>
|
||||||
|
<span class="schedule-times" data-schedule-times="aux-0">— – —</span>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-line" data-schedule-line="aux-1">
|
||||||
|
<span class="schedule-label">Schedule 1</span>
|
||||||
|
<span class="schedule-times" data-schedule-times="aux-1">— – —</span>
|
||||||
|
</div>
|
||||||
|
<p class="schedule-note" data-schedule-note="aux"></p>
|
||||||
|
</div>
|
||||||
<button type="button" id="aux-boost" class="plant-boost primary">Aux Boost Start</button>
|
<button type="button" id="aux-boost" class="plant-boost primary">Aux Boost Start</button>
|
||||||
<div class="plant-setting">
|
<div class="plant-setting">
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
@ -272,7 +298,7 @@
|
|||||||
<div class="meta-row publish-fields">
|
<div class="meta-row publish-fields">
|
||||||
<label>
|
<label>
|
||||||
Publish interval (ms)
|
Publish interval (ms)
|
||||||
<input id="interval-input" type="number" min="500" step="500" />
|
<input id="interval-input" type="number" min="5000" step="500" />
|
||||||
</label>
|
</label>
|
||||||
<p id="last-published" class="muted"></p>
|
<p id="last-published" class="muted"></p>
|
||||||
</div>
|
</div>
|
||||||
@ -281,6 +307,7 @@
|
|||||||
<button id="publish-select-all" type="button">Select all</button>
|
<button id="publish-select-all" type="button">Select all</button>
|
||||||
<button id="publish-deselect-all" type="button">Deselect all</button>
|
<button id="publish-deselect-all" type="button">Deselect all</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="muted publish-note">Publish now sends a single telemetry payload. It does not start the repeating loop.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="telemetry-groups"></div>
|
<div id="telemetry-groups"></div>
|
||||||
@ -301,6 +328,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="eyebrow">New virtual device</p>
|
<p class="eyebrow">New virtual device</p>
|
||||||
<h2 id="add-title">Add to emulator</h2>
|
<h2 id="add-title">Add to emulator</h2>
|
||||||
|
<p id="add-device-count" class="device-count">0/100 devices</p>
|
||||||
<p id="add-help" class="muted">Creates a new device in ThingsBoard and this emulator.</p>
|
<p id="add-help" class="muted">Creates a new device in ThingsBoard and this emulator.</p>
|
||||||
<label class="add-existing-toggle">
|
<label class="add-existing-toggle">
|
||||||
<input id="add-existing" class="device-check" type="checkbox" />
|
<input id="add-existing" class="device-check" type="checkbox" />
|
||||||
|
|||||||
@ -164,6 +164,16 @@ button.stop-telemetry:active:not(:disabled) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.local-time {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -228,6 +238,23 @@ button.stop-telemetry:active:not(:disabled) {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-count {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-count.at-limit {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
.dash-head {
|
.dash-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
@ -423,6 +450,11 @@ button.stop-telemetry:active:not(:disabled) {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.publish-note {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@ -486,6 +518,70 @@ select {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-board {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-board.is-fresh {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent-dim);
|
||||||
|
animation: schedule-in 1.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-line.is-active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-times {
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-line.is-active .schedule-times {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-note:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes schedule-in {
|
||||||
|
from { background: rgba(61, 204, 138, 0.28); }
|
||||||
|
to { background: var(--bg-elev-2); }
|
||||||
|
}
|
||||||
|
|
||||||
.plant-boost {
|
.plant-boost {
|
||||||
display: block;
|
display: block;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
|
|||||||
72
src/auth.js
72
src/auth.js
@ -1,8 +1,67 @@
|
|||||||
|
const MAX_LOGIN_FAILURES = 5;
|
||||||
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const LOGIN_LOCK_MS = 15 * 60 * 1000;
|
||||||
|
const LOGIN_BURST_MS = 1000;
|
||||||
|
|
||||||
|
const loginFailures = new Map();
|
||||||
|
|
||||||
function unauthorized(res) {
|
function unauthorized(res) {
|
||||||
res.set("WWW-Authenticate", 'Basic realm="Diverter emulator"');
|
res.set("WWW-Authenticate", 'Basic realm="Diverter emulator"');
|
||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatWait(ms) {
|
||||||
|
const seconds = Math.max(1, Math.ceil(ms / 1000));
|
||||||
|
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
|
||||||
|
const minutes = Math.ceil(seconds / 60);
|
||||||
|
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneLoginFailures(now) {
|
||||||
|
for (const [key, entry] of loginFailures) {
|
||||||
|
const lockExpired = !entry.lockedUntil || entry.lockedUntil <= now;
|
||||||
|
const windowExpired = now - entry.windowStart > LOGIN_WINDOW_MS;
|
||||||
|
if (lockExpired && windowExpired) loginFailures.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginKey(req) {
|
||||||
|
return clientIp(req) || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectIfLocked(req, res) {
|
||||||
|
const entry = loginFailures.get(loginKey(req));
|
||||||
|
const now = Date.now();
|
||||||
|
if (!entry?.lockedUntil || entry.lockedUntil <= now) return false;
|
||||||
|
const retryAfter = Math.max(1, Math.ceil((entry.lockedUntil - now) / 1000));
|
||||||
|
res.set("Retry-After", String(retryAfter));
|
||||||
|
res.status(429).json({
|
||||||
|
error: `Too many login attempts. Try again in ${formatWait(entry.lockedUntil - now)}.`,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordLoginFailure(req) {
|
||||||
|
const key = loginKey(req);
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = loginFailures.get(key);
|
||||||
|
if (!entry || (now - entry.windowStart > LOGIN_WINDOW_MS && entry.lockedUntil <= now)) {
|
||||||
|
entry = { count: 0, windowStart: now, lockedUntil: 0, lastAt: 0 };
|
||||||
|
loginFailures.set(key, entry);
|
||||||
|
}
|
||||||
|
if (entry.lockedUntil > now) return;
|
||||||
|
if (now - entry.lastAt < LOGIN_BURST_MS) return;
|
||||||
|
entry.lastAt = now;
|
||||||
|
entry.count += 1;
|
||||||
|
if (entry.count >= MAX_LOGIN_FAILURES) {
|
||||||
|
entry.lockedUntil = now + LOGIN_LOCK_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLoginFailures(req) {
|
||||||
|
loginFailures.delete(loginKey(req));
|
||||||
|
}
|
||||||
|
|
||||||
function clientIp(req) {
|
function clientIp(req) {
|
||||||
const forwarded = req.headers["x-forwarded-for"];
|
const forwarded = req.headers["x-forwarded-for"];
|
||||||
if (typeof forwarded === "string" && forwarded.trim()) {
|
if (typeof forwarded === "string" && forwarded.trim()) {
|
||||||
@ -23,9 +82,15 @@ function ipAllowlist(allowedIps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function basicAuth(user, password) {
|
function basicAuth(user, password, { allowAnonymous = false } = {}) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!user || !password) return next();
|
if (!user || !password) {
|
||||||
|
if (allowAnonymous) return next();
|
||||||
|
return res.status(503).json({ error: "Portal credentials are not configured" });
|
||||||
|
}
|
||||||
|
pruneLoginFailures(Date.now());
|
||||||
|
if (rejectIfLocked(req, res)) return;
|
||||||
|
|
||||||
const header = req.headers.authorization || "";
|
const header = req.headers.authorization || "";
|
||||||
if (!header.startsWith("Basic ")) return unauthorized(res);
|
if (!header.startsWith("Basic ")) return unauthorized(res);
|
||||||
|
|
||||||
@ -35,8 +100,11 @@ function basicAuth(user, password) {
|
|||||||
const givenPass = sep === -1 ? "" : decoded.slice(sep + 1);
|
const givenPass = sep === -1 ? "" : decoded.slice(sep + 1);
|
||||||
|
|
||||||
if (givenUser !== user || givenPass !== password) {
|
if (givenUser !== user || givenPass !== password) {
|
||||||
|
recordLoginFailure(req);
|
||||||
|
if (rejectIfLocked(req, res)) return;
|
||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
}
|
}
|
||||||
|
clearLoginFailures(req);
|
||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
require("dotenv").config({ path: path.join(__dirname, "..", ".env") });
|
|
||||||
|
const rootDir = path.join(__dirname, "..");
|
||||||
|
// The image sets NODE_ENV=production and Compose injects .env.
|
||||||
|
// A local process does not, so it loads .env.dev and never reads .env.
|
||||||
|
const envFile = process.env.NODE_ENV === "production" ? ".env" : ".env.dev";
|
||||||
|
require("dotenv").config({ path: path.join(rootDir, envFile) });
|
||||||
|
|
||||||
function optionalList(value) {
|
function optionalList(value) {
|
||||||
return String(value || "")
|
return String(value || "")
|
||||||
@ -8,11 +13,17 @@ function optionalList(value) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootDir = path.join(__dirname, "..");
|
|
||||||
const dbPath = path.isAbsolute(process.env.DB_PATH || "")
|
const dbPath = path.isAbsolute(process.env.DB_PATH || "")
|
||||||
? process.env.DB_PATH
|
? process.env.DB_PATH
|
||||||
: path.join(rootDir, process.env.DB_PATH || "data/emulator.db");
|
: path.join(rootDir, process.env.DB_PATH || "data/emulator.db");
|
||||||
|
|
||||||
|
const deploy = String(process.env.DEPLOY || "production").trim().toLowerCase();
|
||||||
|
|
||||||
|
function portalCredentialsMissing(user, password, mode = deploy) {
|
||||||
|
if (mode === "develop") return false;
|
||||||
|
return !String(user || "").trim() || !String(password || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
port: Number(process.env.PORT || 3080),
|
port: Number(process.env.PORT || 3080),
|
||||||
tbHttpUrl: String(process.env.TB_HTTP_URL || "https://tb-dvgo.datavolt.au").replace(/\/$/, ""),
|
tbHttpUrl: String(process.env.TB_HTTP_URL || "https://tb-dvgo.datavolt.au").replace(/\/$/, ""),
|
||||||
@ -24,9 +35,12 @@ module.exports = {
|
|||||||
tbMqttInsecure: String(process.env.TB_MQTT_INSECURE || "").toLowerCase() === "true",
|
tbMqttInsecure: String(process.env.TB_MQTT_INSECURE || "").toLowerCase() === "true",
|
||||||
tbMqttKeepalive: Number(process.env.TB_MQTT_KEEPALIVE || 30),
|
tbMqttKeepalive: Number(process.env.TB_MQTT_KEEPALIVE || 30),
|
||||||
tbMqttReconnectMs: Number(process.env.TB_MQTT_RECONNECT_MS || 3000),
|
tbMqttReconnectMs: Number(process.env.TB_MQTT_RECONNECT_MS || 3000),
|
||||||
|
envFile,
|
||||||
|
deploy,
|
||||||
portalUser: process.env.PORTAL_USER || "",
|
portalUser: process.env.PORTAL_USER || "",
|
||||||
portalPassword: process.env.PORTAL_PASSWORD || "",
|
portalPassword: process.env.PORTAL_PASSWORD || "",
|
||||||
|
portalCredentialsMissing,
|
||||||
allowedIps: optionalList(process.env.ALLOWED_IPS),
|
allowedIps: optionalList(process.env.ALLOWED_IPS),
|
||||||
telemetryIntervalMs: Number(process.env.TELEMETRY_INTERVAL_MS || 5000),
|
telemetryIntervalMs: Math.max(5000, Number(process.env.TELEMETRY_INTERVAL_MS || 5000) || 5000),
|
||||||
dbPath,
|
dbPath,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,8 +5,19 @@ const { defaultTelemetry, defaultPublishedKeys, normalizeTelemetry, telemetrySch
|
|||||||
const { defaultPlant } = require("./plant");
|
const { defaultPlant } = require("./plant");
|
||||||
const config = require("./config");
|
const config = require("./config");
|
||||||
|
|
||||||
|
const MAX_DEVICES = 100;
|
||||||
|
const MIN_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
const devices = new Map();
|
const devices = new Map();
|
||||||
|
|
||||||
|
function assertDeviceCapacity() {
|
||||||
|
if (devices.size >= MAX_DEVICES) {
|
||||||
|
const error = new Error(`Device limit reached (${devices.size}/${MAX_DEVICES}).`);
|
||||||
|
error.status = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function persistFlags(device) {
|
function persistFlags(device) {
|
||||||
db.saveFlags(device.id, {
|
db.saveFlags(device.id, {
|
||||||
telemetryEnabled: device.telemetryEnabled,
|
telemetryEnabled: device.telemetryEnabled,
|
||||||
@ -60,6 +71,8 @@ function registerLocalDevice(tbDevice) {
|
|||||||
if (existing) return existing.status();
|
if (existing) return existing.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertDeviceCapacity();
|
||||||
|
|
||||||
const device = new VirtualDevice({
|
const device = new VirtualDevice({
|
||||||
id: tbDevice.id,
|
id: tbDevice.id,
|
||||||
name: tbDevice.name,
|
name: tbDevice.name,
|
||||||
@ -108,6 +121,7 @@ async function create({ name, label, type, deviceProfileId, accessToken, id }) {
|
|||||||
error.status = 409;
|
error.status = 409;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
assertDeviceCapacity();
|
||||||
return registerLocalDevice(found);
|
return registerLocalDevice(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,6 +139,8 @@ async function create({ name, label, type, deviceProfileId, accessToken, id }) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertDeviceCapacity();
|
||||||
|
|
||||||
const tbDevice = await thingsboard.createDevice({
|
const tbDevice = await thingsboard.createDevice({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
label,
|
label,
|
||||||
@ -149,6 +165,8 @@ async function adopt({ name, label, type }) {
|
|||||||
return live ? live.status() : already;
|
return live ? live.status() : already;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertDeviceCapacity();
|
||||||
|
|
||||||
const tbDevice = await thingsboard.adoptDeviceByName(trimmed);
|
const tbDevice = await thingsboard.adoptDeviceByName(trimmed);
|
||||||
if (label) tbDevice.label = label;
|
if (label) tbDevice.label = label;
|
||||||
if (type) tbDevice.type = type;
|
if (type) tbDevice.type = type;
|
||||||
@ -232,8 +250,14 @@ function enable(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setIntervalMs(id, intervalMs) {
|
function setIntervalMs(id, intervalMs) {
|
||||||
|
const next = Number(intervalMs);
|
||||||
|
if (!Number.isFinite(next) || next < MIN_INTERVAL_MS) {
|
||||||
|
const error = new Error("Can't go lower than 5000.");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const device = getOrThrow(id);
|
const device = getOrThrow(id);
|
||||||
device.setIntervalMs(intervalMs);
|
device.setIntervalMs(next);
|
||||||
persistFlags(device);
|
persistFlags(device);
|
||||||
return device.status();
|
return device.status();
|
||||||
}
|
}
|
||||||
@ -272,6 +296,8 @@ function restore() {
|
|||||||
});
|
});
|
||||||
device.createdAt = row.createdAt;
|
device.createdAt = row.createdAt;
|
||||||
bindDevice(device);
|
bindDevice(device);
|
||||||
|
const savedKeys = new Set(Array.isArray(row.publishedKeys) ? row.publishedKeys : []);
|
||||||
|
if (!savedKeys.has("auxPower_s")) persistPublishedKeys(device);
|
||||||
if (!device.disabled) device.connect();
|
if (!device.disabled) device.connect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -283,6 +309,8 @@ function shutdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
MAX_DEVICES,
|
||||||
|
MIN_INTERVAL_MS,
|
||||||
list,
|
list,
|
||||||
create,
|
create,
|
||||||
adopt,
|
adopt,
|
||||||
|
|||||||
16
src/index.js
16
src/index.js
@ -30,11 +30,21 @@ function sendIndex(_req, res) {
|
|||||||
res.type("html").send(html);
|
res.type("html").send(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.portalCredentialsMissing(config.portalUser, config.portalPassword)) {
|
||||||
|
console.error("PORTAL_USER and PORTAL_PASSWORD are required unless DEPLOY=develop");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
app.use("/api", ipAllowlist(config.allowedIps), basicAuth(config.portalUser, config.portalPassword), routes);
|
app.use(
|
||||||
|
"/api",
|
||||||
|
ipAllowlist(config.allowedIps),
|
||||||
|
basicAuth(config.portalUser, config.portalPassword, { allowAnonymous: config.deploy === "develop" }),
|
||||||
|
routes
|
||||||
|
);
|
||||||
app.use(
|
app.use(
|
||||||
express.static(publicDir, {
|
express.static(publicDir, {
|
||||||
etag: false,
|
etag: false,
|
||||||
@ -52,11 +62,15 @@ deviceManager.restore();
|
|||||||
|
|
||||||
const server = app.listen(config.port, () => {
|
const server = app.listen(config.port, () => {
|
||||||
console.log(`Diverter device emulator listening on http://localhost:${config.port}`);
|
console.log(`Diverter device emulator listening on http://localhost:${config.port}`);
|
||||||
|
console.log(`Env ${config.envFile} (DEPLOY=${config.deploy})`);
|
||||||
console.log(`ThingsBoard REST ${config.tbHttpUrl}`);
|
console.log(`ThingsBoard REST ${config.tbHttpUrl}`);
|
||||||
console.log(`ThingsBoard MQTT ${config.tbMqttUrl}`);
|
console.log(`ThingsBoard MQTT ${config.tbMqttUrl}`);
|
||||||
if (!config.tbUsername || !config.tbPassword) {
|
if (!config.tbUsername || !config.tbPassword) {
|
||||||
console.warn("TB_USERNAME / TB_PASSWORD are not set — creating devices will fail until they are.");
|
console.warn("TB_USERNAME / TB_PASSWORD are not set — creating devices will fail until they are.");
|
||||||
}
|
}
|
||||||
|
if (config.deploy === "develop" && (!config.portalUser || !config.portalPassword)) {
|
||||||
|
console.warn("DEPLOY=develop with blank portal credentials — API auth is disabled");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function shutdown(signal) {
|
function shutdown(signal) {
|
||||||
|
|||||||
36
src/plant.js
36
src/plant.js
@ -74,6 +74,16 @@ function scheduleModeOn(mode) {
|
|||||||
return value === 2 || value === 3;
|
return value === 2 || value === 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleSlot(startRaw, stopRaw, nowMinutes, modeOn) {
|
||||||
|
const hasStart = startRaw != null && startRaw !== "";
|
||||||
|
const hasStop = stopRaw != null && stopRaw !== "";
|
||||||
|
return {
|
||||||
|
start: hasStart ? num(startRaw, 0) : null,
|
||||||
|
stop: hasStop ? num(stopRaw, 0) : null,
|
||||||
|
active: Boolean(modeOn) && inScheduleWindow(startRaw, stopRaw, nowMinutes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function hwScheduleActive(attrs, nowMinutes) {
|
function hwScheduleActive(attrs, nowMinutes) {
|
||||||
if (!scheduleModeOn(attrs.hwRlyMode)) return false;
|
if (!scheduleModeOn(attrs.hwRlyMode)) return false;
|
||||||
return (
|
return (
|
||||||
@ -90,11 +100,16 @@ function auxScheduleActive(attrs, nowMinutes) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyOneShot(plant, key, untilKey, lastKey, minutes) {
|
function applyOneShot(plant, untilKey, lastKey, manualKey, minutes, command = false) {
|
||||||
const mins = Math.max(0, num(minutes, 0));
|
if (minutes == null || minutes === "") return plant;
|
||||||
if (plant[lastKey] === mins) return plant;
|
const mins = Math.max(0, Math.trunc(num(minutes, 0)));
|
||||||
|
// Attribute polls repeat the same value. A pushed update is an app command,
|
||||||
|
// including Stop while a local emulator button still holds the relay on.
|
||||||
|
if (!command && plant[lastKey] === mins) return plant;
|
||||||
|
const initialSync = !command && plant[lastKey] == null && mins === 0;
|
||||||
return {
|
return {
|
||||||
...plant,
|
...plant,
|
||||||
|
[manualKey]: initialSync ? plant[manualKey] : null,
|
||||||
[lastKey]: mins,
|
[lastKey]: mins,
|
||||||
[untilKey]: mins > 0 ? Date.now() + mins * 60_000 : null,
|
[untilKey]: mins > 0 ? Date.now() + mins * 60_000 : null,
|
||||||
};
|
};
|
||||||
@ -213,6 +228,7 @@ function resolvePowerSim({ plant, hwOn, auxOn }) {
|
|||||||
const telemetry = {
|
const telemetry = {
|
||||||
solarPower_s: solar,
|
solarPower_s: solar,
|
||||||
loadPower_s: home + aux,
|
loadPower_s: home + aux,
|
||||||
|
auxPower_s: aux,
|
||||||
hotWaterInPower_s: hw,
|
hotWaterInPower_s: hw,
|
||||||
batteryPower_s: batteryPower,
|
batteryPower_s: batteryPower,
|
||||||
batteryPercent_s: batteryPercent,
|
batteryPercent_s: batteryPercent,
|
||||||
@ -295,8 +311,18 @@ function describeChannel({ on, oneShot, schedule, cutout, remaining }) {
|
|||||||
function evaluatePlant({ plant, attrs, tankC, now = Date.now(), nowMinutes = minutesSinceMidnight() }) {
|
function evaluatePlant({ plant, attrs, tankC, now = Date.now(), nowMinutes = minutesSinceMidnight() }) {
|
||||||
const hwOneShot = remainingMs(plant.hwBoostUntil, now) > 0;
|
const hwOneShot = remainingMs(plant.hwBoostUntil, now) > 0;
|
||||||
const auxOneShot = remainingMs(plant.auxBoostUntil, now) > 0;
|
const auxOneShot = remainingMs(plant.auxBoostUntil, now) > 0;
|
||||||
|
const hwModeOn = scheduleModeOn(attrs.hwRlyMode);
|
||||||
|
const auxModeOn = scheduleModeOn(attrs.auxRlyMode);
|
||||||
const hwSchedule = hwScheduleActive(attrs, nowMinutes);
|
const hwSchedule = hwScheduleActive(attrs, nowMinutes);
|
||||||
const auxSchedule = auxScheduleActive(attrs, nowMinutes);
|
const auxSchedule = auxScheduleActive(attrs, nowMinutes);
|
||||||
|
const hwSchedules = [
|
||||||
|
scheduleSlot(attrs.hwRlySchedMode0StartTime, attrs.hwRlySchedMode0StopTime, nowMinutes, hwModeOn),
|
||||||
|
scheduleSlot(attrs.hwRlySchedMode1StartTime, attrs.hwRlySchedMode1StopTime, nowMinutes, hwModeOn),
|
||||||
|
];
|
||||||
|
const auxSchedules = [
|
||||||
|
scheduleSlot(attrs.auxRlySchedMode0Start, attrs.auxRlySchedMode0Stop, nowMinutes, auxModeOn),
|
||||||
|
scheduleSlot(attrs.auxRlySchedMode1Start, attrs.auxRlySchedMode1Stop, nowMinutes, auxModeOn),
|
||||||
|
];
|
||||||
const cutout = tankC >= plant.thermostatC;
|
const cutout = tankC >= plant.thermostatC;
|
||||||
let hwWanted = hwOneShot || hwSchedule;
|
let hwWanted = hwOneShot || hwSchedule;
|
||||||
if (plant.hwManual === true) hwWanted = true;
|
if (plant.hwManual === true) hwWanted = true;
|
||||||
@ -313,6 +339,10 @@ function evaluatePlant({ plant, attrs, tankC, now = Date.now(), nowMinutes = min
|
|||||||
auxOneShot,
|
auxOneShot,
|
||||||
hwSchedule,
|
hwSchedule,
|
||||||
auxSchedule,
|
auxSchedule,
|
||||||
|
hwScheduleMode: hwModeOn,
|
||||||
|
auxScheduleMode: auxModeOn,
|
||||||
|
hwSchedules,
|
||||||
|
auxSchedules,
|
||||||
cutout,
|
cutout,
|
||||||
hwWanted,
|
hwWanted,
|
||||||
hwRemainingMs: remainingMs(plant.hwBoostUntil, now),
|
hwRemainingMs: remainingMs(plant.hwBoostUntil, now),
|
||||||
|
|||||||
@ -25,6 +25,14 @@ function idsFromBody(body) {
|
|||||||
return ids.map(String);
|
return ids.map(String);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.get("/clock", (_req, res) => {
|
||||||
|
const now = new Date();
|
||||||
|
res.json({
|
||||||
|
epochMs: now.getTime(),
|
||||||
|
timezoneOffsetMinutes: now.getTimezoneOffset(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/health", asyncHandler(async (_req, res) => {
|
router.get("/health", asyncHandler(async (_req, res) => {
|
||||||
const tb = await thingsboard.ping();
|
const tb = await thingsboard.ping();
|
||||||
res.json({
|
res.json({
|
||||||
@ -48,7 +56,12 @@ router.get("/device-profiles", asyncHandler(async (_req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
router.get("/devices", (_req, res) => {
|
router.get("/devices", (_req, res) => {
|
||||||
res.json({ devices: deviceManager.list() });
|
const devices = deviceManager.list();
|
||||||
|
res.json({
|
||||||
|
devices,
|
||||||
|
count: devices.length,
|
||||||
|
limit: deviceManager.MAX_DEVICES,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/devices", asyncHandler(async (req, res) => {
|
router.post("/devices", asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const TELEMETRY_FIELDS = [
|
const TELEMETRY_FIELDS = [
|
||||||
{ key: "solarPower_s", label: "Solar generation", unit: "W", group: "power", type: "number", default: 3200 },
|
{ key: "solarPower_s", label: "Solar generation", unit: "W", group: "power", type: "number", default: 3200 },
|
||||||
{ key: "loadPower_s", label: "Home load", unit: "W", group: "power", type: "number", default: 1500 },
|
{ key: "loadPower_s", label: "Home load", unit: "W", group: "power", type: "number", default: 1500 },
|
||||||
|
{ key: "auxPower_s", label: "Aux load", unit: "W", group: "power", type: "number", default: 0 },
|
||||||
{ key: "hotWaterInPower_s", label: "Hot water in", unit: "W", group: "power", type: "number", default: 800 },
|
{ key: "hotWaterInPower_s", label: "Hot water in", unit: "W", group: "power", type: "number", default: 800 },
|
||||||
{ key: "hotWaterOutPower_s", label: "Hot water out", unit: "W", group: "power", type: "number", default: 0 },
|
{ key: "hotWaterOutPower_s", label: "Hot water out", unit: "W", group: "power", type: "number", default: 0 },
|
||||||
{ key: "batteryPower_s", label: "Battery power", unit: "W", group: "power", type: "number", default: -700, hint: "Negative = charging, positive = discharging" },
|
{ key: "batteryPower_s", label: "Battery power", unit: "W", group: "power", type: "number", default: -700, hint: "Negative = charging, positive = discharging" },
|
||||||
@ -77,7 +78,9 @@ function normalizePublishedKeys(input) {
|
|||||||
if (input == null) return defaultPublishedKeys();
|
if (input == null) return defaultPublishedKeys();
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
const enabled = new Set(input.map(String));
|
const enabled = new Set(input.map(String));
|
||||||
return known.filter((key) => enabled.has(key));
|
const selected = known.filter((key) => enabled.has(key));
|
||||||
|
if (!enabled.has("auxPower_s") && known.includes("auxPower_s")) selected.push("auxPower_s");
|
||||||
|
return selected;
|
||||||
}
|
}
|
||||||
if (typeof input === "object") {
|
if (typeof input === "object") {
|
||||||
return known.filter((key) => Boolean(input[key]));
|
return known.filter((key) => Boolean(input[key]));
|
||||||
|
|||||||
@ -158,7 +158,7 @@ class VirtualDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIntervalMs(intervalMs) {
|
setIntervalMs(intervalMs) {
|
||||||
const next = Math.max(500, Number(intervalMs) || config.telemetryIntervalMs);
|
const next = Math.max(5000, Number(intervalMs) || config.telemetryIntervalMs);
|
||||||
const wasRunning = this.telemetryEnabled;
|
const wasRunning = this.telemetryEnabled;
|
||||||
this.intervalMs = next;
|
this.intervalMs = next;
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
@ -218,16 +218,53 @@ class VirtualDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeSharedAttributes(incoming) {
|
mergeSharedAttributes(incoming, { command = false } = {}) {
|
||||||
const shared = incoming?.shared && typeof incoming.shared === "object" ? incoming.shared : incoming;
|
const shared = incoming?.shared && typeof incoming.shared === "object" ? incoming.shared : incoming;
|
||||||
if (!shared || typeof shared !== "object") return;
|
if (!shared || typeof shared !== "object" || Array.isArray(shared)) return;
|
||||||
const next = { ...this.sharedAttributes };
|
const next = { ...this.sharedAttributes };
|
||||||
for (const [key, value] of Object.entries(shared)) {
|
for (const [key, value] of Object.entries(shared)) {
|
||||||
next[key] = unwrapAttr(value);
|
next[key] = unwrapAttr(value);
|
||||||
}
|
}
|
||||||
|
const scheduleChanged = (keys) => keys.some((key) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(shared, key)
|
||||||
|
&& this.sharedAttributes[key] !== unwrapAttr(shared[key])
|
||||||
|
));
|
||||||
|
if (scheduleChanged([
|
||||||
|
"hwRlyMode",
|
||||||
|
"hwRlySchedMode0StartTime",
|
||||||
|
"hwRlySchedMode0StopTime",
|
||||||
|
"hwRlySchedMode1StartTime",
|
||||||
|
"hwRlySchedMode1StopTime",
|
||||||
|
])) {
|
||||||
|
this.plant = { ...this.plant, hwManual: null };
|
||||||
|
}
|
||||||
|
if (scheduleChanged([
|
||||||
|
"auxRlyMode",
|
||||||
|
"auxRlySchedMode0Start",
|
||||||
|
"auxRlySchedMode0Stop",
|
||||||
|
"auxRlySchedMode1Start",
|
||||||
|
"auxRlySchedMode1Stop",
|
||||||
|
])) {
|
||||||
|
this.plant = { ...this.plant, auxManual: null };
|
||||||
|
}
|
||||||
this.sharedAttributes = next;
|
this.sharedAttributes = next;
|
||||||
this.plant = applyOneShot(this.plant, "boostNowHw", "hwBoostUntil", "lastBoostNowHw", next.boostNowHw);
|
const commanded = (key) => command && Object.prototype.hasOwnProperty.call(shared, key);
|
||||||
this.plant = applyOneShot(this.plant, "boostNowAux", "auxBoostUntil", "lastBoostNowAux", next.boostNowAux);
|
this.plant = applyOneShot(
|
||||||
|
this.plant,
|
||||||
|
"hwBoostUntil",
|
||||||
|
"lastBoostNowHw",
|
||||||
|
"hwManual",
|
||||||
|
next.boostNowHw,
|
||||||
|
commanded("boostNowHw"),
|
||||||
|
);
|
||||||
|
this.plant = applyOneShot(
|
||||||
|
this.plant,
|
||||||
|
"auxBoostUntil",
|
||||||
|
"lastBoostNowAux",
|
||||||
|
"auxManual",
|
||||||
|
next.boostNowAux,
|
||||||
|
commanded("boostNowAux"),
|
||||||
|
);
|
||||||
this.tickPlant();
|
this.tickPlant();
|
||||||
if (this.onPlantChange) this.onPlantChange(this);
|
if (this.onPlantChange) this.onPlantChange(this);
|
||||||
}
|
}
|
||||||
@ -355,6 +392,10 @@ class VirtualDevice {
|
|||||||
payload[key] = this.telemetry[key];
|
payload[key] = this.telemetry[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Aux watts are plant state. Always send them, even if an older saved key list omitted auxPower_s.
|
||||||
|
if (Object.prototype.hasOwnProperty.call(this.telemetry, "auxPower_s")) {
|
||||||
|
payload.auxPower_s = this.telemetry.auxPower_s;
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -386,7 +427,12 @@ class VirtualDevice {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topic === "v1/devices/me/attributes" || topic.startsWith("v1/devices/me/attributes/response/")) {
|
if (topic === "v1/devices/me/attributes") {
|
||||||
|
this.mergeSharedAttributes(parsed, { command: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic.startsWith("v1/devices/me/attributes/response/")) {
|
||||||
this.mergeSharedAttributes(parsed);
|
this.mergeSharedAttributes(parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user