and another commit

This commit is contained in:
Kris MacAir 2026-10-19 17:02:27 +01:00
parent 95998a0d75
commit 7b330cccec
14 changed files with 494 additions and 25 deletions

2
.cursorignore Normal file
View File

@ -0,0 +1,2 @@
.env
.env.*

1
.gitignore vendored
View File

@ -2,5 +2,6 @@ node_modules/
data/*.db
data/*.db-*
.env
.env.*
.DS_Store
deploy/

BIN
dvgo-emu-readme.docx Normal file

Binary file not shown.

View File

@ -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 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();

View File

@ -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">
<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">
<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" />

View File

@ -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;

View File

@ -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();
};
}

View File

@ -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,
};

View File

@ -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,

View File

@ -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) {

View File

@ -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),

View File

@ -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) => {

View File

@ -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]));

View File

@ -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);
}
}