From b1384668431cbbdcc7e343b06f02816769ad8655 Mon Sep 17 00:00:00 2001 From: Kris MacAir Date: Thu, 24 Sep 2026 10:57:06 +0100 Subject: [PATCH] another commit --- .dockerignore | 7 ++ .env.example | 3 + Dockerfile | 18 ++++ README.md | 11 ++- deploy/Caddyfile | 8 ++ deploy/README.md | 32 +++++++ deploy/docker-compose.yml | 31 +++++++ deploy/ship.sh | 77 +++++++++++++++++ public/app.js | 133 ++++++++++++++++++++++++---- public/index.html | 178 ++++++++++++++++++++++++++++++++------ public/styles.css | 61 ++++++++++++- src/deviceManager.js | 40 +++++---- src/index.js | 40 ++++++++- src/plant.js | 134 ++++++++++++++++++++++++---- src/thingsboard.js | 63 ++++++++++++++ src/virtualDevice.js | 14 +-- 16 files changed, 760 insertions(+), 90 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 deploy/Caddyfile create mode 100644 deploy/README.md create mode 100644 deploy/docker-compose.yml create mode 100755 deploy/ship.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8e52fb5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +data +.env +.git +deploy +*.md +.DS_Store diff --git a/.env.example b/.env.example index e138de3..fffa30d 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ TB_MQTT_URL=mqtts://tb-dvgo.datavolt.au:8883 # Milliseconds between reconnect attempts while a device is connected (default 3000) # TB_MQTT_RECONNECT_MS=3000 +# Public hostname for HTTPS. DNS A record must point at the Lightsail IP. +PORTAL_HOST= + # Portal protection (leave blank to disable auth locally) PORTAL_USER= PORTAL_PASSWORD= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..071729e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-bookworm-slim + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY src ./src +COPY public ./public + +RUN mkdir -p /app/data && chown -R node:node /app + +USER node + +ENV NODE_ENV=production +EXPOSE 3080 + +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index 6b66710..8bce3d2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Behaviour: - 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` +- **Battery** section is display-only for now: **Battery Charging** lamp stays off, **Charge rate (% / min)** defaults to 10 +- Solar, grid, home load, hot water, aux, and battery cards set the energy balance in watts. Grid is calculated. Lamps are red for draw, green for supply, and grey for zero. Hot water watts apply only while heating, and aux watts only while aux is on. Those setpoints write the live power and flow telemetry keys. `loadPower_s` is home plus aux. ## Telemetry keys @@ -114,6 +116,10 @@ npm start Open [http://localhost:3080](http://localhost:3080). +## Deploy + +Build a Docker image locally and copy it to an Ubuntu host over SSH. See [deploy/README.md](deploy/README.md). From this repo: `./deploy/ship.sh` (SSH host `diverter-emu`). The SQLite file stays on the host in `/opt/diverter-device-emulator/data`. + Optional env: - `PORT` — portal port (default `3080`) @@ -122,9 +128,10 @@ Optional env: - `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 +- `PORTAL_HOST` — public hostname for HTTPS on the Lightsail deploy +- `PORTAL_USER` / `PORTAL_PASSWORD` — login for the portal - `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. +If a device already exists in ThingsBoard, tick **Add existing device** in the add modal and paste its MQTT access token. The emulator looks the device up on the server (id, name, label, type) and attaches it. You can also create by TB name with no token, or save-by-name if create times out. diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..2908ee3 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,8 @@ +{$PORTAL_HOST} { + reverse_proxy app:{$PORT:3080} { + header_down Cache-Control "no-store, no-cache, must-revalidate" + header_down CDN-Cache-Control "no-store" + header_down Cloudflare-CDN-Cache-Control "no-store" + header_down Pragma "no-cache" + } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..bf87ddf --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,32 @@ +# Deploy on Lightsail + +Build the app image on your machine and copy it over SSH. Caddy on the instance terminates HTTPS and proxies to the app container. The app is not published on port 3080. `.env` is copied from this machine on each deploy. The SQLite file stays in `/opt/diverter-device-emulator/data`, and the Let's Encrypt certificate stays in a Docker volume. + +## One-time setup + +1. Create an Ubuntu 24.04 Lightsail instance and attach a static IP. +2. Pick a hostname and set a DNS A record to that IP. Put the hostname in `.env` as `PORTAL_HOST`. Set `PORTAL_USER` and `PORTAL_PASSWORD`. Leave `ALLOWED_IPS` unset. +3. In the Lightsail IPv4 firewall, allow SSH (22) from your IP, and TCP **80** and **443** from any IPv4 address. +4. Install Docker and Compose on the instance: + +```bash +sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2 && sudo systemctl enable --now docker +``` + +## Deploy + +From this repo, with Docker Desktop running: + +```bash +./deploy/ship.sh +``` + +That uses the `diverter-emu` host in `~/.ssh/config`, including its user and key. The script builds the app image for the server's CPU, copies it with the Caddy image, and runs both. The site is `https://`. Caddy requests the certificate on first start, which needs the DNS record and ports 80 and 443 already open. + +Responses send `Cache-Control` / `Cloudflare-CDN-Cache-Control: no-store` so Cloudflare does not cache HTML, CSS, or JS. CSS and JS URLs are also versioned, so a deploy does not keep serving a stale edge copy. After this change is live, purge Cloudflare once (Caching → Purge Everything) so the old `/styles.css` and `/app.js` drop out. + +Logs: + +```bash +ssh diverter-emu sudo bash -c 'cd /opt/diverter-device-emulator && docker compose logs -f' +``` diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..eca66bd --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,31 @@ +# Image is built on the laptop and loaded by deploy/ship.sh. Do not build on the server. +name: diverter + +services: + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + environment: + PORTAL_HOST: ${PORTAL_HOST} + PORT: ${PORT:-3080} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + depends_on: + - app + + app: + image: diverter-device-emulator:latest + restart: unless-stopped + env_file: + - .env + expose: + - "${PORT:-3080}" + volumes: + - ./data:/app/data + +volumes: + caddy_data: diff --git a/deploy/ship.sh b/deploy/ship.sh new file mode 100755 index 0000000..01c112e --- /dev/null +++ b/deploy/ship.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Build the app image on this machine, copy it and Caddy to the host, and restart both. +# Uses the diverter-emu entry in ~/.ssh/config (user and key). +# Usage: ./deploy/ship.sh [ssh-host] +set -euo pipefail + +HOST="${1:-diverter-emu}" +IMAGE=diverter-device-emulator +REMOTE_DIR=/opt/diverter-device-emulator + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ ! -f .env ]]; then + echo "Missing $ROOT/.env — copy .env.example and fill it in." >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is not on PATH. Start Docker Desktop, then run this again." >&2 + exit 1 +fi + +env_value() { + local key="$1" + grep -E "^${key}=" .env | head -n 1 | cut -d= -f2- | tr -d "\"'" | tr -d '[:space:]' +} + +PORTAL_HOST="$(env_value PORTAL_HOST)" +if [[ -z "$PORTAL_HOST" ]]; then + echo "Set PORTAL_HOST in .env to the public hostname. Its DNS A record must point at the Lightsail IP before the first deploy, or Caddy cannot get a certificate." >&2 + exit 1 +fi + +ARCH="$(ssh "$HOST" "uname -m")" +case "$ARCH" in + x86_64) PLATFORM=linux/amd64 ;; + aarch64 | arm64) PLATFORM=linux/arm64 ;; + *) + echo "Unsupported server architecture: $ARCH" >&2 + exit 1 + ;; +esac + +TAR="$(mktemp -t diverter-image.XXXXXX.tar)" +cleanup() { rm -f "$TAR"; } +trap cleanup EXIT + +if ! ssh "$HOST" "sudo docker compose version" >/dev/null 2>&1; then + echo "The server has no Docker Compose. On the instance: sudo apt-get install -y docker-compose-v2" >&2 + exit 1 +fi + +# Attestation indexes from Docker Desktop make `docker save` fail, so both images are single-platform. +echo "Building $IMAGE for $PLATFORM" +docker build --platform "$PLATFORM" --provenance=false --sbom=false -t "$IMAGE" . +echo "Preparing caddy:2 for $PLATFORM" +docker build --platform "$PLATFORM" --provenance=false --sbom=false -t caddy:2 - <<'EOF' +FROM caddy:2 +EOF + +echo "Saving images" +docker save -o "$TAR" "$IMAGE" caddy:2 + +echo "Copying images, .env, and Caddy config to $HOST" +ssh "$HOST" "sudo mkdir -p '$REMOTE_DIR/data' && sudo chown 1000:1000 '$REMOTE_DIR/data'" +scp "$TAR" "$HOST:/tmp/${IMAGE}.tar" +scp .env "$HOST:/tmp/${IMAGE}.env" +scp "$ROOT/deploy/docker-compose.yml" "$ROOT/deploy/Caddyfile" "$HOST:/tmp/" + +ssh "$HOST" "sudo mv /tmp/${IMAGE}.env '$REMOTE_DIR/.env' && sudo chmod 600 '$REMOTE_DIR/.env' && sudo mv /tmp/docker-compose.yml /tmp/Caddyfile '$REMOTE_DIR/'" +ssh "$HOST" "sudo docker load -i /tmp/${IMAGE}.tar && rm -f /tmp/${IMAGE}.tar" +ssh "$HOST" "sudo docker rm -f '$IMAGE' >/dev/null 2>&1 || true" +ssh "$HOST" "sudo bash -c 'cd \"$REMOTE_DIR\" && docker compose up -d --pull never --remove-orphans'" + +echo "Containers on $HOST:" +ssh "$HOST" "sudo docker ps --filter name='diverter-' --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" diff --git a/public/app.js b/public/app.js index e14a5c1..c961806 100644 --- a/public/app.js +++ b/public/app.js @@ -41,6 +41,12 @@ const els = { addProfile: document.getElementById("add-profile"), addBusy: document.getElementById("add-busy"), addBusyText: document.getElementById("add-busy-text"), + addExisting: document.getElementById("add-existing"), + addCreateFields: document.getElementById("add-create-fields"), + addTokenField: document.getElementById("add-token-field"), + addTitle: document.getElementById("add-title"), + addHelp: document.getElementById("add-help"), + addSubmit: document.getElementById("add-submit"), confirmModal: document.getElementById("confirm-modal"), confirmTitle: document.getElementById("confirm-title"), confirmMessage: document.getElementById("confirm-message"), @@ -54,6 +60,9 @@ const els = { heatRate: document.getElementById("heat-rate"), thermostat: document.getElementById("thermostat"), tankTemp: document.getElementById("tank-temp"), + battLamp: document.getElementById("batt-lamp"), + battLampDetail: document.getElementById("batt-lamp-detail"), + chargeRate: document.getElementById("charge-rate"), }; function headers(extra = {}) { @@ -223,16 +232,70 @@ function renderPlant(device) { const plant = device.plant || {}; els.hwLamp.classList.toggle("on", Boolean(view.hwOn)); els.auxLamp.classList.toggle("on", Boolean(view.auxOn)); + els.battLamp.classList.toggle("on", Boolean(view.battCharging)); 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; - } + els.battLampDetail.textContent = view.battDetail || "Off"; + const heat = plant.heatRateCpm ?? 1; + const thermo = plant.thermostatC ?? 65; + const charge = plant.chargeRatePpm ?? 10; + document.getElementById("heat-rate-current").textContent = String(heat); + document.getElementById("thermostat-current").textContent = String(thermo); + document.getElementById("charge-rate-current").textContent = String(charge); + if (document.activeElement !== els.heatRate) els.heatRate.value = heat; + if (document.activeElement !== els.thermostat) els.thermostat.value = thermo; + if (document.activeElement !== els.chargeRate) els.chargeRate.value = charge; const tank = device.telemetry?.heatsink_s; els.tankTemp.textContent = tank == null ? "—" : `${Number(tank).toFixed(1)} °C`; + renderPowerSim(device); +} + +const POWER_FIELDS = { + solar: "solarW", + home: "homeW", + aux: "auxW", + battery: "batteryW", + hotWater: "hotWaterW", +}; + +function renderPowerSim(device) { + const rows = device.plantView?.power?.rows || []; + for (const row of rows) { + const lamp = document.querySelector(`[data-power-lamp="${row.id}"]`); + const value = document.querySelector(`[data-power-value="${row.id}"]`); + if (lamp) { + lamp.classList.toggle("draw", row.state === "draw"); + lamp.classList.toggle("supply", row.state === "supply"); + } + if (value) { + const direction = row.id === "grid" ? row.label : row.state === "supply" ? "supply" : row.state === "draw" ? "draw" : ""; + const suffix = row.id === "grid" || row.id === "battery" ? ` ${direction}` : ""; + value.textContent = `${row.watts} W${suffix}`.trim(); + } + const input = document.querySelector(`[data-power-input="${row.id}"]`); + if (input && document.activeElement !== input && row.setpoint != null) { + input.value = row.setpoint; + } + const mode = document.querySelector(`[data-power-mode="${row.id}"]`); + if (mode && document.activeElement !== mode && row.mode) mode.value = row.mode; + } +} + +async function savePowerRow(id) { + const device = selectedDevice(); + if (!device) return; + const field = POWER_FIELDS[id]; + if (!field) return; + const input = document.querySelector(`[data-power-input="${id}"]`); + const body = { [field]: Number(input.value) }; + if (id === "battery") { + body.batteryMode = document.getElementById("power-battery-mode").value === "supply" ? "supply" : "draw"; + } + const data = await api(`/api/devices/${device.id}/plant`, { + method: "PATCH", + body: JSON.stringify(body), + }); + upsertDevice(data.device); } function publishedKeySet(device) { @@ -456,8 +519,25 @@ document.addEventListener("keydown", (event) => { if (event.key === "Escape") setDeviceMenuOpen(false); }); +function applyAddMode() { + const existing = els.addExisting.checked; + els.addCreateFields.classList.toggle("hidden", existing); + els.addTokenField.classList.toggle("hidden", !existing); + els.addTitle.textContent = existing ? "Add existing device" : "Add to emulator"; + els.addHelp.textContent = existing + ? "Paste the MQTT access token to load a ThingsBoard device into this emulator." + : "Creates a new device in ThingsBoard and this emulator."; + els.addSubmit.textContent = existing ? "Add device" : "Create device"; +} + +els.addExisting.addEventListener("change", () => { + els.addError.classList.add("hidden"); + applyAddMode(); +}); + document.getElementById("add-open").addEventListener("click", () => { els.addError.classList.add("hidden"); + applyAddMode(); els.addModal.classList.remove("hidden"); }); document.getElementById("add-cancel").addEventListener("click", () => { @@ -477,15 +557,27 @@ function setAddBusy(busy, text) { 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, - }; + const existing = els.addExisting.checked; + const payload = existing + ? { accessToken: document.getElementById("add-token").value || undefined } + : { + 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, + }; + if (existing && !String(payload.accessToken || "").trim()) { + els.addError.classList.remove("hidden"); + els.addError.textContent = "Paste the MQTT access token of the ThingsBoard device to add."; + return; + } + if (!existing && !String(payload.name || "").trim()) { + els.addError.classList.remove("hidden"); + els.addError.textContent = "Enter a name to create a device."; + return; + } - setAddBusy(true, "Waiting for ThingsBoard…"); + setAddBusy(true, payload.accessToken ? "Looking up ThingsBoard device…" : "Waiting for ThingsBoard…"); try { const data = await api("/api/devices", { method: "POST", @@ -543,6 +635,7 @@ function finishAdd(device) { els.addProfile.querySelector("option")?.value || ""; if (preferredId) els.addProfile.value = preferredId; + applyAddMode(); renderDashboard(); } @@ -692,13 +785,21 @@ async function savePlant() { body: JSON.stringify({ heatRateCpm: Number(els.heatRate.value), thermostatC: Number(els.thermostat.value), + chargeRatePpm: Number(els.chargeRate.value), }), }); upsertDevice(data.device); } -els.heatRate.addEventListener("change", () => savePlant()); -els.thermostat.addEventListener("change", () => savePlant()); +document.getElementById("dashboard").addEventListener("click", (event) => { + const powerButton = event.target.closest("[data-power-update]"); + if (powerButton) { + savePowerRow(powerButton.getAttribute("data-power-update")); + return; + } + const plantButton = event.target.closest("[data-plant-update]"); + if (plantButton) savePlant(); +}); els.publishNow.addEventListener("click", async () => { await flushTelemetry(); diff --git a/public/index.html b/public/index.html index 4c20b55..8287a79 100644 --- a/public/index.html +++ b/public/index.html @@ -103,6 +103,46 @@

Virtual plant

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.

+
+

Solar

+
+ +
+ Current value + 0 W +
+ + +
+
+
+

Grid

+
+ +
+ Current value + 0 W +
+
+
+
+

Home load

+
+ +
+ Current value + 0 W +
+ + +
+

Hot water

@@ -113,23 +153,46 @@ Off
-
- — -
- Hot Water Temp - heatsink_s -
+
+
+
+ Current value (°C) + —
-
+
+
+ Current value (°C / min) + 1 +
+ +
+
+
+ Current value (°C) + 65 +
+ +
+
+ +
+ Current value + 0 W +
+ +
@@ -143,6 +206,60 @@ +
+ +
+ Current value + 0 W +
+ + +
+
+
+

Battery

+
+
+ +
+ Battery Charging + Off +
+
+
+
+
+ Current value (% / min) + 10 +
+ + +
+
+ +
+ Current value + 0 W +
+ + + +
@@ -179,31 +296,38 @@

Waiting for ThingsBoard…

New virtual device

-

Create in ThingsBoard

-