init commit
This commit is contained in:
commit
b83211eb80
29
.env.example
Normal file
29
.env.example
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
PORT=3080
|
||||||
|
|
||||||
|
# ThingsBoard REST (tenant admin or user who can create devices)
|
||||||
|
TB_HTTP_URL=https://tb-dvgo.datavolt.au
|
||||||
|
TB_USERNAME=
|
||||||
|
TB_PASSWORD=
|
||||||
|
|
||||||
|
# Pin created devices to the Diverter / DVgo profile
|
||||||
|
TB_DEVICE_PROFILE_ID=e6b53240-7fec-11f1-a69d-edbc0b9170ac
|
||||||
|
TB_DEVICE_TYPE=DVgo
|
||||||
|
|
||||||
|
# MQTT used by each virtual device (access token is the username)
|
||||||
|
TB_MQTT_URL=mqtts://tb-dvgo.datavolt.au:8883
|
||||||
|
# Set to true if using mqtts with a private CA
|
||||||
|
# TB_MQTT_INSECURE=false
|
||||||
|
# Seconds between MQTT pings (default 30). 0 disables keepalive.
|
||||||
|
# TB_MQTT_KEEPALIVE=30
|
||||||
|
# Milliseconds between reconnect attempts while a device is connected (default 3000)
|
||||||
|
# TB_MQTT_RECONNECT_MS=3000
|
||||||
|
|
||||||
|
# Portal protection (leave blank to disable auth locally)
|
||||||
|
PORTAL_USER=
|
||||||
|
PORTAL_PASSWORD=
|
||||||
|
|
||||||
|
# Optional comma-separated IPs for the control API
|
||||||
|
# ALLOWED_IPS=127.0.0.1,::1
|
||||||
|
|
||||||
|
TELEMETRY_INTERVAL_MS=5000
|
||||||
|
DB_PATH=./data/emulator.db
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
130
README.md
Normal file
130
README.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
# DVgo device emulator
|
||||||
|
|
||||||
|
Node.js ThingsBoard device emulator with a SQLite-backed web portal. Each virtual device can connect over MQTT, publish live `*_s` telemetry, react to shared-attribute commands from the apps, and be taken offline without deleting the ThingsBoard record.
|
||||||
|
|
||||||
|
Requires **Node 22+** (uses the built-in `node:sqlite` module).
|
||||||
|
|
||||||
|
Python historical data generation is **not** included.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Create / delete real ThingsBoard devices via the REST API (delete still removes the emulator row if the TB device is already gone)
|
||||||
|
- Persist credentials, telemetry, published-key selection, and plant settings in SQLite
|
||||||
|
- MQTT connect with the device access token as username (`v1/devices/me/telemetry`, plus attributes and RPC)
|
||||||
|
- Web portal (`http://localhost:3080`) to:
|
||||||
|
- add devices (or attach an existing MQTT access token)
|
||||||
|
- select devices (select all / deselect all) and enable, disable, or delete
|
||||||
|
- Connect / Disconnect MQTT (new devices stay idle until you connect)
|
||||||
|
- start / stop the telemetry loop (Start telemetry is disabled until connected)
|
||||||
|
- toggle which keys are included in each publish (per-card **On**, plus select all / deselect all under Publish now)
|
||||||
|
- edit every live `_s` key from the device dashboard
|
||||||
|
- run a **virtual plant** that follows boost and schedule commands from the apps
|
||||||
|
|
||||||
|
On startup, devices already in SQLite are restored. They reconnect only if they were left enabled (not disabled). New devices are created disconnected, with telemetry off.
|
||||||
|
|
||||||
|
## Virtual plant
|
||||||
|
|
||||||
|
While MQTT is connected, the device polls ThingsBoard shared attributes every 5 seconds (and also receives live pushes). It understands:
|
||||||
|
|
||||||
|
| Source | Keys |
|
||||||
|
| --- | --- |
|
||||||
|
| One-shot boost | `boostNowHw`, `boostNowAux` (minutes; written by the apps via TB REST) |
|
||||||
|
| HW schedule | `hwRlyMode` 2 or 3, plus `hwRlySchedMode0/1StartTime` / `StopTime` (minutes since midnight) |
|
||||||
|
| Aux schedule | `auxRlyMode` 2 or 3, plus `auxRlySchedMode0/1Start` / `Stop` |
|
||||||
|
|
||||||
|
Schedule saves in the apps are SQL-only, so this emulator polls rather than waiting for a TB push.
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- Red / grey lamps for **hot water boost** and **aux boost / divert**
|
||||||
|
- While HW is on, `heatsink_s` rises at **Heat rate (°C / min)** (integer, default 1)
|
||||||
|
- At **Thermostat max (°C)** (default 65) heating stops, `rlyInSel_s` goes to 0, and `boostNowHw` is written back to ThingsBoard as 0
|
||||||
|
- Live state is published as `heatsink_s`, `rlyInSel_s`, `auxOn_s`, `auxRelay_s`
|
||||||
|
|
||||||
|
## Telemetry keys
|
||||||
|
|
||||||
|
Each device can publish the following live `*_s` keys on `v1/devices/me/telemetry`. Dashboard cards are ordered Status, Power, Energy flows, Energy today, Validity. Only keys with **On** checked are sent.
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
- `heatsink_s` — hot water / tank temperature (°C)
|
||||||
|
- `batterySoc_s` — battery state of charge (%)
|
||||||
|
- `soc_s` — SoC fallback (%)
|
||||||
|
- `batteryPercent_s` — battery percent fallback (%)
|
||||||
|
- `auxOn_s` — aux on (0/1)
|
||||||
|
- `auxRelay_s` — aux relay (0/1)
|
||||||
|
- `rlyInSel_s` — control load heating (0/1)
|
||||||
|
- `xCntlLoadState` — control load state (integer; no `_s` suffix)
|
||||||
|
|
||||||
|
### Power (W)
|
||||||
|
|
||||||
|
- `solarPower_s` — solar generation
|
||||||
|
- `loadPower_s` — home load
|
||||||
|
- `hotWaterInPower_s` — hot water in
|
||||||
|
- `hotWaterOutPower_s` — hot water out
|
||||||
|
- `batteryPower_s` — battery power (negative = charging, positive = discharging)
|
||||||
|
- `gridPower_s` — grid power (positive = importing, negative = exporting)
|
||||||
|
|
||||||
|
### Energy flows (W)
|
||||||
|
|
||||||
|
- `solarToLoadPower_s`
|
||||||
|
- `solarToHotWaterPower_s`
|
||||||
|
- `solarToBatteryPower_s`
|
||||||
|
- `solarToGridPower_s`
|
||||||
|
- `gridToLoadPower_s`
|
||||||
|
- `gridToHotWaterPower_s`
|
||||||
|
- `gridToBatteryPower_s`
|
||||||
|
- `batteryToLoadPower_s`
|
||||||
|
- `batteryToHotWaterPower_s`
|
||||||
|
- `batteryToGridPower_s`
|
||||||
|
|
||||||
|
### Energy today (Wh)
|
||||||
|
|
||||||
|
- `solarToday_s`
|
||||||
|
- `loadToday_s`
|
||||||
|
- `hotWaterInToday_s`
|
||||||
|
- `hotWaterOutToday_s`
|
||||||
|
- `solarToLoadToday_s`
|
||||||
|
- `solarToHotWaterToday_s`
|
||||||
|
- `solarToGridToday_s`
|
||||||
|
- `solarToBatteryToday_s`
|
||||||
|
- `gridToLoadToday_s`
|
||||||
|
- `gridToHotWaterToday_s`
|
||||||
|
- `gridToBatteryToday_s`
|
||||||
|
- `batteryToLoadToday_s`
|
||||||
|
- `batteryToHotwaterToday_s` — note the lowercase `w` in `Hotwater`
|
||||||
|
- `batteryToGridToday_s`
|
||||||
|
- `batteryExportToday_s`
|
||||||
|
- `batteryImportToday_s`
|
||||||
|
- `gridImport_s`
|
||||||
|
- `gridExport_s`
|
||||||
|
|
||||||
|
### Validity
|
||||||
|
|
||||||
|
- `vPwrReadings_s` — bitmask of valid live power keys (`65535` = bits 0–15 all valid)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# fill TB_USERNAME / TB_PASSWORD (tenant admin or a user who can create devices)
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3080](http://localhost:3080).
|
||||||
|
|
||||||
|
Optional env:
|
||||||
|
|
||||||
|
- `PORT` — portal port (default `3080`)
|
||||||
|
- `TB_HTTP_URL` — ThingsBoard REST origin (default `https://tb-dvgo.datavolt.au`)
|
||||||
|
- `TB_DEVICE_PROFILE_ID` — defaults to the Diverter / DVgo profile (`e6b53240-7fec-11f1-a69d-edbc0b9170ac`). The add-device dropdown is preselected to a profile named Diverter or DVgo when ThingsBoard returns one.
|
||||||
|
- `TB_DEVICE_TYPE` — default `DVgo`
|
||||||
|
- `TB_MQTT_URL` — default `mqtts://tb-dvgo.datavolt.au:8883` (TLS). Port 1883 is not reachable from outside the TB host.
|
||||||
|
- `TB_MQTT_INSECURE` — `true` to skip TLS certificate verification
|
||||||
|
- `PORTAL_USER` / `PORTAL_PASSWORD` — basic auth for the control API
|
||||||
|
- `ALLOWED_IPS` — comma-separated allowlist for `/api`
|
||||||
|
- `TELEMETRY_INTERVAL_MS` — default `5000`
|
||||||
|
- `DB_PATH` — default `./data/emulator.db`
|
||||||
|
|
||||||
|
If a device already exists in ThingsBoard, paste its MQTT access token in the add form and skip REST provisioning. If create times out, the portal can look the device up by name and adopt it.
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
1532
package-lock.json
generated
Normal file
1532
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "diverter-device-emulator",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "ThingsBoard virtual device emulator with a web control portal",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"mqtt": "^5.10.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
796
public/app.js
Normal file
796
public/app.js
Normal file
@ -0,0 +1,796 @@
|
|||||||
|
const state = {
|
||||||
|
auth: sessionStorage.getItem("emu-auth") || "",
|
||||||
|
devices: [],
|
||||||
|
selectedId: localStorage.getItem("emu-selected") || "",
|
||||||
|
selectedIds: new Set(),
|
||||||
|
schema: { groups: [], fields: [] },
|
||||||
|
profiles: [],
|
||||||
|
health: null,
|
||||||
|
pendingTelemetry: {},
|
||||||
|
applyingRemote: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
loginOverlay: document.getElementById("login-overlay"),
|
||||||
|
loginForm: document.getElementById("login-form"),
|
||||||
|
loginError: document.getElementById("login-error"),
|
||||||
|
tbStatus: document.getElementById("tb-status"),
|
||||||
|
mqttStatus: document.getElementById("mqtt-status"),
|
||||||
|
deviceList: document.getElementById("device-list"),
|
||||||
|
emptyState: document.getElementById("empty-state"),
|
||||||
|
dashboard: document.getElementById("dashboard"),
|
||||||
|
dashName: document.getElementById("dash-name"),
|
||||||
|
dashType: document.getElementById("dash-type"),
|
||||||
|
dashId: document.getElementById("dash-id"),
|
||||||
|
toggleMqtt: document.getElementById("toggle-mqtt"),
|
||||||
|
mqttSpinner: document.getElementById("mqtt-spinner"),
|
||||||
|
connectionResponse: document.getElementById("connection-response"),
|
||||||
|
connectionResponseLabel: document.querySelector(".connection-response"),
|
||||||
|
toggleTelemetry: document.getElementById("toggle-telemetry"),
|
||||||
|
deleteOne: document.getElementById("delete-one"),
|
||||||
|
interval: document.getElementById("interval-input"),
|
||||||
|
publishNow: document.getElementById("publish-now"),
|
||||||
|
publishSelectAll: document.getElementById("publish-select-all"),
|
||||||
|
publishDeselectAll: document.getElementById("publish-deselect-all"),
|
||||||
|
lastPublished: document.getElementById("last-published"),
|
||||||
|
groups: document.getElementById("telemetry-groups"),
|
||||||
|
rpcPre: document.getElementById("rpc-pre"),
|
||||||
|
addModal: document.getElementById("add-modal"),
|
||||||
|
addForm: document.getElementById("add-form"),
|
||||||
|
addError: document.getElementById("add-error"),
|
||||||
|
addProfile: document.getElementById("add-profile"),
|
||||||
|
addBusy: document.getElementById("add-busy"),
|
||||||
|
addBusyText: document.getElementById("add-busy-text"),
|
||||||
|
confirmModal: document.getElementById("confirm-modal"),
|
||||||
|
confirmTitle: document.getElementById("confirm-title"),
|
||||||
|
confirmMessage: document.getElementById("confirm-message"),
|
||||||
|
confirmOk: document.getElementById("confirm-ok"),
|
||||||
|
confirmCancel: document.getElementById("confirm-cancel"),
|
||||||
|
bulkStatus: document.getElementById("bulk-status"),
|
||||||
|
hwLamp: document.getElementById("hw-lamp"),
|
||||||
|
hwLampDetail: document.getElementById("hw-lamp-detail"),
|
||||||
|
auxLamp: document.getElementById("aux-lamp"),
|
||||||
|
auxLampDetail: document.getElementById("aux-lamp-detail"),
|
||||||
|
heatRate: document.getElementById("heat-rate"),
|
||||||
|
thermostat: document.getElementById("thermostat"),
|
||||||
|
tankTemp: document.getElementById("tank-temp"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function headers(extra = {}) {
|
||||||
|
const result = { "Content-Type": "application/json", ...extra };
|
||||||
|
if (state.auth) result.Authorization = `Basic ${state.auth}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers: headers(options.headers),
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
showLogin("Authentication required");
|
||||||
|
const error = new Error("Authentication required");
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(data.error || `Request failed (${response.status})`);
|
||||||
|
error.status = response.status;
|
||||||
|
error.timedOut = Boolean(data.timedOut);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin(message) {
|
||||||
|
els.loginOverlay.classList.remove("hidden");
|
||||||
|
els.loginError.classList.toggle("hidden", !message);
|
||||||
|
els.loginError.textContent = message || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLogin() {
|
||||||
|
els.loginOverlay.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function askConfirm({ title, message, okLabel = "OK", cancelLabel = "Cancel" }) {
|
||||||
|
els.confirmTitle.textContent = title;
|
||||||
|
els.confirmMessage.textContent = message;
|
||||||
|
els.confirmOk.textContent = okLabel;
|
||||||
|
els.confirmCancel.textContent = cancelLabel;
|
||||||
|
els.confirmModal.classList.remove("hidden");
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const finish = (value) => {
|
||||||
|
els.confirmModal.classList.add("hidden");
|
||||||
|
els.confirmOk.removeEventListener("click", onOk);
|
||||||
|
els.confirmCancel.removeEventListener("click", onCancel);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
const onOk = () => finish(true);
|
||||||
|
const onCancel = () => finish(false);
|
||||||
|
els.confirmOk.addEventListener("click", onOk);
|
||||||
|
els.confirmCancel.addEventListener("click", onCancel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedDevice() {
|
||||||
|
return state.devices.find((device) => device.id === state.selectedId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelected(id) {
|
||||||
|
state.selectedId = id;
|
||||||
|
localStorage.setItem("emu-selected", id || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
if (!ts) return "Never published";
|
||||||
|
return `Last publish ${new Date(ts).toLocaleTimeString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHealth() {
|
||||||
|
const tb = state.health?.thingsboard;
|
||||||
|
els.tbStatus.textContent = tb?.ok ? `ThingsBoard ${tb.host}` : `ThingsBoard ${tb?.error || "offline"}`;
|
||||||
|
els.tbStatus.className = `status-pill ${tb?.ok ? "on" : "off"}`;
|
||||||
|
els.mqttStatus.textContent = state.health?.mqttUrl || "MQTT";
|
||||||
|
els.mqttStatus.className = "status-pill on";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (!state.devices.length) {
|
||||||
|
els.deviceList.innerHTML = `<p class="muted">No emulated devices yet.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.deviceList.innerHTML = state.devices
|
||||||
|
.map((device) => {
|
||||||
|
const checked = state.selectedIds.has(device.id) ? "checked" : "";
|
||||||
|
const selected = device.id === state.selectedId ? "selected" : "";
|
||||||
|
const live = device.connected && !device.disabled;
|
||||||
|
return `
|
||||||
|
<div class="device-row ${selected}" data-id="${device.id}">
|
||||||
|
<input class="device-check" type="checkbox" data-select="${device.id}" ${checked} aria-label="Select ${escapeHtml(device.name)}" />
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(device.label || device.name)}</strong>
|
||||||
|
<small>${escapeHtml(device.name)}${device.telemetryEnabled ? " · telemetry on" : " · telemetry off"}</small>
|
||||||
|
</div>
|
||||||
|
<span class="dot ${live ? "live" : ""}" title="${live ? "Connected" : "Disconnected"}"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard({ refreshFields = true } = {}) {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) {
|
||||||
|
state.lastRenderedId = "";
|
||||||
|
els.dashboard.classList.add("hidden");
|
||||||
|
els.emptyState.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.emptyState.classList.add("hidden");
|
||||||
|
els.dashboard.classList.remove("hidden");
|
||||||
|
els.dashName.textContent = device.label || device.name;
|
||||||
|
els.dashType.textContent = device.type || "Device";
|
||||||
|
els.dashId.textContent = device.id;
|
||||||
|
|
||||||
|
const mqttBusy = Boolean(device.connecting);
|
||||||
|
els.mqttSpinner.classList.toggle("hidden", !mqttBusy);
|
||||||
|
els.toggleMqtt.disabled = mqttBusy;
|
||||||
|
if (device.connected) {
|
||||||
|
els.toggleMqtt.textContent = "Disconnect";
|
||||||
|
els.toggleMqtt.className = "";
|
||||||
|
} else {
|
||||||
|
els.toggleMqtt.textContent = "Connect";
|
||||||
|
els.toggleMqtt.className = "primary";
|
||||||
|
}
|
||||||
|
const response = device.lastConnectionResponse || device.lastError || "—";
|
||||||
|
els.connectionResponse.textContent = response;
|
||||||
|
els.connectionResponseLabel.classList.toggle(
|
||||||
|
"error",
|
||||||
|
Boolean(device.lastError) && !device.connected && !device.connecting,
|
||||||
|
);
|
||||||
|
|
||||||
|
els.toggleTelemetry.textContent = device.telemetryEnabled ? "Stop telemetry" : "Start telemetry";
|
||||||
|
els.toggleTelemetry.className = device.telemetryEnabled ? "stop-telemetry" : "primary";
|
||||||
|
els.toggleTelemetry.disabled = !device.connected && !device.telemetryEnabled;
|
||||||
|
if (document.activeElement !== els.interval) {
|
||||||
|
els.interval.value = device.intervalMs;
|
||||||
|
}
|
||||||
|
els.lastPublished.textContent = formatTime(device.lastPublishedAt);
|
||||||
|
renderPlant(device);
|
||||||
|
els.rpcPre.textContent = JSON.stringify(
|
||||||
|
{ lastRpc: device.lastRpc, sharedAttributes: device.sharedAttributes },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const editing =
|
||||||
|
els.groups.contains(document.activeElement) || Object.keys(state.pendingTelemetry).length > 0;
|
||||||
|
const deviceChanged = state.lastRenderedId !== device.id;
|
||||||
|
state.lastRenderedId = device.id;
|
||||||
|
if ((refreshFields || deviceChanged || !els.groups.childElementCount) && !editing) {
|
||||||
|
renderFields(device);
|
||||||
|
} else {
|
||||||
|
syncFieldValues(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlant(device) {
|
||||||
|
const view = device.plantView || {};
|
||||||
|
const plant = device.plant || {};
|
||||||
|
els.hwLamp.classList.toggle("on", Boolean(view.hwOn));
|
||||||
|
els.auxLamp.classList.toggle("on", Boolean(view.auxOn));
|
||||||
|
els.hwLampDetail.textContent = view.hwDetail || "Off";
|
||||||
|
els.auxLampDetail.textContent = view.auxDetail || "Off";
|
||||||
|
if (document.activeElement !== els.heatRate) {
|
||||||
|
els.heatRate.value = plant.heatRateCpm ?? 1;
|
||||||
|
}
|
||||||
|
if (document.activeElement !== els.thermostat) {
|
||||||
|
els.thermostat.value = plant.thermostatC ?? 65;
|
||||||
|
}
|
||||||
|
const tank = device.telemetry?.heatsink_s;
|
||||||
|
els.tankTemp.textContent = tank == null ? "—" : `${Number(tank).toFixed(1)} °C`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishedKeySet(device) {
|
||||||
|
if (Array.isArray(device?.publishedKeys)) return new Set(device.publishedKeys);
|
||||||
|
return new Set(state.schema.fields.map((field) => field.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFields(device) {
|
||||||
|
const values = { ...device.telemetry, ...state.pendingTelemetry };
|
||||||
|
const enabled = publishedKeySet(device);
|
||||||
|
els.groups.innerHTML = state.schema.groups
|
||||||
|
.map((group) => {
|
||||||
|
const fields = state.schema.fields.filter((field) => field.group === group.id);
|
||||||
|
return `
|
||||||
|
<section class="group">
|
||||||
|
<h3>${escapeHtml(group.title)}</h3>
|
||||||
|
<p class="muted">${escapeHtml(group.subtitle || "")}</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="telemetry-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>key</th>
|
||||||
|
<th>value</th>
|
||||||
|
<th>On</th>
|
||||||
|
<th>new value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${fields.map((field) => fieldRowHtml(field, values[field.key], enabled.has(field.key))).join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue(value) {
|
||||||
|
if (value === true || value === "1") return "1";
|
||||||
|
if (value === false || value === "0") return "0";
|
||||||
|
if (value == null) return "—";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFieldValues(device) {
|
||||||
|
const pending = state.pendingTelemetry;
|
||||||
|
const values = device.telemetry || {};
|
||||||
|
const enabled = publishedKeySet(device);
|
||||||
|
|
||||||
|
for (const cell of els.groups.querySelectorAll("[data-value-key]")) {
|
||||||
|
const key = cell.getAttribute("data-value-key");
|
||||||
|
if (!key || Object.prototype.hasOwnProperty.call(pending, key)) continue;
|
||||||
|
cell.textContent = displayValue(values[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const toggle of els.groups.querySelectorAll("[data-publish-key]")) {
|
||||||
|
const key = toggle.dataset.publishKey;
|
||||||
|
const on = enabled.has(key);
|
||||||
|
if (document.activeElement !== toggle) toggle.checked = on;
|
||||||
|
const row = toggle.closest("tr");
|
||||||
|
if (row) row.classList.toggle("inactive", !on);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldRowHtml(field, value, enabled) {
|
||||||
|
const inactive = enabled ? "" : "inactive";
|
||||||
|
const onToggle = `
|
||||||
|
<input
|
||||||
|
class="device-check key-on-check"
|
||||||
|
type="checkbox"
|
||||||
|
data-publish-key="${escapeHtml(field.key)}"
|
||||||
|
${enabled ? "checked" : ""}
|
||||||
|
aria-label="Include ${escapeHtml(field.key)} in telemetry"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
const submit = `
|
||||||
|
<button type="button" data-submit-key="${escapeHtml(field.key)}">update</button>
|
||||||
|
`;
|
||||||
|
const title = escapeHtml(field.unit ? `${field.label} (${field.unit})` : field.label);
|
||||||
|
|
||||||
|
let editor;
|
||||||
|
if (field.type === "boolean") {
|
||||||
|
const on = value === 1 || value === true || value === "1";
|
||||||
|
editor = `
|
||||||
|
<select
|
||||||
|
data-key="${escapeHtml(field.key)}"
|
||||||
|
aria-label="New value for ${escapeHtml(field.key)}"
|
||||||
|
>
|
||||||
|
<option value="1" ${on ? "selected" : ""}>true</option>
|
||||||
|
<option value="0" ${on ? "" : "selected"}>false</option>
|
||||||
|
</select>
|
||||||
|
${submit}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const step = field.step ?? (field.type === "integer" ? 1 : "any");
|
||||||
|
editor = `
|
||||||
|
<input
|
||||||
|
data-key="${escapeHtml(field.key)}"
|
||||||
|
type="number"
|
||||||
|
step="${step}"
|
||||||
|
${field.min != null ? `min="${field.min}"` : ""}
|
||||||
|
${field.max != null ? `max="${field.max}"` : ""}
|
||||||
|
value="${value ?? 0}"
|
||||||
|
aria-label="New value for ${escapeHtml(field.key)}"
|
||||||
|
/>
|
||||||
|
${submit}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="field-row ${inactive}">
|
||||||
|
<td class="key" title="${title}">${escapeHtml(field.key)}</td>
|
||||||
|
<td class="field-value" data-value-key="${escapeHtml(field.key)}">${escapeHtml(displayValue(value))}</td>
|
||||||
|
<td class="on-cell">${onToggle}</td>
|
||||||
|
<td class="new-value">${editor}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushTelemetry() {
|
||||||
|
const device = selectedDevice();
|
||||||
|
const payload = { ...state.pendingTelemetry };
|
||||||
|
if (!device || !Object.keys(payload).length) return;
|
||||||
|
state.pendingTelemetry = {};
|
||||||
|
const data = await api(`/api/devices/${device.id}/telemetry`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
upsertDevice(data.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertDevice(device) {
|
||||||
|
const index = state.devices.findIndex((item) => item.id === device.id);
|
||||||
|
if (index === -1) state.devices.unshift(device);
|
||||||
|
else state.devices[index] = device;
|
||||||
|
renderList();
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const [health, devices] = await Promise.all([api("/api/health"), api("/api/devices")]);
|
||||||
|
state.health = health;
|
||||||
|
state.devices = devices.devices || [];
|
||||||
|
if (state.selectedId && !state.devices.some((device) => device.id === state.selectedId)) {
|
||||||
|
setSelected(state.devices[0]?.id || "");
|
||||||
|
}
|
||||||
|
if (!state.selectedId && state.devices[0]) setSelected(state.devices[0].id);
|
||||||
|
renderHealth();
|
||||||
|
renderList();
|
||||||
|
renderDashboard({ refreshFields: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatic() {
|
||||||
|
const [schema, profiles] = await Promise.all([
|
||||||
|
api("/api/telemetry-schema"),
|
||||||
|
api("/api/device-profiles").catch(() => ({ profiles: [] })),
|
||||||
|
]);
|
||||||
|
state.schema = schema;
|
||||||
|
state.profiles = profiles.profiles || [];
|
||||||
|
const preferredId =
|
||||||
|
profiles.defaultProfileId ||
|
||||||
|
state.profiles.find((profile) => profile.preferred)?.id ||
|
||||||
|
"";
|
||||||
|
els.addProfile.innerHTML = state.profiles
|
||||||
|
.map(
|
||||||
|
(profile) =>
|
||||||
|
`<option value="${profile.id}">${escapeHtml(profile.name)}${
|
||||||
|
profile.preferred ? " (diverter)" : profile.default ? " (TB default)" : ""
|
||||||
|
}</option>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
if (preferredId) els.addProfile.value = preferredId;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.loginForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const user = document.getElementById("login-user").value;
|
||||||
|
const pass = document.getElementById("login-pass").value;
|
||||||
|
state.auth = btoa(`${user}:${pass}`);
|
||||||
|
sessionStorage.setItem("emu-auth", state.auth);
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
await loadStatic();
|
||||||
|
hideLogin();
|
||||||
|
} catch (error) {
|
||||||
|
showLogin(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceMenuToggle = document.getElementById("device-menu-toggle");
|
||||||
|
const deviceMenu = document.getElementById("device-menu");
|
||||||
|
|
||||||
|
function setDeviceMenuOpen(open) {
|
||||||
|
deviceMenu.classList.toggle("hidden", !open);
|
||||||
|
deviceMenuToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceMenuToggle.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setDeviceMenuOpen(deviceMenu.classList.contains("hidden"));
|
||||||
|
});
|
||||||
|
|
||||||
|
deviceMenu.addEventListener("click", (event) => {
|
||||||
|
if (event.target.closest("button")) setDeviceMenuOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (!event.target.closest(".device-menu")) setDeviceMenuOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") setDeviceMenuOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("add-open").addEventListener("click", () => {
|
||||||
|
els.addError.classList.add("hidden");
|
||||||
|
els.addModal.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
document.getElementById("add-cancel").addEventListener("click", () => {
|
||||||
|
els.addModal.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
function setAddBusy(busy, text) {
|
||||||
|
els.addBusy.classList.toggle("hidden", !busy);
|
||||||
|
els.addBusy.setAttribute("aria-busy", busy ? "true" : "false");
|
||||||
|
if (text) els.addBusyText.textContent = text;
|
||||||
|
const submitButton = els.addForm.querySelector("button[type='submit']");
|
||||||
|
const cancelButton = document.getElementById("add-cancel");
|
||||||
|
submitButton.disabled = busy;
|
||||||
|
cancelButton.disabled = busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.addForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
els.addError.classList.add("hidden");
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById("add-name").value,
|
||||||
|
label: document.getElementById("add-label").value,
|
||||||
|
type: document.getElementById("add-type").value,
|
||||||
|
deviceProfileId: document.getElementById("add-profile").value || undefined,
|
||||||
|
accessToken: document.getElementById("add-token").value || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
setAddBusy(true, "Waiting for ThingsBoard…");
|
||||||
|
try {
|
||||||
|
const data = await api("/api/devices", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
finishAdd(data.device);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.timedOut && !payload.accessToken) {
|
||||||
|
setAddBusy(false);
|
||||||
|
const saveAnyway = await askConfirm({
|
||||||
|
title: "ThingsBoard timed out",
|
||||||
|
message: `No response while creating "${payload.name}". The device may already exist in ThingsBoard. Save it to the emulator database anyway?`,
|
||||||
|
okLabel: "Save to emulator",
|
||||||
|
cancelLabel: "Don't save",
|
||||||
|
});
|
||||||
|
if (!saveAnyway) {
|
||||||
|
els.addError.classList.remove("hidden");
|
||||||
|
els.addError.textContent = error.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddBusy(true, "Looking up the ThingsBoard device…");
|
||||||
|
try {
|
||||||
|
const adopted = await api("/api/devices/adopt", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: payload.name,
|
||||||
|
label: payload.label,
|
||||||
|
type: payload.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
finishAdd(adopted.device);
|
||||||
|
return;
|
||||||
|
} catch (adoptError) {
|
||||||
|
els.addError.classList.remove("hidden");
|
||||||
|
els.addError.textContent = adoptError.timedOut
|
||||||
|
? `${adoptError.message} Paste the MQTT access token from ThingsBoard and try Create again.`
|
||||||
|
: adoptError.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
els.addError.classList.remove("hidden");
|
||||||
|
els.addError.textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
setAddBusy(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function finishAdd(device) {
|
||||||
|
upsertDevice(device);
|
||||||
|
setSelected(device.id);
|
||||||
|
els.addModal.classList.add("hidden");
|
||||||
|
els.addForm.reset();
|
||||||
|
const preferredId =
|
||||||
|
state.profiles.find((profile) => profile.preferred)?.id ||
|
||||||
|
els.addProfile.querySelector("option")?.value ||
|
||||||
|
"";
|
||||||
|
if (preferredId) els.addProfile.value = preferredId;
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
els.deviceList.addEventListener("click", (event) => {
|
||||||
|
const checkbox = event.target.closest("input[data-select]");
|
||||||
|
if (checkbox) {
|
||||||
|
const id = checkbox.getAttribute("data-select");
|
||||||
|
if (checkbox.checked) state.selectedIds.add(id);
|
||||||
|
else state.selectedIds.delete(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = event.target.closest(".device-row");
|
||||||
|
if (!row) return;
|
||||||
|
setSelected(row.dataset.id);
|
||||||
|
renderList();
|
||||||
|
renderDashboard();
|
||||||
|
});
|
||||||
|
|
||||||
|
function targetDeviceIds() {
|
||||||
|
if (state.selectedIds.size) return [...state.selectedIds];
|
||||||
|
if (state.selectedId) return [state.selectedId];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBulkStatus(message) {
|
||||||
|
if (!els.bulkStatus) return;
|
||||||
|
els.bulkStatus.textContent = message || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBulk(action) {
|
||||||
|
const ids = targetDeviceIds();
|
||||||
|
if (!ids.length) {
|
||||||
|
setBulkStatus("Tick a checkbox, or open a device, then try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "delete") {
|
||||||
|
const ok = await askConfirm({
|
||||||
|
title: "Remove from emulator?",
|
||||||
|
message: `Remove ${ids.length} device(s) from this emulator? If they still exist in ThingsBoard they will be deleted there too.`,
|
||||||
|
okLabel: "Delete",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
setBulkStatus("Deleting…");
|
||||||
|
ids.forEach((id) => dropDeviceLocally(id));
|
||||||
|
renderList();
|
||||||
|
renderDashboard();
|
||||||
|
} else {
|
||||||
|
setBulkStatus("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const path =
|
||||||
|
action === "delete"
|
||||||
|
? "/api/devices/delete"
|
||||||
|
: action === "enable"
|
||||||
|
? "/api/devices/enable"
|
||||||
|
: "/api/devices/disable";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api(path, { method: "POST", body: JSON.stringify({ ids }) });
|
||||||
|
if (action === "delete") {
|
||||||
|
ids.forEach((id) => state.selectedIds.delete(id));
|
||||||
|
if (ids.includes(state.selectedId)) setSelected("");
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
setBulkStatus(
|
||||||
|
action === "delete"
|
||||||
|
? `Deleted ${ids.length} device(s).`
|
||||||
|
: action === "enable"
|
||||||
|
? `Enabled ${ids.length} device(s).`
|
||||||
|
: `Disabled ${ids.length} device(s).`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setBulkStatus(error.message);
|
||||||
|
await refresh().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("select-all").addEventListener("click", () => {
|
||||||
|
if (!state.devices.length) {
|
||||||
|
setBulkStatus("No devices to select.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.selectedIds = new Set(state.devices.map((device) => device.id));
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("deselect-all").addEventListener("click", () => {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("bulk-disable").addEventListener("click", () => runBulk("disable"));
|
||||||
|
document.getElementById("bulk-enable").addEventListener("click", () => runBulk("enable"));
|
||||||
|
document.getElementById("bulk-delete").addEventListener("click", () => runBulk("delete"));
|
||||||
|
|
||||||
|
els.toggleTelemetry.addEventListener("click", async () => {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
if (!device.telemetryEnabled && !device.connected) return;
|
||||||
|
const path = device.telemetryEnabled
|
||||||
|
? `/api/devices/${device.id}/telemetry/stop`
|
||||||
|
: `/api/devices/${device.id}/telemetry/start`;
|
||||||
|
const data = await api(path, { method: "POST" });
|
||||||
|
upsertDevice(data.device);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.toggleMqtt.addEventListener("click", async () => {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device || device.connecting) return;
|
||||||
|
const path = device.connected
|
||||||
|
? `/api/devices/${device.id}/disable`
|
||||||
|
: `/api/devices/${device.id}/enable`;
|
||||||
|
const data = await api(path, { method: "POST" });
|
||||||
|
upsertDevice(data.device);
|
||||||
|
});
|
||||||
|
|
||||||
|
function dropDeviceLocally(id) {
|
||||||
|
state.selectedIds.delete(id);
|
||||||
|
state.devices = state.devices.filter((device) => device.id !== id);
|
||||||
|
if (state.selectedId === id) setSelected(state.devices[0]?.id || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
els.deleteOne.addEventListener("click", async () => {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
state.selectedIds = new Set([device.id]);
|
||||||
|
await runBulk("delete");
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function savePlant() {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
const data = await api(`/api/devices/${device.id}/plant`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
heatRateCpm: Number(els.heatRate.value),
|
||||||
|
thermostatC: Number(els.thermostat.value),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
upsertDevice(data.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
els.heatRate.addEventListener("change", () => savePlant());
|
||||||
|
els.thermostat.addEventListener("change", () => savePlant());
|
||||||
|
|
||||||
|
els.publishNow.addEventListener("click", async () => {
|
||||||
|
await flushTelemetry();
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
const data = await api(`/api/devices/${device.id}/telemetry/publish`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(device.telemetry),
|
||||||
|
});
|
||||||
|
upsertDevice(data.device);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function savePublishedKeys(keys) {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
const data = await api(`/api/devices/${device.id}/published-keys`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ keys }),
|
||||||
|
});
|
||||||
|
upsertDevice(data.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
els.publishSelectAll.addEventListener("click", async () => {
|
||||||
|
await savePublishedKeys(state.schema.fields.map((field) => field.key));
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (device) renderFields(device);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.publishDeselectAll.addEventListener("click", async () => {
|
||||||
|
await savePublishedKeys([]);
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (device) renderFields(device);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.groups.addEventListener("change", async (event) => {
|
||||||
|
const publishToggle = event.target.closest("[data-publish-key]");
|
||||||
|
if (!publishToggle) return;
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
const key = publishToggle.dataset.publishKey;
|
||||||
|
const next = publishedKeySet(device);
|
||||||
|
if (publishToggle.checked) next.add(key);
|
||||||
|
else next.delete(key);
|
||||||
|
const row = publishToggle.closest("tr");
|
||||||
|
if (row) row.classList.toggle("inactive", !publishToggle.checked);
|
||||||
|
await savePublishedKeys(state.schema.fields.map((item) => item.key).filter((item) => next.has(item)));
|
||||||
|
});
|
||||||
|
|
||||||
|
function readNewValue(key) {
|
||||||
|
const field = state.schema.fields.find((item) => item.key === key);
|
||||||
|
const input = els.groups.querySelector(`[data-key="${CSS.escape(key)}"]`);
|
||||||
|
if (!field || !input) return null;
|
||||||
|
if (field.type === "boolean") {
|
||||||
|
return input.value === "1" || input.value === "true" ? 1 : 0;
|
||||||
|
}
|
||||||
|
return Number(input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNewValue(key) {
|
||||||
|
const value = readNewValue(key);
|
||||||
|
if (value == null || Number.isNaN(value)) return;
|
||||||
|
state.pendingTelemetry[key] = value;
|
||||||
|
await flushTelemetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
els.groups.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-submit-key]");
|
||||||
|
if (!button) return;
|
||||||
|
submitNewValue(button.dataset.submitKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.groups.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
const input = event.target.closest("[data-key]");
|
||||||
|
if (!input) return;
|
||||||
|
event.preventDefault();
|
||||||
|
submitNewValue(input.dataset.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
try {
|
||||||
|
await loadStatic();
|
||||||
|
await refresh();
|
||||||
|
hideLogin();
|
||||||
|
setInterval(() => {
|
||||||
|
refresh().catch(() => {});
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 401) {
|
||||||
|
showLogin(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
225
public/index.html
Normal file
225
public/index.html
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>DVgo Device Emulator</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Outfit:wght@500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="login-overlay" class="overlay hidden">
|
||||||
|
<form id="login-form" class="login-card" autocomplete="on">
|
||||||
|
<p class="eyebrow">Control portal</p>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p class="muted">Basic auth for the emulator API. Credentials stay in this browser session.</p>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input id="login-user" name="username" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input id="login-pass" name="password" type="password" required />
|
||||||
|
</label>
|
||||||
|
<p id="login-error" class="error hidden"></p>
|
||||||
|
<button type="submit">Continue</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>DVgo device emulator</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-meta">
|
||||||
|
<div id="tb-status" class="status-pill off">ThingsBoard</div>
|
||||||
|
<div id="mqtt-status" class="status-pill off">MQTT</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Devices</h2>
|
||||||
|
<div class="device-menu">
|
||||||
|
<button
|
||||||
|
id="device-menu-toggle"
|
||||||
|
type="button"
|
||||||
|
class="device-menu-button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="device-menu"
|
||||||
|
aria-label="Device options"
|
||||||
|
>
|
||||||
|
<span class="device-menu-icon" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<div id="device-menu" class="device-menu-panel hidden" role="menu">
|
||||||
|
<button id="add-open" type="button" class="primary" role="menuitem">Add</button>
|
||||||
|
<button id="select-all" type="button" role="menuitem">Select all</button>
|
||||||
|
<button id="deselect-all" type="button" role="menuitem">Deselect all</button>
|
||||||
|
<button id="bulk-disable" type="button" role="menuitem">Disable selected</button>
|
||||||
|
<button id="bulk-enable" type="button" role="menuitem">Enable selected</button>
|
||||||
|
<button id="bulk-delete" type="button" class="danger" role="menuitem">Delete selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="bulk-status" class="muted"></p>
|
||||||
|
<div id="device-list" class="device-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<div id="empty-state" class="empty-state">
|
||||||
|
<h2>No device selected</h2>
|
||||||
|
<p>Add a virtual DVgo device, then use the dashboard to publish live *_s telemetry to ThingsBoard.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dashboard" class="dashboard hidden">
|
||||||
|
<div class="dash-head">
|
||||||
|
<div>
|
||||||
|
<p id="dash-type" class="eyebrow"></p>
|
||||||
|
<h2 id="dash-name"></h2>
|
||||||
|
<p id="dash-id" class="mono muted"></p>
|
||||||
|
</div>
|
||||||
|
<button id="delete-one" type="button" class="danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-block">
|
||||||
|
<button id="toggle-mqtt" type="button" class="primary">Connect</button>
|
||||||
|
<span id="mqtt-spinner" class="spinner spinner-inline hidden" aria-hidden="true"></span>
|
||||||
|
<p class="connection-response">
|
||||||
|
connection response:
|
||||||
|
<span id="connection-response">—</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-block">
|
||||||
|
<button id="toggle-telemetry" type="button" class="primary">Start telemetry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="group plant-panel">
|
||||||
|
<h3>Virtual plant</h3>
|
||||||
|
<p class="muted">Follows ThingsBoard shared attributes from the apps: one-shot boost (boostNowHw / boostNowAux) and HW/aux schedules. Schedule saves are polled every 5s while MQTT is connected.</p>
|
||||||
|
<section class="plant-card">
|
||||||
|
<h4>Hot water</h4>
|
||||||
|
<div class="lamps">
|
||||||
|
<div class="lamp-card">
|
||||||
|
<span id="hw-lamp" class="lamp" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<strong>Hot water boost</strong>
|
||||||
|
<small id="hw-lamp-detail">Off</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lamp-card">
|
||||||
|
<span id="tank-temp" class="plant-temp-value">—</span>
|
||||||
|
<div>
|
||||||
|
<strong>Hot Water Temp</strong>
|
||||||
|
<small class="key">heatsink_s</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row plant-fields">
|
||||||
|
<label>
|
||||||
|
Heat rate (°C / min)
|
||||||
|
<input id="heat-rate" type="number" min="0" max="20" step="1" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Thermostat max (°C)
|
||||||
|
<input id="thermostat" type="number" min="0" max="99" step="1" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="plant-card">
|
||||||
|
<h4>Aux</h4>
|
||||||
|
<div class="lamps">
|
||||||
|
<div class="lamp-card">
|
||||||
|
<span id="aux-lamp" class="lamp" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<strong>Aux boost / divert</strong>
|
||||||
|
<small id="aux-lamp-detail">Off</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group publish-panel">
|
||||||
|
<h3>Publish</h3>
|
||||||
|
<div class="meta-row publish-fields">
|
||||||
|
<label>
|
||||||
|
Publish interval (ms)
|
||||||
|
<input id="interval-input" type="number" min="500" step="500" />
|
||||||
|
</label>
|
||||||
|
<p id="last-published" class="muted"></p>
|
||||||
|
</div>
|
||||||
|
<div class="publish-actions">
|
||||||
|
<button id="publish-now" type="button">Publish now</button>
|
||||||
|
<button id="publish-select-all" type="button">Select all</button>
|
||||||
|
<button id="publish-deselect-all" type="button">Deselect all</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="telemetry-groups"></div>
|
||||||
|
|
||||||
|
<details class="rpc-log">
|
||||||
|
<summary>Last RPC / shared attributes</summary>
|
||||||
|
<pre id="rpc-pre">No RPC yet.</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="add-modal" class="overlay hidden">
|
||||||
|
<form id="add-form" class="login-card">
|
||||||
|
<div id="add-busy" class="busy hidden" aria-live="polite" aria-busy="false">
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
<p id="add-busy-text">Waiting for ThingsBoard…</p>
|
||||||
|
</div>
|
||||||
|
<p class="eyebrow">New virtual device</p>
|
||||||
|
<h2>Create in ThingsBoard</h2>
|
||||||
|
<label>
|
||||||
|
Name / serial
|
||||||
|
<input id="add-name" name="name" required placeholder="EMU-AABBCCDDEEFF" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Label
|
||||||
|
<input id="add-label" name="label" placeholder="Kitchen emulator" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Type
|
||||||
|
<input id="add-type" name="type" placeholder="DVgo" value="DVgo" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Device profile
|
||||||
|
<select id="add-profile"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Existing access token (optional)
|
||||||
|
<input id="add-token" name="accessToken" placeholder="Skip TB create and attach this MQTT token" />
|
||||||
|
</label>
|
||||||
|
<p id="add-error" class="error hidden"></p>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" id="add-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="primary">Create device</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="confirm-modal" class="overlay hidden">
|
||||||
|
<div class="login-card">
|
||||||
|
<p class="eyebrow">ThingsBoard timeout</p>
|
||||||
|
<h2 id="confirm-title">Save device anyway?</h2>
|
||||||
|
<p id="confirm-message" class="muted"></p>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" id="confirm-cancel">Don't save</button>
|
||||||
|
<button type="button" id="confirm-ok" class="primary">Save to emulator</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
702
public/styles.css
Normal file
702
public/styles.css
Normal file
@ -0,0 +1,702 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #07110f;
|
||||||
|
--bg-elev: #0f1b18;
|
||||||
|
--bg-elev-2: #15241f;
|
||||||
|
--line: #274038;
|
||||||
|
--text: #e7f3ee;
|
||||||
|
--muted: #8aa397;
|
||||||
|
--accent: #3dcc8a;
|
||||||
|
--accent-dim: rgba(61, 204, 138, 0.14);
|
||||||
|
--warn: #f0b429;
|
||||||
|
--danger: #e85d4c;
|
||||||
|
--info: #4cc4d4;
|
||||||
|
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at 10% -10%, rgba(61, 204, 138, 0.12), transparent 50%),
|
||||||
|
radial-gradient(900px 400px at 100% 0%, rgba(76, 196, 212, 0.08), transparent 45%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "IBM Plex Sans", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: Outfit, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 3px 0 #081210;
|
||||||
|
transition: transform 0.08s ease, box-shadow 0.08s ease, filter 0.08s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
border-color: #3c5d52;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active:not(:disabled) {
|
||||||
|
transform: translateY(3px);
|
||||||
|
box-shadow: 0 0 0 #081210;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #062015;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 3px 0 #1f7a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:active:not(:disabled) {
|
||||||
|
box-shadow: 0 0 0 #1f7a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
color: #ffd4ce;
|
||||||
|
border-color: #7a322a;
|
||||||
|
background: #2a1412;
|
||||||
|
box-shadow: 0 3px 0 #5a221c;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:active:not(:disabled) {
|
||||||
|
box-shadow: 0 0 0 #5a221c;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.stop-telemetry {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 3px 0 #9a3228;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.stop-telemetry:active:not(:disabled) {
|
||||||
|
box-shadow: 0 0 0 #9a3228;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-block {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-block > button {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-response {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-response span {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-response.error span {
|
||||||
|
color: #ffb4ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-inline {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-width: 2px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 22px 28px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-meta,
|
||||||
|
.dash-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill::before {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #5c6f68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.on {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: rgba(61, 204, 138, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.on::before {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 4px rgba(61, 204, 138, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.warn {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.warn::before {
|
||||||
|
background: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.off::before {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 340px minmax(0, 1fr);
|
||||||
|
min-height: calc(100vh - 92px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
background: rgba(15, 27, 24, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.dash-head {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row.selected {
|
||||||
|
border-color: rgba(61, 204, 138, 0.55);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(61, 204, 138, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-check {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin: 0;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg) center / 12px 12px no-repeat;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-check:hover {
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-check:checked {
|
||||||
|
background-color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='%23062015' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round' d='M3.2 8.3 L6.6 11.6 L12.8 4.4'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row small {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row .dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row .dot.live {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-menu-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-menu-icon,
|
||||||
|
.device-menu-icon::before,
|
||||||
|
.device-menu-icon::after {
|
||||||
|
display: block;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-menu-icon {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-menu-icon::before,
|
||||||
|
.device-menu-icon::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-menu-icon::before {
|
||||||
|
top: -7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-menu-icon::after {
|
||||||
|
top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-menu-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 12;
|
||||||
|
min-width: 210px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-menu-panel button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-status:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
padding: 22px 26px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 48px 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ffb4ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-fields {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-card {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-card h4 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-family: Outfit, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp-card strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp-card small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp-card .key {
|
||||||
|
color: var(--info);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-temp-value {
|
||||||
|
min-width: 64px;
|
||||||
|
font-family: Outfit, sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3a4743;
|
||||||
|
opacity: 0.45;
|
||||||
|
box-shadow: inset 0 0 0 2px #1b2824;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp.on {
|
||||||
|
background: var(--danger);
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 0 4px rgba(232, 93, 76, 0.22), 0 0 16px rgba(232, 93, 76, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-fields {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#telemetry-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table th,
|
||||||
|
.telemetry-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table .key {
|
||||||
|
color: var(--info);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table .field-value {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table .on-cell {
|
||||||
|
width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table .on-cell .device-check {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table .new-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table .new-value input[type="number"],
|
||||||
|
.telemetry-table .new-value select {
|
||||||
|
width: 140px;
|
||||||
|
min-width: 140px;
|
||||||
|
max-width: 140px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table .new-value button {
|
||||||
|
padding: 7px 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row.inactive {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-on-check {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(4, 10, 9, 0.72);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirm-modal {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: min(440px, 100%);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(8, 18, 16, 0.88);
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 18px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--line);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpc-log {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpc-log pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/auth.js
Normal file
44
src/auth.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
function unauthorized(res) {
|
||||||
|
res.set("WWW-Authenticate", 'Basic realm="Diverter emulator"');
|
||||||
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientIp(req) {
|
||||||
|
const forwarded = req.headers["x-forwarded-for"];
|
||||||
|
if (typeof forwarded === "string" && forwarded.trim()) {
|
||||||
|
return forwarded.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return req.socket?.remoteAddress || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipAllowlist(allowedIps) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!allowedIps.length) return next();
|
||||||
|
const ip = clientIp(req);
|
||||||
|
const allowed = allowedIps.some((entry) => ip === entry || ip.endsWith(`:${entry}`) || ip.includes(entry));
|
||||||
|
if (!allowed) {
|
||||||
|
return res.status(403).json({ error: `Forbidden from ${ip || "unknown IP"}` });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicAuth(user, password) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!user || !password) return next();
|
||||||
|
const header = req.headers.authorization || "";
|
||||||
|
if (!header.startsWith("Basic ")) return unauthorized(res);
|
||||||
|
|
||||||
|
const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
|
||||||
|
const sep = decoded.indexOf(":");
|
||||||
|
const givenUser = sep === -1 ? decoded : decoded.slice(0, sep);
|
||||||
|
const givenPass = sep === -1 ? "" : decoded.slice(sep + 1);
|
||||||
|
|
||||||
|
if (givenUser !== user || givenPass !== password) {
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ipAllowlist, basicAuth };
|
||||||
32
src/config.js
Normal file
32
src/config.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const path = require("path");
|
||||||
|
require("dotenv").config({ path: path.join(__dirname, "..", ".env") });
|
||||||
|
|
||||||
|
function optionalList(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.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");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
port: Number(process.env.PORT || 3080),
|
||||||
|
tbHttpUrl: String(process.env.TB_HTTP_URL || "https://tb-dvgo.datavolt.au").replace(/\/$/, ""),
|
||||||
|
tbUsername: process.env.TB_USERNAME || "",
|
||||||
|
tbPassword: process.env.TB_PASSWORD || "",
|
||||||
|
tbDeviceProfileId: process.env.TB_DEVICE_PROFILE_ID || "e6b53240-7fec-11f1-a69d-edbc0b9170ac",
|
||||||
|
tbDeviceType: process.env.TB_DEVICE_TYPE || "DVgo",
|
||||||
|
tbMqttUrl: process.env.TB_MQTT_URL || "mqtts://tb-dvgo.datavolt.au:8883",
|
||||||
|
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),
|
||||||
|
portalUser: process.env.PORTAL_USER || "",
|
||||||
|
portalPassword: process.env.PORTAL_PASSWORD || "",
|
||||||
|
allowedIps: optionalList(process.env.ALLOWED_IPS),
|
||||||
|
telemetryIntervalMs: Number(process.env.TELEMETRY_INTERVAL_MS || 5000),
|
||||||
|
dbPath,
|
||||||
|
};
|
||||||
151
src/db.js
Normal file
151
src/db.js
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { DatabaseSync } = require("node:sqlite");
|
||||||
|
const config = require("./config");
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||||
|
|
||||||
|
const db = new DatabaseSync(config.dbPath);
|
||||||
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT,
|
||||||
|
type TEXT,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
telemetry_json TEXT NOT NULL,
|
||||||
|
telemetry_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
interval_ms INTEGER NOT NULL DEFAULT 5000,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
published_keys_json TEXT,
|
||||||
|
plant_json TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deviceColumns = db.prepare("PRAGMA table_info(devices)").all().map((row) => row.name);
|
||||||
|
if (!deviceColumns.includes("published_keys_json")) {
|
||||||
|
db.exec("ALTER TABLE devices ADD COLUMN published_keys_json TEXT");
|
||||||
|
}
|
||||||
|
if (!deviceColumns.includes("plant_json")) {
|
||||||
|
db.exec("ALTER TABLE devices ADD COLUMN plant_json TEXT");
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertDevice = db.prepare(`
|
||||||
|
INSERT INTO devices (
|
||||||
|
id, name, label, type, access_token, telemetry_json,
|
||||||
|
telemetry_enabled, disabled, interval_ms, created_at, published_keys_json, plant_json
|
||||||
|
) VALUES (
|
||||||
|
@id, @name, @label, @type, @accessToken, @telemetryJson,
|
||||||
|
@telemetryEnabled, @disabled, @intervalMs, @createdAt, @publishedKeysJson, @plantJson
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateTelemetry = db.prepare(`
|
||||||
|
UPDATE devices
|
||||||
|
SET telemetry_json = @telemetryJson
|
||||||
|
WHERE id = @id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updatePublishedKeys = db.prepare(`
|
||||||
|
UPDATE devices
|
||||||
|
SET published_keys_json = @publishedKeysJson
|
||||||
|
WHERE id = @id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updatePlant = db.prepare(`
|
||||||
|
UPDATE devices
|
||||||
|
SET plant_json = @plantJson
|
||||||
|
WHERE id = @id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateFlags = db.prepare(`
|
||||||
|
UPDATE devices
|
||||||
|
SET telemetry_enabled = @telemetryEnabled,
|
||||||
|
disabled = @disabled,
|
||||||
|
interval_ms = @intervalMs
|
||||||
|
WHERE id = @id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteDevice = db.prepare(`DELETE FROM devices WHERE id = @id`);
|
||||||
|
const getDevice = db.prepare(`SELECT * FROM devices WHERE id = @id`);
|
||||||
|
const getDeviceByName = db.prepare(`SELECT * FROM devices WHERE name = @name`);
|
||||||
|
const listDevices = db.prepare(`SELECT * FROM devices ORDER BY created_at DESC`);
|
||||||
|
|
||||||
|
function parseJson(raw, fallback = null) {
|
||||||
|
if (raw == null || raw === "") return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToDevice(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
label: row.label,
|
||||||
|
type: row.type,
|
||||||
|
accessToken: row.access_token,
|
||||||
|
telemetry: JSON.parse(row.telemetry_json),
|
||||||
|
publishedKeys: parseJson(row.published_keys_json, null),
|
||||||
|
plant: parseJson(row.plant_json, null),
|
||||||
|
telemetryEnabled: Boolean(row.telemetry_enabled),
|
||||||
|
disabled: Boolean(row.disabled),
|
||||||
|
intervalMs: row.interval_ms,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
insertDevice(device) {
|
||||||
|
insertDevice.run({
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
label: device.label || null,
|
||||||
|
type: device.type || null,
|
||||||
|
accessToken: device.accessToken,
|
||||||
|
telemetryJson: JSON.stringify(device.telemetry),
|
||||||
|
publishedKeysJson: JSON.stringify(device.publishedKeys || null),
|
||||||
|
plantJson: JSON.stringify(device.plant || null),
|
||||||
|
telemetryEnabled: device.telemetryEnabled ? 1 : 0,
|
||||||
|
disabled: device.disabled ? 1 : 0,
|
||||||
|
intervalMs: device.intervalMs,
|
||||||
|
createdAt: device.createdAt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
saveTelemetry(id, telemetry) {
|
||||||
|
updateTelemetry.run({ id, telemetryJson: JSON.stringify(telemetry) });
|
||||||
|
},
|
||||||
|
savePublishedKeys(id, publishedKeys) {
|
||||||
|
updatePublishedKeys.run({ id, publishedKeysJson: JSON.stringify(publishedKeys || []) });
|
||||||
|
},
|
||||||
|
savePlant(id, plant) {
|
||||||
|
updatePlant.run({ id, plantJson: JSON.stringify(plant || {}) });
|
||||||
|
},
|
||||||
|
saveFlags(id, { telemetryEnabled, disabled, intervalMs }) {
|
||||||
|
updateFlags.run({
|
||||||
|
id,
|
||||||
|
telemetryEnabled: telemetryEnabled ? 1 : 0,
|
||||||
|
disabled: disabled ? 1 : 0,
|
||||||
|
intervalMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeDevice(id) {
|
||||||
|
deleteDevice.run({ id });
|
||||||
|
},
|
||||||
|
getDevice(id) {
|
||||||
|
return rowToDevice(getDevice.get({ id }));
|
||||||
|
},
|
||||||
|
getDeviceByName(name) {
|
||||||
|
return rowToDevice(getDeviceByName.get({ name }));
|
||||||
|
},
|
||||||
|
listDevices() {
|
||||||
|
return listDevices.all().map(rowToDevice);
|
||||||
|
},
|
||||||
|
};
|
||||||
297
src/deviceManager.js
Normal file
297
src/deviceManager.js
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const db = require("./db");
|
||||||
|
const thingsboard = require("./thingsboard");
|
||||||
|
const { VirtualDevice } = require("./virtualDevice");
|
||||||
|
const { defaultTelemetry, defaultPublishedKeys, normalizeTelemetry, telemetrySchema } = require("./telemetry");
|
||||||
|
const { defaultPlant } = require("./plant");
|
||||||
|
const config = require("./config");
|
||||||
|
|
||||||
|
const devices = new Map();
|
||||||
|
|
||||||
|
function persistFlags(device) {
|
||||||
|
db.saveFlags(device.id, {
|
||||||
|
telemetryEnabled: device.telemetryEnabled,
|
||||||
|
disabled: device.disabled,
|
||||||
|
intervalMs: device.intervalMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPublishedKeys(device) {
|
||||||
|
db.savePublishedKeys(device.id, device.publishedKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPlant(device) {
|
||||||
|
db.savePlant(device.id, device.plant);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistTelemetry(device) {
|
||||||
|
db.saveTelemetry(device.id, device.telemetry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDevice(device) {
|
||||||
|
device.onTelemetryChange = () => persistTelemetry(device);
|
||||||
|
device.onPlantChange = () => persistPlant(device);
|
||||||
|
device.onSharedWrite = (attrs) => {
|
||||||
|
thingsboard.writeSharedAttributes(device.id, attrs).catch((error) => {
|
||||||
|
device.lastError = error.message;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
devices.set(device.id, device);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrThrow(id) {
|
||||||
|
const device = devices.get(id);
|
||||||
|
if (!device) {
|
||||||
|
const error = new Error("Device not found");
|
||||||
|
error.status = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
return [...devices.values()]
|
||||||
|
.map((device) => device.status())
|
||||||
|
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerLocalDevice(tbDevice) {
|
||||||
|
if (devices.has(tbDevice.id) || db.getDeviceByName(tbDevice.name)) {
|
||||||
|
const existing = devices.get(tbDevice.id) || [...devices.values()].find((item) => item.name === tbDevice.name);
|
||||||
|
if (existing) return existing.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = new VirtualDevice({
|
||||||
|
id: tbDevice.id,
|
||||||
|
name: tbDevice.name,
|
||||||
|
label: tbDevice.label,
|
||||||
|
type: tbDevice.type,
|
||||||
|
accessToken: tbDevice.accessToken,
|
||||||
|
telemetry: defaultTelemetry(),
|
||||||
|
publishedKeys: defaultPublishedKeys(),
|
||||||
|
plant: defaultPlant(),
|
||||||
|
intervalMs: config.telemetryIntervalMs,
|
||||||
|
telemetryEnabled: false,
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
device.createdAt = Date.now();
|
||||||
|
|
||||||
|
db.insertDevice({
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
label: device.label,
|
||||||
|
type: device.type,
|
||||||
|
accessToken: device.accessToken,
|
||||||
|
telemetry: device.telemetry,
|
||||||
|
publishedKeys: device.publishedKeys,
|
||||||
|
plant: device.plant,
|
||||||
|
telemetryEnabled: false,
|
||||||
|
disabled: true,
|
||||||
|
intervalMs: device.intervalMs,
|
||||||
|
createdAt: device.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
bindDevice(device);
|
||||||
|
return device.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create({ name, label, type, deviceProfileId, accessToken, id }) {
|
||||||
|
const trimmed = String(name || "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
const error = new Error("Device name is required");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const already = db.getDeviceByName(trimmed);
|
||||||
|
if (already) {
|
||||||
|
const error = new Error(`Emulator already has a device named "${trimmed}"`);
|
||||||
|
error.status = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingToken = String(accessToken || "").trim();
|
||||||
|
const tbDevice = existingToken
|
||||||
|
? {
|
||||||
|
id: String(id || "").trim() || crypto.randomUUID(),
|
||||||
|
name: trimmed,
|
||||||
|
label: label || trimmed,
|
||||||
|
type: type || config.tbDeviceType,
|
||||||
|
accessToken: existingToken,
|
||||||
|
}
|
||||||
|
: await thingsboard.createDevice({
|
||||||
|
name: trimmed,
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
deviceProfileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return registerLocalDevice(tbDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adopt({ name, label, type }) {
|
||||||
|
const trimmed = String(name || "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
const error = new Error("Device name is required");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const already = db.getDeviceByName(trimmed);
|
||||||
|
if (already) {
|
||||||
|
const live = devices.get(already.id);
|
||||||
|
return live ? live.status() : already;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tbDevice = await thingsboard.adoptDeviceByName(trimmed);
|
||||||
|
if (label) tbDevice.label = label;
|
||||||
|
if (type) tbDevice.type = type;
|
||||||
|
return registerLocalDevice(tbDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
const device = getOrThrow(id);
|
||||||
|
devices.delete(id);
|
||||||
|
db.removeDevice(id);
|
||||||
|
try {
|
||||||
|
device.disconnect({ disabled: true });
|
||||||
|
} catch {
|
||||||
|
// MQTT teardown must not block emulator cleanup.
|
||||||
|
}
|
||||||
|
thingsboard.deleteDevice(id).catch((error) => {
|
||||||
|
console.warn(`ThingsBoard delete for ${id}: ${error.message}`);
|
||||||
|
});
|
||||||
|
return { ok: true, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMany(ids) {
|
||||||
|
const results = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
results.push(await remove(id));
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ ok: false, id, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPublishedKeys(id, keys) {
|
||||||
|
if (!Array.isArray(keys)) {
|
||||||
|
const error = new Error("keys[] is required");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const device = getOrThrow(id);
|
||||||
|
device.setPublishedKeys(keys);
|
||||||
|
persistPublishedKeys(device);
|
||||||
|
if (device.connected) device.publishTelemetry();
|
||||||
|
return device.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTelemetry(id, partial) {
|
||||||
|
const device = getOrThrow(id);
|
||||||
|
device.setTelemetry(partial);
|
||||||
|
persistTelemetry(device);
|
||||||
|
if (device.connected) device.publishTelemetry();
|
||||||
|
return device.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTelemetry(id) {
|
||||||
|
const device = getOrThrow(id);
|
||||||
|
device.startTelemetry();
|
||||||
|
persistFlags(device);
|
||||||
|
return device.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTelemetry(id) {
|
||||||
|
const device = getOrThrow(id);
|
||||||
|
device.stopTelemetry();
|
||||||
|
persistFlags(device);
|
||||||
|
return device.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable(id) {
|
||||||
|
const device = getOrThrow(id);
|
||||||
|
device.disconnect({ disabled: true });
|
||||||
|
persistFlags(device);
|
||||||
|
return device.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enable(id) {
|
||||||
|
const device = getOrThrow(id);
|
||||||
|
device.enable();
|
||||||
|
persistFlags(device);
|
||||||
|
return device.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIntervalMs(id, intervalMs) {
|
||||||
|
const device = getOrThrow(id);
|
||||||
|
device.setIntervalMs(intervalMs);
|
||||||
|
persistFlags(device);
|
||||||
|
return device.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlant(id, partial) {
|
||||||
|
const device = getOrThrow(id);
|
||||||
|
device.setPlant(partial || {});
|
||||||
|
persistPlant(device);
|
||||||
|
return device.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMany(ids, action) {
|
||||||
|
return ids.map((id) => {
|
||||||
|
try {
|
||||||
|
return { ok: true, device: action(id) };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, id, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
for (const row of db.listDevices()) {
|
||||||
|
const device = new VirtualDevice({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
label: row.label,
|
||||||
|
type: row.type,
|
||||||
|
accessToken: row.accessToken,
|
||||||
|
telemetry: normalizeTelemetry(row.telemetry),
|
||||||
|
publishedKeys: row.publishedKeys,
|
||||||
|
plant: row.plant,
|
||||||
|
intervalMs: row.intervalMs,
|
||||||
|
telemetryEnabled: row.telemetryEnabled,
|
||||||
|
disabled: row.disabled,
|
||||||
|
});
|
||||||
|
device.createdAt = row.createdAt;
|
||||||
|
bindDevice(device);
|
||||||
|
if (!device.disabled) device.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
for (const device of devices.values()) {
|
||||||
|
device.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
list,
|
||||||
|
create,
|
||||||
|
adopt,
|
||||||
|
remove,
|
||||||
|
removeMany,
|
||||||
|
updateTelemetry,
|
||||||
|
setPublishedKeys,
|
||||||
|
startTelemetry,
|
||||||
|
stopTelemetry,
|
||||||
|
disable,
|
||||||
|
enable,
|
||||||
|
setIntervalMs,
|
||||||
|
setPlant,
|
||||||
|
applyMany,
|
||||||
|
restore,
|
||||||
|
shutdown,
|
||||||
|
telemetrySchema,
|
||||||
|
};
|
||||||
38
src/index.js
Normal file
38
src/index.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const express = require("express");
|
||||||
|
const config = require("./config");
|
||||||
|
const { basicAuth, ipAllowlist } = require("./auth");
|
||||||
|
const deviceManager = require("./deviceManager");
|
||||||
|
const routes = require("./routes");
|
||||||
|
|
||||||
|
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(express.static(path.join(__dirname, "..", "public")));
|
||||||
|
|
||||||
|
app.get("*", (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, "..", "public", "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
deviceManager.restore();
|
||||||
|
|
||||||
|
const server = app.listen(config.port, () => {
|
||||||
|
console.log(`Diverter device emulator listening on http://localhost:${config.port}`);
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(signal) {
|
||||||
|
console.log(`Received ${signal}, closing emulator`);
|
||||||
|
deviceManager.shutdown();
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 3000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
190
src/plant.js
Normal file
190
src/plant.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
const SHARED_KEYS = [
|
||||||
|
"boostNowHw",
|
||||||
|
"boostNowAux",
|
||||||
|
"hwRlyMode",
|
||||||
|
"hwRlySchedMode0StartTime",
|
||||||
|
"hwRlySchedMode0StopTime",
|
||||||
|
"hwRlySchedMode1StartTime",
|
||||||
|
"hwRlySchedMode1StopTime",
|
||||||
|
"auxRlyMode",
|
||||||
|
"auxRlySchedMode0Start",
|
||||||
|
"auxRlySchedMode0Stop",
|
||||||
|
"auxRlySchedMode1Start",
|
||||||
|
"auxRlySchedMode1Stop",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_PLANT = {
|
||||||
|
heatRateCpm: 1,
|
||||||
|
thermostatC: 65,
|
||||||
|
hwBoostUntil: null,
|
||||||
|
auxBoostUntil: null,
|
||||||
|
lastBoostNowHw: null,
|
||||||
|
lastBoostNowAux: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultPlant() {
|
||||||
|
return { ...DEFAULT_PLANT };
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapAttr(value) {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value) && "value" in value) {
|
||||||
|
return value.value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(value, fallback = 0) {
|
||||||
|
const raw = unwrapAttr(value);
|
||||||
|
const parsed = typeof raw === "number" ? raw : Number(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundTenths(value) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) return 0;
|
||||||
|
return Math.round(numeric * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesSinceMidnight(date = new Date()) {
|
||||||
|
return date.getHours() * 60 + date.getMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function inScheduleWindow(start, stop, nowMinutes) {
|
||||||
|
const from = num(start, 0);
|
||||||
|
const to = num(stop, 0);
|
||||||
|
if (from === to) return false;
|
||||||
|
if (from < to) return nowMinutes >= from && nowMinutes < to;
|
||||||
|
return nowMinutes >= from || nowMinutes < to;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleModeOn(mode) {
|
||||||
|
const value = num(mode, 0);
|
||||||
|
return value === 2 || value === 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hwScheduleActive(attrs, nowMinutes) {
|
||||||
|
if (!scheduleModeOn(attrs.hwRlyMode)) return false;
|
||||||
|
return (
|
||||||
|
inScheduleWindow(attrs.hwRlySchedMode0StartTime, attrs.hwRlySchedMode0StopTime, nowMinutes) ||
|
||||||
|
inScheduleWindow(attrs.hwRlySchedMode1StartTime, attrs.hwRlySchedMode1StopTime, nowMinutes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function auxScheduleActive(attrs, nowMinutes) {
|
||||||
|
if (!scheduleModeOn(attrs.auxRlyMode)) return false;
|
||||||
|
return (
|
||||||
|
inScheduleWindow(attrs.auxRlySchedMode0Start, attrs.auxRlySchedMode0Stop, nowMinutes) ||
|
||||||
|
inScheduleWindow(attrs.auxRlySchedMode1Start, attrs.auxRlySchedMode1Stop, nowMinutes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOneShot(plant, key, untilKey, lastKey, minutes) {
|
||||||
|
const mins = Math.max(0, num(minutes, 0));
|
||||||
|
if (plant[lastKey] === mins) return plant;
|
||||||
|
return {
|
||||||
|
...plant,
|
||||||
|
[lastKey]: mins,
|
||||||
|
[untilKey]: mins > 0 ? Date.now() + mins * 60_000 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlant(input) {
|
||||||
|
const next = defaultPlant();
|
||||||
|
if (!input || typeof input !== "object") return next;
|
||||||
|
const heat = Math.trunc(num(input.heatRateCpm ?? input.heatRateCps, next.heatRateCpm));
|
||||||
|
const thermo = num(input.thermostatC, next.thermostatC);
|
||||||
|
next.heatRateCpm = Math.max(0, Math.min(20, heat));
|
||||||
|
next.thermostatC = Math.max(0, Math.min(99, thermo));
|
||||||
|
next.hwBoostUntil = input.hwBoostUntil || null;
|
||||||
|
next.auxBoostUntil = input.auxBoostUntil || null;
|
||||||
|
next.lastBoostNowHw = input.lastBoostNowHw ?? null;
|
||||||
|
next.lastBoostNowAux = input.lastBoostNowAux ?? null;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HW_HEAT_W = 800;
|
||||||
|
|
||||||
|
function applyPlantOutputs(telemetry, { hwOn }) {
|
||||||
|
const next = { ...telemetry };
|
||||||
|
if (hwOn) {
|
||||||
|
if (!(Number(next.hotWaterInPower_s) > 0)) next.hotWaterInPower_s = HW_HEAT_W;
|
||||||
|
if (!(Number(next.solarToHotWaterPower_s) > 0)) {
|
||||||
|
next.solarToHotWaterPower_s = Number(next.hotWaterInPower_s) || HW_HEAT_W;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.hotWaterInPower_s = 0;
|
||||||
|
next.solarToHotWaterPower_s = 0;
|
||||||
|
next.gridToHotWaterPower_s = 0;
|
||||||
|
next.batteryToHotWaterPower_s = 0;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingMs(until, now) {
|
||||||
|
if (!until) return 0;
|
||||||
|
return Math.max(0, until - now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeChannel({ on, oneShot, schedule, cutout, remaining }) {
|
||||||
|
if (cutout) return "Thermostat cutout";
|
||||||
|
if (!on) return "Off";
|
||||||
|
const parts = [];
|
||||||
|
if (oneShot) {
|
||||||
|
const mins = Math.max(1, Math.ceil(remaining / 60_000));
|
||||||
|
parts.push(`One-shot · ${mins} min left`);
|
||||||
|
}
|
||||||
|
if (schedule) parts.push("Schedule window");
|
||||||
|
return parts.join(" · ") || "On";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 hwSchedule = hwScheduleActive(attrs, nowMinutes);
|
||||||
|
const auxSchedule = auxScheduleActive(attrs, nowMinutes);
|
||||||
|
const cutout = tankC >= plant.thermostatC;
|
||||||
|
const hwWanted = hwOneShot || hwSchedule;
|
||||||
|
const auxOn = auxOneShot || auxSchedule;
|
||||||
|
const hwOn = hwWanted && !cutout;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hwOn,
|
||||||
|
auxOn,
|
||||||
|
hwOneShot,
|
||||||
|
auxOneShot,
|
||||||
|
hwSchedule,
|
||||||
|
auxSchedule,
|
||||||
|
cutout,
|
||||||
|
hwWanted,
|
||||||
|
hwRemainingMs: remainingMs(plant.hwBoostUntil, now),
|
||||||
|
auxRemainingMs: remainingMs(plant.auxBoostUntil, now),
|
||||||
|
hwDetail: describeChannel({
|
||||||
|
on: hwOn,
|
||||||
|
oneShot: hwOneShot,
|
||||||
|
schedule: hwSchedule,
|
||||||
|
cutout: cutout && hwWanted,
|
||||||
|
remaining: remainingMs(plant.hwBoostUntil, now),
|
||||||
|
}),
|
||||||
|
auxDetail: describeChannel({
|
||||||
|
on: auxOn,
|
||||||
|
oneShot: auxOneShot,
|
||||||
|
schedule: auxSchedule,
|
||||||
|
cutout: false,
|
||||||
|
remaining: remainingMs(plant.auxBoostUntil, now),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SHARED_KEYS,
|
||||||
|
defaultPlant,
|
||||||
|
normalizePlant,
|
||||||
|
unwrapAttr,
|
||||||
|
num,
|
||||||
|
roundTenths,
|
||||||
|
minutesSinceMidnight,
|
||||||
|
inScheduleWindow,
|
||||||
|
applyOneShot,
|
||||||
|
applyPlantOutputs,
|
||||||
|
evaluatePlant,
|
||||||
|
};
|
||||||
144
src/routes.js
Normal file
144
src/routes.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const deviceManager = require("./deviceManager");
|
||||||
|
const thingsboard = require("./thingsboard");
|
||||||
|
const config = require("./config");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function asyncHandler(fn) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
try {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function idsFromBody(body) {
|
||||||
|
const ids = body?.ids;
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
const error = new Error("ids[] is required");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return ids.map(String);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/health", asyncHandler(async (_req, res) => {
|
||||||
|
const tb = await thingsboard.ping();
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
mqttUrl: config.tbMqttUrl,
|
||||||
|
thingsboard: tb,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get("/telemetry-schema", (_req, res) => {
|
||||||
|
res.json(deviceManager.telemetrySchema());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/device-profiles", asyncHandler(async (_req, res) => {
|
||||||
|
const profiles = await thingsboard.listDeviceProfiles();
|
||||||
|
const preferred = profiles.find((profile) => profile.preferred);
|
||||||
|
res.json({
|
||||||
|
profiles,
|
||||||
|
defaultProfileId: preferred?.id || config.tbDeviceProfileId || null,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get("/devices", (_req, res) => {
|
||||||
|
res.json({ devices: deviceManager.list() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/devices", asyncHandler(async (req, res) => {
|
||||||
|
const device = await deviceManager.create({
|
||||||
|
name: req.body?.name,
|
||||||
|
label: req.body?.label,
|
||||||
|
type: req.body?.type,
|
||||||
|
deviceProfileId: req.body?.deviceProfileId,
|
||||||
|
accessToken: req.body?.accessToken,
|
||||||
|
id: req.body?.id,
|
||||||
|
});
|
||||||
|
res.status(201).json({ device });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/adopt", asyncHandler(async (req, res) => {
|
||||||
|
const device = await deviceManager.adopt({
|
||||||
|
name: req.body?.name,
|
||||||
|
label: req.body?.label,
|
||||||
|
type: req.body?.type,
|
||||||
|
});
|
||||||
|
res.status(201).json({ device });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.delete("/devices/:id", asyncHandler(async (req, res) => {
|
||||||
|
const result = await deviceManager.remove(req.params.id);
|
||||||
|
res.json(result);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/delete", asyncHandler(async (req, res) => {
|
||||||
|
const results = await deviceManager.removeMany(idsFromBody(req.body));
|
||||||
|
res.json({ results });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.patch("/devices/:id/published-keys", asyncHandler(async (req, res) => {
|
||||||
|
const device = deviceManager.setPublishedKeys(req.params.id, req.body?.keys);
|
||||||
|
res.json({ device });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.patch("/devices/:id/telemetry", asyncHandler(async (req, res) => {
|
||||||
|
const device = deviceManager.updateTelemetry(req.params.id, req.body || {});
|
||||||
|
res.json({ device });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/:id/telemetry/start", asyncHandler(async (req, res) => {
|
||||||
|
res.json({ device: deviceManager.startTelemetry(req.params.id) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/:id/telemetry/stop", asyncHandler(async (req, res) => {
|
||||||
|
res.json({ device: deviceManager.stopTelemetry(req.params.id) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/:id/telemetry/publish", asyncHandler(async (req, res) => {
|
||||||
|
const device = deviceManager.updateTelemetry(req.params.id, req.body || {});
|
||||||
|
res.json({ device });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/:id/interval", asyncHandler(async (req, res) => {
|
||||||
|
res.json({ device: deviceManager.setIntervalMs(req.params.id, req.body?.intervalMs) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.patch("/devices/:id/plant", asyncHandler(async (req, res) => {
|
||||||
|
res.json({ device: deviceManager.setPlant(req.params.id, req.body || {}) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/:id/disable", asyncHandler(async (req, res) => {
|
||||||
|
res.json({ device: deviceManager.disable(req.params.id) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/:id/enable", asyncHandler(async (req, res) => {
|
||||||
|
res.json({ device: deviceManager.enable(req.params.id) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/disable", asyncHandler(async (req, res) => {
|
||||||
|
res.json({ results: deviceManager.applyMany(idsFromBody(req.body), deviceManager.disable) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/devices/enable", asyncHandler(async (req, res) => {
|
||||||
|
res.json({ results: deviceManager.applyMany(idsFromBody(req.body), deviceManager.enable) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.use((_req, res) => {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use((error, _req, res, _next) => {
|
||||||
|
const status = error.status || 500;
|
||||||
|
res.status(status).json({
|
||||||
|
error: error.message || "Unexpected error",
|
||||||
|
timedOut: Boolean(error.timedOut),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
123
src/telemetry.js
Normal file
123
src/telemetry.js
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
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: "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" },
|
||||||
|
{ key: "gridPower_s", label: "Grid power", unit: "W", group: "power", type: "number", default: -200, hint: "Positive = importing, negative = exporting" },
|
||||||
|
|
||||||
|
{ key: "solarToLoadPower_s", label: "Solar → load", unit: "W", group: "flows", type: "number", default: 1500 },
|
||||||
|
{ key: "solarToHotWaterPower_s", label: "Solar → hot water", unit: "W", group: "flows", type: "number", default: 800 },
|
||||||
|
{ key: "solarToBatteryPower_s", label: "Solar → battery", unit: "W", group: "flows", type: "number", default: 700 },
|
||||||
|
{ key: "solarToGridPower_s", label: "Solar → grid", unit: "W", group: "flows", type: "number", default: 200 },
|
||||||
|
{ key: "gridToLoadPower_s", label: "Grid → load", unit: "W", group: "flows", type: "number", default: 0 },
|
||||||
|
{ key: "gridToHotWaterPower_s", label: "Grid → hot water", unit: "W", group: "flows", type: "number", default: 0 },
|
||||||
|
{ key: "gridToBatteryPower_s", label: "Grid → battery", unit: "W", group: "flows", type: "number", default: 0 },
|
||||||
|
{ key: "batteryToLoadPower_s", label: "Battery → load", unit: "W", group: "flows", type: "number", default: 0 },
|
||||||
|
{ key: "batteryToHotWaterPower_s", label: "Battery → hot water", unit: "W", group: "flows", type: "number", default: 0 },
|
||||||
|
{ key: "batteryToGridPower_s", label: "Battery → grid", unit: "W", group: "flows", type: "number", default: 0 },
|
||||||
|
|
||||||
|
{ key: "vPwrReadings_s", label: "Valid power readings mask", unit: "bitmask", group: "validity", type: "integer", default: 65535, hint: "65535 = all live power keys valid (bits 0–15)" },
|
||||||
|
|
||||||
|
{ key: "solarToday_s", label: "Solar today", unit: "Wh", group: "energy", type: "number", default: 12500 },
|
||||||
|
{ key: "loadToday_s", label: "Load today", unit: "Wh", group: "energy", type: "number", default: 8200 },
|
||||||
|
{ key: "hotWaterInToday_s", label: "Hot water in today", unit: "Wh", group: "energy", type: "number", default: 4100 },
|
||||||
|
{ key: "hotWaterOutToday_s", label: "Hot water out today", unit: "Wh", group: "energy", type: "number", default: 0 },
|
||||||
|
{ key: "solarToLoadToday_s", label: "Solar → load today", unit: "Wh", group: "energy", type: "number", default: 6200 },
|
||||||
|
{ key: "solarToHotWaterToday_s", label: "Solar → hot water today", unit: "Wh", group: "energy", type: "number", default: 3100 },
|
||||||
|
{ key: "solarToGridToday_s", label: "Solar → grid today", unit: "Wh", group: "energy", type: "number", default: 1400 },
|
||||||
|
{ key: "solarToBatteryToday_s", label: "Solar → battery today", unit: "Wh", group: "energy", type: "number", default: 1800 },
|
||||||
|
{ key: "gridToLoadToday_s", label: "Grid → load today", unit: "Wh", group: "energy", type: "number", default: 900 },
|
||||||
|
{ key: "gridToHotWaterToday_s", label: "Grid → hot water today", unit: "Wh", group: "energy", type: "number", default: 200 },
|
||||||
|
{ key: "gridToBatteryToday_s", label: "Grid → battery today", unit: "Wh", group: "energy", type: "number", default: 0 },
|
||||||
|
{ key: "batteryToLoadToday_s", label: "Battery → load today", unit: "Wh", group: "energy", type: "number", default: 1100 },
|
||||||
|
{ key: "batteryToHotwaterToday_s", label: "Battery → hot water today", unit: "Wh", group: "energy", type: "number", default: 800 },
|
||||||
|
{ key: "batteryToGridToday_s", label: "Battery → grid today", unit: "Wh", group: "energy", type: "number", default: 0 },
|
||||||
|
{ key: "batteryExportToday_s", label: "Battery export today", unit: "Wh", group: "energy", type: "number", default: 0 },
|
||||||
|
{ key: "batteryImportToday_s", label: "Battery import today", unit: "Wh", group: "energy", type: "number", default: 1800 },
|
||||||
|
{ key: "gridImport_s", label: "Grid import today", unit: "Wh", group: "energy", type: "number", default: 1100 },
|
||||||
|
{ key: "gridExport_s", label: "Grid export today", unit: "Wh", group: "energy", type: "number", default: 1400 },
|
||||||
|
|
||||||
|
{ key: "heatsink_s", label: "Hot water / tank temp", unit: "°C", group: "status", type: "number", default: 54.2, step: 0.1 },
|
||||||
|
{ key: "batterySoc_s", label: "Battery SoC", unit: "%", group: "status", type: "number", default: 64, min: 0, max: 100 },
|
||||||
|
{ key: "soc_s", label: "SoC (fallback)", unit: "%", group: "status", type: "number", default: 64, min: 0, max: 100 },
|
||||||
|
{ key: "batteryPercent_s", label: "Battery percent (fallback)", unit: "%", group: "status", type: "number", default: 64, min: 0, max: 100 },
|
||||||
|
{ key: "auxOn_s", label: "Aux on", unit: "", group: "status", type: "boolean", default: 0 },
|
||||||
|
{ key: "auxRelay_s", label: "Aux relay", unit: "", group: "status", type: "boolean", default: 0 },
|
||||||
|
{ key: "rlyInSel_s", label: "Control load heating", unit: "", group: "status", type: "boolean", default: 0 },
|
||||||
|
{ key: "xCntlLoadState", label: "Control load state", unit: "", group: "status", type: "integer", default: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUPS = [
|
||||||
|
{ id: "status", title: "Status", subtitle: "Tank temp, battery SoC, aux / control-load flags" },
|
||||||
|
{ id: "power", title: "Power", subtitle: "Instantaneous watts published as *_s" },
|
||||||
|
{ id: "flows", title: "Energy flows", subtitle: "Node-to-node power used by the live flow UI" },
|
||||||
|
{ id: "energy", title: "Energy today", subtitle: "Watt-hours for daily totals" },
|
||||||
|
{ id: "validity", title: "Validity", subtitle: "vPwrReadings_s bitmask for live power keys" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function telemetryKeys() {
|
||||||
|
return TELEMETRY_FIELDS.map((field) => field.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTelemetry() {
|
||||||
|
const values = {};
|
||||||
|
for (const field of TELEMETRY_FIELDS) {
|
||||||
|
values[field.key] = field.default;
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPublishedKeys() {
|
||||||
|
return telemetryKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublishedKeys(input) {
|
||||||
|
const known = telemetryKeys();
|
||||||
|
if (input == null) return defaultPublishedKeys();
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
const enabled = new Set(input.map(String));
|
||||||
|
return known.filter((key) => enabled.has(key));
|
||||||
|
}
|
||||||
|
if (typeof input === "object") {
|
||||||
|
return known.filter((key) => Boolean(input[key]));
|
||||||
|
}
|
||||||
|
return defaultPublishedKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTelemetry(input) {
|
||||||
|
const next = defaultTelemetry();
|
||||||
|
if (!input || typeof input !== "object") return next;
|
||||||
|
|
||||||
|
for (const field of TELEMETRY_FIELDS) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(input, field.key)) continue;
|
||||||
|
const raw = input[field.key];
|
||||||
|
if (field.type === "boolean") {
|
||||||
|
next[field.key] = raw === true || raw === 1 || raw === "1" || raw === "true" || raw === "on" ? 1 : 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const numeric = typeof raw === "number" ? raw : Number(raw);
|
||||||
|
if (!Number.isFinite(numeric)) continue;
|
||||||
|
if (field.type === "integer") {
|
||||||
|
next[field.key] = Math.trunc(numeric);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[field.key] = field.key === "heatsink_s" ? Math.round(numeric * 10) / 10 : numeric;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function telemetrySchema() {
|
||||||
|
return { groups: GROUPS, fields: TELEMETRY_FIELDS };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TELEMETRY_FIELDS,
|
||||||
|
GROUPS,
|
||||||
|
telemetryKeys,
|
||||||
|
defaultTelemetry,
|
||||||
|
defaultPublishedKeys,
|
||||||
|
normalizeTelemetry,
|
||||||
|
normalizePublishedKeys,
|
||||||
|
telemetrySchema,
|
||||||
|
};
|
||||||
262
src/thingsboard.js
Normal file
262
src/thingsboard.js
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
const config = require("./config");
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
baseURL: config.tbHttpUrl,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cachedToken = null;
|
||||||
|
let tokenExpiresAt = 0;
|
||||||
|
|
||||||
|
function requireTbCredentials() {
|
||||||
|
if (!config.tbUsername || !config.tbPassword) {
|
||||||
|
const error = new Error("TB_USERNAME and TB_PASSWORD must be set in .env");
|
||||||
|
error.status = 500;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(force = false) {
|
||||||
|
requireTbCredentials();
|
||||||
|
if (!force && cachedToken && Date.now() < tokenExpiresAt - 60_000) {
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await http.post("/api/auth/login", {
|
||||||
|
username: config.tbUsername,
|
||||||
|
password: config.tbPassword,
|
||||||
|
});
|
||||||
|
cachedToken = data.token;
|
||||||
|
tokenExpiresAt = Date.now() + 2 * 60 * 60 * 1000;
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withAuth(request) {
|
||||||
|
const token = await login();
|
||||||
|
try {
|
||||||
|
return await request(token);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const retryToken = await login(true);
|
||||||
|
return request(retryToken);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(token) {
|
||||||
|
return { "X-Authorization": `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTimeoutError(error) {
|
||||||
|
if (!error) return false;
|
||||||
|
if (error.timedOut) return true;
|
||||||
|
const code = error.code || error.cause?.code;
|
||||||
|
if (code === "ECONNABORTED" || code === "ETIMEDOUT" || code === "ERR_CANCELED") return true;
|
||||||
|
return /timeout/i.test(String(error.message || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tbError(error, fallback) {
|
||||||
|
const message =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.error ||
|
||||||
|
error.message ||
|
||||||
|
fallback;
|
||||||
|
const wrapped = new Error(message);
|
||||||
|
wrapped.status = error.response?.status || error.status || 502;
|
||||||
|
wrapped.cause = error;
|
||||||
|
if (isTimeoutError(error)) {
|
||||||
|
wrapped.status = 504;
|
||||||
|
wrapped.timedOut = true;
|
||||||
|
wrapped.message = `${fallback}: ThingsBoard timed out. The record may still have been created.`;
|
||||||
|
}
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCredentials(token, deviceId) {
|
||||||
|
const { data: credentials } = await http.get(`/api/device/${deviceId}/credentials`, {
|
||||||
|
headers: authHeaders(token),
|
||||||
|
});
|
||||||
|
if (!credentials?.credentialsId) {
|
||||||
|
throw new Error("ThingsBoard did not return device credentials");
|
||||||
|
}
|
||||||
|
return credentials.credentialsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDeviceByName(name) {
|
||||||
|
try {
|
||||||
|
return await withAuth(async (token) => {
|
||||||
|
try {
|
||||||
|
const { data: device } = await http.get("/api/tenant/devices", {
|
||||||
|
headers: authHeaders(token),
|
||||||
|
params: { deviceName: name },
|
||||||
|
});
|
||||||
|
return device || null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw tbError(error, "Failed to look up ThingsBoard device");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDeviceCredentials(deviceId) {
|
||||||
|
try {
|
||||||
|
return await withAuth(async (token) => getCredentials(token, deviceId));
|
||||||
|
} catch (error) {
|
||||||
|
throw tbError(error, "Failed to fetch ThingsBoard device credentials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adoptDeviceByName(name, { attempts = 4, delayMs = 1500 } = {}) {
|
||||||
|
let lastError = null;
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const device = await findDeviceByName(name);
|
||||||
|
if (!device?.id?.id) {
|
||||||
|
lastError = new Error(`ThingsBoard has no device named "${name}" yet`);
|
||||||
|
lastError.status = 404;
|
||||||
|
if (attempt < attempts) await sleep(delayMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const accessToken = await getDeviceCredentials(device.id.id);
|
||||||
|
return {
|
||||||
|
id: device.id.id,
|
||||||
|
name: device.name || name,
|
||||||
|
label: device.label || name,
|
||||||
|
type: device.type || config.tbDeviceType,
|
||||||
|
accessToken,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (isTimeoutError(error) && attempt >= 2) break;
|
||||||
|
if (attempt < attempts) await sleep(delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError || tbError(new Error("lookup failed"), "Failed to adopt ThingsBoard device");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDevice({ name, label, type, deviceProfileId }) {
|
||||||
|
try {
|
||||||
|
return await withAuth(async (token) => {
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
label: label || name,
|
||||||
|
type: type || config.tbDeviceType,
|
||||||
|
};
|
||||||
|
const profileId = deviceProfileId || config.tbDeviceProfileId;
|
||||||
|
if (profileId) {
|
||||||
|
body.deviceProfileId = { entityType: "DEVICE_PROFILE", id: profileId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: device } = await http.post("/api/device", body, {
|
||||||
|
headers: authHeaders(token),
|
||||||
|
});
|
||||||
|
const deviceId = device.id?.id;
|
||||||
|
if (!deviceId) {
|
||||||
|
throw new Error("ThingsBoard did not return a device id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await getCredentials(token, deviceId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: deviceId,
|
||||||
|
name: device.name,
|
||||||
|
label: device.label || label || name,
|
||||||
|
type: device.type || body.type,
|
||||||
|
accessToken,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw tbError(error, "Failed to create ThingsBoard device");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDevice(deviceId) {
|
||||||
|
try {
|
||||||
|
await withAuth(async (token) => {
|
||||||
|
await http.delete(`/api/device/${deviceId}`, {
|
||||||
|
headers: authHeaders(token),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const message = String(error.response?.data?.message || error.message || "");
|
||||||
|
if (status === 404 || status === 400 || /not found/i.test(message)) return;
|
||||||
|
throw tbError(error, "Failed to delete ThingsBoard device");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIVERTER_PROFILE_NAME = /diverter|dvgo/i;
|
||||||
|
|
||||||
|
function preferredProfileId(profiles) {
|
||||||
|
const configured = config.tbDeviceProfileId;
|
||||||
|
if (configured && profiles.some((profile) => profile.id === configured)) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
const named = profiles.find((profile) => DIVERTER_PROFILE_NAME.test(profile.name || ""));
|
||||||
|
if (named) return named.id;
|
||||||
|
return configured || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDeviceProfiles() {
|
||||||
|
try {
|
||||||
|
return await withAuth(async (token) => {
|
||||||
|
const { data } = await http.get("/api/deviceProfiles", {
|
||||||
|
headers: authHeaders(token),
|
||||||
|
params: { pageSize: 100, page: 0 },
|
||||||
|
});
|
||||||
|
const profiles = (data.data || []).map((profile) => ({
|
||||||
|
id: profile.id?.id,
|
||||||
|
name: profile.name,
|
||||||
|
default: Boolean(profile.default),
|
||||||
|
}));
|
||||||
|
const preferredId = preferredProfileId(profiles);
|
||||||
|
return profiles.map((profile) => ({
|
||||||
|
...profile,
|
||||||
|
preferred: profile.id === preferredId,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw tbError(error, "Failed to list device profiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ping() {
|
||||||
|
try {
|
||||||
|
await login();
|
||||||
|
return { ok: true, host: config.tbHttpUrl };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, host: config.tbHttpUrl, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSharedAttributes(deviceId, attrs) {
|
||||||
|
try {
|
||||||
|
await withAuth(async (token) => {
|
||||||
|
await http.post(`/api/plugins/telemetry/${deviceId}/SHARED_SCOPE`, attrs, {
|
||||||
|
headers: { ...authHeaders(token), "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw tbError(error, "Failed to write ThingsBoard shared attributes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createDevice,
|
||||||
|
deleteDevice,
|
||||||
|
listDeviceProfiles,
|
||||||
|
findDeviceByName,
|
||||||
|
getDeviceCredentials,
|
||||||
|
adoptDeviceByName,
|
||||||
|
writeSharedAttributes,
|
||||||
|
ping,
|
||||||
|
};
|
||||||
420
src/virtualDevice.js
Normal file
420
src/virtualDevice.js
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
const mqtt = require("mqtt");
|
||||||
|
const config = require("./config");
|
||||||
|
const { normalizeTelemetry, normalizePublishedKeys } = require("./telemetry");
|
||||||
|
const {
|
||||||
|
SHARED_KEYS,
|
||||||
|
normalizePlant,
|
||||||
|
unwrapAttr,
|
||||||
|
applyOneShot,
|
||||||
|
applyPlantOutputs,
|
||||||
|
evaluatePlant,
|
||||||
|
roundTenths,
|
||||||
|
} = require("./plant");
|
||||||
|
|
||||||
|
class VirtualDevice {
|
||||||
|
constructor({ id, name, label, type, accessToken, telemetry, publishedKeys, plant, intervalMs, telemetryEnabled, disabled }) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.label = label;
|
||||||
|
this.type = type;
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.telemetry = normalizeTelemetry(telemetry);
|
||||||
|
this.publishedKeys = normalizePublishedKeys(publishedKeys);
|
||||||
|
this.plant = normalizePlant(plant);
|
||||||
|
this.intervalMs = intervalMs || config.telemetryIntervalMs;
|
||||||
|
this.telemetryEnabled = telemetryEnabled !== false;
|
||||||
|
this.disabled = Boolean(disabled);
|
||||||
|
this.connected = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.lastError = null;
|
||||||
|
this.lastConnectionResponse = null;
|
||||||
|
this.lastPublishedAt = null;
|
||||||
|
this.lastRpc = null;
|
||||||
|
this.sharedAttributes = {};
|
||||||
|
this.client = null;
|
||||||
|
this.timer = null;
|
||||||
|
this.plantTimer = null;
|
||||||
|
this.attrPollTimer = null;
|
||||||
|
this.attrRequestId = 1;
|
||||||
|
this.lastHeatAt = Date.now();
|
||||||
|
this.heatTankC = Number(this.telemetry.heatsink_s) || 0;
|
||||||
|
this.wantedOnline = !this.disabled;
|
||||||
|
this.onTelemetryChange = null;
|
||||||
|
this.onPlantChange = null;
|
||||||
|
this.onSharedWrite = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.disabled) return;
|
||||||
|
if (this.connected || this.connecting) return;
|
||||||
|
this.wantedOnline = true;
|
||||||
|
this.stopPlantLoop();
|
||||||
|
this.endClient();
|
||||||
|
|
||||||
|
this.connecting = true;
|
||||||
|
this.connected = false;
|
||||||
|
this.lastError = null;
|
||||||
|
this.lastConnectionResponse = "Waiting for CONNACK…";
|
||||||
|
|
||||||
|
this.client = mqtt.connect(config.tbMqttUrl, {
|
||||||
|
username: this.accessToken,
|
||||||
|
clientId: `emu-${this.id.slice(0, 8)}-${Date.now()}`,
|
||||||
|
keepalive: Math.max(10, Number(config.tbMqttKeepalive) || 30),
|
||||||
|
reconnectPeriod: Math.max(0, Number(config.tbMqttReconnectMs) || 3000),
|
||||||
|
connectTimeout: 15_000,
|
||||||
|
rejectUnauthorized: !config.tbMqttInsecure,
|
||||||
|
protocolVersion: 4,
|
||||||
|
clean: true,
|
||||||
|
resubscribe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("connect", () => {
|
||||||
|
this.connecting = false;
|
||||||
|
this.connected = true;
|
||||||
|
this.lastError = null;
|
||||||
|
this.lastConnectionResponse = "Connected";
|
||||||
|
this.client.subscribe("v1/devices/me/rpc/request/+");
|
||||||
|
this.client.subscribe("v1/devices/me/attributes");
|
||||||
|
this.client.subscribe("v1/devices/me/attributes/response/+");
|
||||||
|
this.requestSharedAttributes();
|
||||||
|
this.startPlantLoop();
|
||||||
|
if (this.telemetryEnabled) this.startTelemetry();
|
||||||
|
this.publishTelemetry();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("reconnect", () => {
|
||||||
|
this.connecting = true;
|
||||||
|
this.connected = false;
|
||||||
|
this.lastConnectionResponse = "Reconnecting…";
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("close", () => {
|
||||||
|
this.connected = false;
|
||||||
|
if (this.connecting) return;
|
||||||
|
if (this.wantedOnline) {
|
||||||
|
this.lastConnectionResponse = this.lastError || "Connection closed";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("error", (error) => {
|
||||||
|
this.connecting = false;
|
||||||
|
this.connected = false;
|
||||||
|
this.lastError = error.message;
|
||||||
|
this.lastConnectionResponse = error.message;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("offline", () => {
|
||||||
|
this.connected = false;
|
||||||
|
if (this.wantedOnline && !this.connecting) {
|
||||||
|
this.lastConnectionResponse = this.lastError || "Offline";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("message", (topic, payload) => {
|
||||||
|
this.handleMessage(topic, payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
endClient() {
|
||||||
|
if (!this.client) return;
|
||||||
|
const client = this.client;
|
||||||
|
this.client = null;
|
||||||
|
client.removeAllListeners();
|
||||||
|
client.end(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect({ disabled = false } = {}) {
|
||||||
|
this.wantedOnline = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.connected = false;
|
||||||
|
if (disabled) this.disabled = true;
|
||||||
|
this.lastConnectionResponse = "Disconnected";
|
||||||
|
this.stopTelemetry();
|
||||||
|
this.stopPlantLoop();
|
||||||
|
this.endClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
this.disabled = false;
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
startTelemetry() {
|
||||||
|
this.telemetryEnabled = true;
|
||||||
|
if (this.timer) return;
|
||||||
|
this.timer = setInterval(() => this.publishTelemetry(), this.intervalMs);
|
||||||
|
if (this.connected) this.publishTelemetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTelemetry() {
|
||||||
|
this.telemetryEnabled = false;
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIntervalMs(intervalMs) {
|
||||||
|
const next = Math.max(500, Number(intervalMs) || config.telemetryIntervalMs);
|
||||||
|
const wasRunning = this.telemetryEnabled;
|
||||||
|
this.intervalMs = next;
|
||||||
|
if (wasRunning) {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
this.telemetryEnabled = true;
|
||||||
|
this.startTelemetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTelemetry(partial) {
|
||||||
|
this.telemetry = normalizeTelemetry({ ...this.telemetry, ...partial });
|
||||||
|
if (partial && Object.prototype.hasOwnProperty.call(partial, "heatsink_s")) {
|
||||||
|
this.heatTankC = Number(this.telemetry.heatsink_s) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlant(partial) {
|
||||||
|
this.plant = normalizePlant({ ...this.plant, ...partial });
|
||||||
|
this.tickPlant();
|
||||||
|
if (this.onPlantChange) this.onPlantChange(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestSharedAttributes() {
|
||||||
|
if (!this.client || !this.connected) return;
|
||||||
|
const id = this.attrRequestId++;
|
||||||
|
this.client.publish(
|
||||||
|
`v1/devices/me/attributes/request/${id}`,
|
||||||
|
JSON.stringify({ sharedKeys: SHARED_KEYS.join(",") }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
startPlantLoop() {
|
||||||
|
this.stopPlantLoop();
|
||||||
|
this.lastHeatAt = Date.now();
|
||||||
|
this.plantTimer = setInterval(() => this.tickPlant(), 1000);
|
||||||
|
this.attrPollTimer = setInterval(() => this.requestSharedAttributes(), 5000);
|
||||||
|
this.tickPlant();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPlantLoop() {
|
||||||
|
if (this.plantTimer) {
|
||||||
|
clearInterval(this.plantTimer);
|
||||||
|
this.plantTimer = null;
|
||||||
|
}
|
||||||
|
if (this.attrPollTimer) {
|
||||||
|
clearInterval(this.attrPollTimer);
|
||||||
|
this.attrPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeSharedAttributes(incoming) {
|
||||||
|
const shared = incoming?.shared && typeof incoming.shared === "object" ? incoming.shared : incoming;
|
||||||
|
if (!shared || typeof shared !== "object") return;
|
||||||
|
const next = { ...this.sharedAttributes };
|
||||||
|
for (const [key, value] of Object.entries(shared)) {
|
||||||
|
next[key] = unwrapAttr(value);
|
||||||
|
}
|
||||||
|
this.sharedAttributes = next;
|
||||||
|
this.plant = applyOneShot(this.plant, "boostNowHw", "hwBoostUntil", "lastBoostNowHw", next.boostNowHw);
|
||||||
|
this.plant = applyOneShot(this.plant, "boostNowAux", "auxBoostUntil", "lastBoostNowAux", next.boostNowAux);
|
||||||
|
this.tickPlant();
|
||||||
|
if (this.onPlantChange) this.onPlantChange(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
plantSnapshot() {
|
||||||
|
return evaluatePlant({
|
||||||
|
plant: this.plant,
|
||||||
|
attrs: this.sharedAttributes,
|
||||||
|
tankC: Number(this.telemetry.heatsink_s) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expireOneShot(channel) {
|
||||||
|
if (channel === "hw") {
|
||||||
|
if (!this.plant.hwBoostUntil && this.plant.lastBoostNowHw === 0) return;
|
||||||
|
this.plant = {
|
||||||
|
...this.plant,
|
||||||
|
hwBoostUntil: null,
|
||||||
|
lastBoostNowHw: 0,
|
||||||
|
};
|
||||||
|
if (this.onSharedWrite) this.onSharedWrite({ boostNowHw: 0 });
|
||||||
|
} else {
|
||||||
|
if (!this.plant.auxBoostUntil && this.plant.lastBoostNowAux === 0) return;
|
||||||
|
this.plant = {
|
||||||
|
...this.plant,
|
||||||
|
auxBoostUntil: null,
|
||||||
|
lastBoostNowAux: 0,
|
||||||
|
};
|
||||||
|
if (this.onSharedWrite) this.onSharedWrite({ boostNowAux: 0 });
|
||||||
|
}
|
||||||
|
if (this.onPlantChange) this.onPlantChange(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
tickPlant() {
|
||||||
|
const now = Date.now();
|
||||||
|
const dtSec = Math.max(0, Math.min(5, (now - this.lastHeatAt) / 1000));
|
||||||
|
this.lastHeatAt = now;
|
||||||
|
|
||||||
|
if (this.plant.hwBoostUntil && this.plant.hwBoostUntil <= now) {
|
||||||
|
this.expireOneShot("hw");
|
||||||
|
}
|
||||||
|
if (this.plant.auxBoostUntil && this.plant.auxBoostUntil <= now) {
|
||||||
|
this.expireOneShot("aux");
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = this.plantSnapshot();
|
||||||
|
let tank = this.heatTankC;
|
||||||
|
if (before.hwOn && dtSec > 0 && this.plant.heatRateCpm > 0) {
|
||||||
|
tank = Math.min(this.plant.thermostatC, tank + (this.plant.heatRateCpm / 60) * dtSec);
|
||||||
|
}
|
||||||
|
this.heatTankC = tank;
|
||||||
|
const heatsink = roundTenths(tank);
|
||||||
|
|
||||||
|
const after = evaluatePlant({
|
||||||
|
plant: this.plant,
|
||||||
|
attrs: this.sharedAttributes,
|
||||||
|
tankC: tank,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (after.cutout && after.hwWanted && after.hwOneShot) {
|
||||||
|
this.expireOneShot("hw");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hw = after.hwOn ? 1 : 0;
|
||||||
|
const aux = after.auxOn ? 1 : 0;
|
||||||
|
const withPower = applyPlantOutputs(this.telemetry, { hwOn: after.hwOn });
|
||||||
|
const changed =
|
||||||
|
this.telemetry.heatsink_s !== heatsink ||
|
||||||
|
this.telemetry.rlyInSel_s !== hw ||
|
||||||
|
this.telemetry.auxOn_s !== aux ||
|
||||||
|
this.telemetry.auxRelay_s !== aux ||
|
||||||
|
this.telemetry.hotWaterInPower_s !== withPower.hotWaterInPower_s ||
|
||||||
|
this.telemetry.solarToHotWaterPower_s !== withPower.solarToHotWaterPower_s ||
|
||||||
|
this.telemetry.gridToHotWaterPower_s !== withPower.gridToHotWaterPower_s ||
|
||||||
|
this.telemetry.batteryToHotWaterPower_s !== withPower.batteryToHotWaterPower_s;
|
||||||
|
|
||||||
|
this.telemetry = {
|
||||||
|
...withPower,
|
||||||
|
heatsink_s: heatsink,
|
||||||
|
rlyInSel_s: hw,
|
||||||
|
auxOn_s: aux,
|
||||||
|
auxRelay_s: aux,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
this.publishTelemetry();
|
||||||
|
if (this.onTelemetryChange) this.onTelemetryChange(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
publishPlantTelemetry() {
|
||||||
|
if (!this.client || !this.connected) return;
|
||||||
|
this.client.publish(
|
||||||
|
"v1/devices/me/telemetry",
|
||||||
|
JSON.stringify({
|
||||||
|
heatsink_s: this.telemetry.heatsink_s,
|
||||||
|
rlyInSel_s: this.telemetry.rlyInSel_s,
|
||||||
|
auxOn_s: this.telemetry.auxOn_s,
|
||||||
|
auxRelay_s: this.telemetry.auxRelay_s,
|
||||||
|
}),
|
||||||
|
{ qos: 1 },
|
||||||
|
);
|
||||||
|
this.lastPublishedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublishedKeys(keys) {
|
||||||
|
this.publishedKeys = normalizePublishedKeys(keys ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
telemetryPayload() {
|
||||||
|
const payload = {};
|
||||||
|
for (const key of this.publishedKeys) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(this.telemetry, key)) {
|
||||||
|
payload[key] = this.telemetry[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
publishTelemetry() {
|
||||||
|
if (!this.client || !this.connected) return;
|
||||||
|
const payload = this.telemetryPayload();
|
||||||
|
if (!Object.keys(payload).length) return;
|
||||||
|
this.client.publish("v1/devices/me/telemetry", JSON.stringify(payload), { qos: 1 });
|
||||||
|
this.lastPublishedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
publishAttributes(attrs) {
|
||||||
|
if (!this.client || !this.connected) return;
|
||||||
|
this.client.publish("v1/devices/me/attributes", JSON.stringify(attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(topic, payload) {
|
||||||
|
let parsed = {};
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(payload.toString());
|
||||||
|
} catch {
|
||||||
|
parsed = { raw: payload.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic.startsWith("v1/devices/me/rpc/request/")) {
|
||||||
|
const requestId = topic.split("/").pop();
|
||||||
|
this.lastRpc = { at: Date.now(), requestId, request: parsed };
|
||||||
|
this.handleRpc(requestId, parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic === "v1/devices/me/attributes" || topic.startsWith("v1/devices/me/attributes/response/")) {
|
||||||
|
this.mergeSharedAttributes(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRpc(requestId, request) {
|
||||||
|
const method = request?.method;
|
||||||
|
const params = request?.params;
|
||||||
|
let result = { success: true };
|
||||||
|
|
||||||
|
if (method === "getState") {
|
||||||
|
result = { success: true, telemetry: this.telemetry, attributes: this.sharedAttributes };
|
||||||
|
} else if (method === "setSwitch" || method === "setAux" || method === "setAuxOn") {
|
||||||
|
const on = params === true || params === 1 || params?.value === true;
|
||||||
|
this.telemetry.auxOn_s = on ? 1 : 0;
|
||||||
|
this.telemetry.auxRelay_s = on ? 1 : 0;
|
||||||
|
this.publishAttributes({ auxOn: this.telemetry.auxOn_s });
|
||||||
|
result = { success: true, auxOn_s: this.telemetry.auxOn_s };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.client && this.connected) {
|
||||||
|
this.client.publish(`v1/devices/me/rpc/response/${requestId}`, JSON.stringify(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
label: this.label,
|
||||||
|
type: this.type,
|
||||||
|
disabled: this.disabled,
|
||||||
|
telemetryEnabled: this.telemetryEnabled,
|
||||||
|
intervalMs: this.intervalMs,
|
||||||
|
connected: this.connected,
|
||||||
|
connecting: this.connecting,
|
||||||
|
lastError: this.lastError,
|
||||||
|
lastConnectionResponse: this.lastConnectionResponse,
|
||||||
|
lastPublishedAt: this.lastPublishedAt,
|
||||||
|
lastRpc: this.lastRpc,
|
||||||
|
sharedAttributes: this.sharedAttributes,
|
||||||
|
telemetry: this.telemetry,
|
||||||
|
publishedKeys: this.publishedKeys,
|
||||||
|
plant: this.plant,
|
||||||
|
plantView: this.plantSnapshot(),
|
||||||
|
createdAt: this.createdAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { VirtualDevice };
|
||||||
Loading…
Reference in New Issue
Block a user