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-*
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
deploy/
|
||||
BIN
dvgo-emu-readme.docx
Normal file
BIN
dvgo-emu-readme.docx
Normal file
Binary file not shown.
128
public/app.js
128
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 = `<p class="muted">No emulated devices yet.</p>`;
|
||||
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 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: Number(els.interval.value) }),
|
||||
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();
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
<h1>DVgo device emulator</h1>
|
||||
</div>
|
||||
<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="mqtt-status" class="status-pill off">MQTT</div>
|
||||
</div>
|
||||
@ -44,7 +45,10 @@
|
||||
<main class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<h2>Devices</h2>
|
||||
<span id="device-count" class="device-count">0/100</span>
|
||||
</div>
|
||||
<div class="device-menu">
|
||||
<button
|
||||
id="device-menu-toggle"
|
||||
@ -159,6 +163,17 @@
|
||||
<span id="hw-lamp" class="lamp" aria-hidden="true"></span>
|
||||
<output class="plant-status" data-plant-status="hotWater"></output>
|
||||
</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>
|
||||
<div class="plant-setting">
|
||||
<div class="plant-current">
|
||||
@ -211,6 +226,17 @@
|
||||
<span id="aux-lamp" class="lamp" aria-hidden="true"></span>
|
||||
<output class="plant-status" data-plant-status="aux"></output>
|
||||
</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>
|
||||
<div class="plant-setting">
|
||||
<div class="plant-current">
|
||||
@ -272,7 +298,7 @@
|
||||
<div class="meta-row publish-fields">
|
||||
<label>
|
||||
Publish interval (ms)
|
||||
<input id="interval-input" type="number" min="500" step="500" />
|
||||
<input id="interval-input" type="number" min="5000" step="500" />
|
||||
</label>
|
||||
<p id="last-published" class="muted"></p>
|
||||
</div>
|
||||
@ -281,6 +307,7 @@
|
||||
<button id="publish-select-all" type="button">Select all</button>
|
||||
<button id="publish-deselect-all" type="button">Deselect all</button>
|
||||
</div>
|
||||
<p class="muted publish-note">Publish now sends a single telemetry payload. It does not start the repeating loop.</p>
|
||||
</section>
|
||||
|
||||
<div id="telemetry-groups"></div>
|
||||
@ -301,6 +328,7 @@
|
||||
</div>
|
||||
<p class="eyebrow">New virtual device</p>
|
||||
<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>
|
||||
<label class="add-existing-toggle">
|
||||
<input id="add-existing" class="device-check" type="checkbox" />
|
||||
|
||||
@ -164,6 +164,16 @@ button.stop-telemetry:active:not(:disabled) {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -228,6 +238,23 @@ button.stop-telemetry:active:not(:disabled) {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@ -423,6 +450,11 @@ button.stop-telemetry:active:not(:disabled) {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.publish-note {
|
||||
margin: 14px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@ -486,6 +518,70 @@ select {
|
||||
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 {
|
||||
display: block;
|
||||
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) {
|
||||
res.set("WWW-Authenticate", 'Basic realm="Diverter emulator"');
|
||||
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) {
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
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) => {
|
||||
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 || "";
|
||||
if (!header.startsWith("Basic ")) return unauthorized(res);
|
||||
|
||||
@ -35,8 +100,11 @@ function basicAuth(user, password) {
|
||||
const givenPass = sep === -1 ? "" : decoded.slice(sep + 1);
|
||||
|
||||
if (givenUser !== user || givenPass !== password) {
|
||||
recordLoginFailure(req);
|
||||
if (rejectIfLocked(req, res)) return;
|
||||
return unauthorized(res);
|
||||
}
|
||||
clearLoginFailures(req);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
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) {
|
||||
return String(value || "")
|
||||
@ -8,11 +13,17 @@ function optionalList(value) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const rootDir = path.join(__dirname, "..");
|
||||
const dbPath = path.isAbsolute(process.env.DB_PATH || "")
|
||||
? process.env.DB_PATH
|
||||
: 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 = {
|
||||
port: Number(process.env.PORT || 3080),
|
||||
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",
|
||||
tbMqttKeepalive: Number(process.env.TB_MQTT_KEEPALIVE || 30),
|
||||
tbMqttReconnectMs: Number(process.env.TB_MQTT_RECONNECT_MS || 3000),
|
||||
envFile,
|
||||
deploy,
|
||||
portalUser: process.env.PORTAL_USER || "",
|
||||
portalPassword: process.env.PORTAL_PASSWORD || "",
|
||||
portalCredentialsMissing,
|
||||
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,
|
||||
};
|
||||
|
||||
@ -5,8 +5,19 @@ const { defaultTelemetry, defaultPublishedKeys, normalizeTelemetry, telemetrySch
|
||||
const { defaultPlant } = require("./plant");
|
||||
const config = require("./config");
|
||||
|
||||
const MAX_DEVICES = 100;
|
||||
const MIN_INTERVAL_MS = 5000;
|
||||
|
||||
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) {
|
||||
db.saveFlags(device.id, {
|
||||
telemetryEnabled: device.telemetryEnabled,
|
||||
@ -60,6 +71,8 @@ function registerLocalDevice(tbDevice) {
|
||||
if (existing) return existing.status();
|
||||
}
|
||||
|
||||
assertDeviceCapacity();
|
||||
|
||||
const device = new VirtualDevice({
|
||||
id: tbDevice.id,
|
||||
name: tbDevice.name,
|
||||
@ -108,6 +121,7 @@ async function create({ name, label, type, deviceProfileId, accessToken, id }) {
|
||||
error.status = 409;
|
||||
throw error;
|
||||
}
|
||||
assertDeviceCapacity();
|
||||
return registerLocalDevice(found);
|
||||
}
|
||||
|
||||
@ -125,6 +139,8 @@ async function create({ name, label, type, deviceProfileId, accessToken, id }) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
assertDeviceCapacity();
|
||||
|
||||
const tbDevice = await thingsboard.createDevice({
|
||||
name: trimmed,
|
||||
label,
|
||||
@ -149,6 +165,8 @@ async function adopt({ name, label, type }) {
|
||||
return live ? live.status() : already;
|
||||
}
|
||||
|
||||
assertDeviceCapacity();
|
||||
|
||||
const tbDevice = await thingsboard.adoptDeviceByName(trimmed);
|
||||
if (label) tbDevice.label = label;
|
||||
if (type) tbDevice.type = type;
|
||||
@ -232,8 +250,14 @@ function enable(id) {
|
||||
}
|
||||
|
||||
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);
|
||||
device.setIntervalMs(intervalMs);
|
||||
device.setIntervalMs(next);
|
||||
persistFlags(device);
|
||||
return device.status();
|
||||
}
|
||||
@ -272,6 +296,8 @@ function restore() {
|
||||
});
|
||||
device.createdAt = row.createdAt;
|
||||
bindDevice(device);
|
||||
const savedKeys = new Set(Array.isArray(row.publishedKeys) ? row.publishedKeys : []);
|
||||
if (!savedKeys.has("auxPower_s")) persistPublishedKeys(device);
|
||||
if (!device.disabled) device.connect();
|
||||
}
|
||||
}
|
||||
@ -283,6 +309,8 @@ function shutdown() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_DEVICES,
|
||||
MIN_INTERVAL_MS,
|
||||
list,
|
||||
create,
|
||||
adopt,
|
||||
|
||||
16
src/index.js
16
src/index.js
@ -30,11 +30,21 @@ function sendIndex(_req, res) {
|
||||
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();
|
||||
app.disable("x-powered-by");
|
||||
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(
|
||||
express.static(publicDir, {
|
||||
etag: false,
|
||||
@ -52,11 +62,15 @@ deviceManager.restore();
|
||||
|
||||
const server = app.listen(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 MQTT ${config.tbMqttUrl}`);
|
||||
if (!config.tbUsername || !config.tbPassword) {
|
||||
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) {
|
||||
|
||||
36
src/plant.js
36
src/plant.js
@ -74,6 +74,16 @@ function scheduleModeOn(mode) {
|
||||
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) {
|
||||
if (!scheduleModeOn(attrs.hwRlyMode)) return false;
|
||||
return (
|
||||
@ -90,11 +100,16 @@ function auxScheduleActive(attrs, nowMinutes) {
|
||||
);
|
||||
}
|
||||
|
||||
function applyOneShot(plant, key, untilKey, lastKey, minutes) {
|
||||
const mins = Math.max(0, num(minutes, 0));
|
||||
if (plant[lastKey] === mins) return plant;
|
||||
function applyOneShot(plant, untilKey, lastKey, manualKey, minutes, command = false) {
|
||||
if (minutes == null || minutes === "") 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 {
|
||||
...plant,
|
||||
[manualKey]: initialSync ? plant[manualKey] : null,
|
||||
[lastKey]: mins,
|
||||
[untilKey]: mins > 0 ? Date.now() + mins * 60_000 : null,
|
||||
};
|
||||
@ -213,6 +228,7 @@ function resolvePowerSim({ plant, hwOn, auxOn }) {
|
||||
const telemetry = {
|
||||
solarPower_s: solar,
|
||||
loadPower_s: home + aux,
|
||||
auxPower_s: aux,
|
||||
hotWaterInPower_s: hw,
|
||||
batteryPower_s: batteryPower,
|
||||
batteryPercent_s: batteryPercent,
|
||||
@ -295,8 +311,18 @@ function describeChannel({ on, oneShot, schedule, cutout, remaining }) {
|
||||
function evaluatePlant({ plant, attrs, tankC, now = Date.now(), nowMinutes = minutesSinceMidnight() }) {
|
||||
const hwOneShot = remainingMs(plant.hwBoostUntil, 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 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;
|
||||
let hwWanted = hwOneShot || hwSchedule;
|
||||
if (plant.hwManual === true) hwWanted = true;
|
||||
@ -313,6 +339,10 @@ function evaluatePlant({ plant, attrs, tankC, now = Date.now(), nowMinutes = min
|
||||
auxOneShot,
|
||||
hwSchedule,
|
||||
auxSchedule,
|
||||
hwScheduleMode: hwModeOn,
|
||||
auxScheduleMode: auxModeOn,
|
||||
hwSchedules,
|
||||
auxSchedules,
|
||||
cutout,
|
||||
hwWanted,
|
||||
hwRemainingMs: remainingMs(plant.hwBoostUntil, now),
|
||||
|
||||
@ -25,6 +25,14 @@ function idsFromBody(body) {
|
||||
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) => {
|
||||
const tb = await thingsboard.ping();
|
||||
res.json({
|
||||
@ -48,7 +56,12 @@ router.get("/device-profiles", asyncHandler(async (_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) => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const TELEMETRY_FIELDS = [
|
||||
{ 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: "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: "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" },
|
||||
@ -77,7 +78,9 @@ function normalizePublishedKeys(input) {
|
||||
if (input == null) return defaultPublishedKeys();
|
||||
if (Array.isArray(input)) {
|
||||
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") {
|
||||
return known.filter((key) => Boolean(input[key]));
|
||||
|
||||
@ -158,7 +158,7 @@ class VirtualDevice {
|
||||
}
|
||||
|
||||
setIntervalMs(intervalMs) {
|
||||
const next = Math.max(500, Number(intervalMs) || config.telemetryIntervalMs);
|
||||
const next = Math.max(5000, Number(intervalMs) || config.telemetryIntervalMs);
|
||||
const wasRunning = this.telemetryEnabled;
|
||||
this.intervalMs = next;
|
||||
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;
|
||||
if (!shared || typeof shared !== "object") return;
|
||||
if (!shared || typeof shared !== "object" || Array.isArray(shared)) return;
|
||||
const next = { ...this.sharedAttributes };
|
||||
for (const [key, value] of Object.entries(shared)) {
|
||||
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.plant = applyOneShot(this.plant, "boostNowHw", "hwBoostUntil", "lastBoostNowHw", next.boostNowHw);
|
||||
this.plant = applyOneShot(this.plant, "boostNowAux", "auxBoostUntil", "lastBoostNowAux", next.boostNowAux);
|
||||
const commanded = (key) => command && Object.prototype.hasOwnProperty.call(shared, key);
|
||||
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();
|
||||
if (this.onPlantChange) this.onPlantChange(this);
|
||||
}
|
||||
@ -355,6 +392,10 @@ class VirtualDevice {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -386,7 +427,12 @@ class VirtualDevice {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user