another commit

This commit is contained in:
Kris MacAir 2026-09-24 10:57:06 +01:00
parent b83211eb80
commit b138466843
16 changed files with 760 additions and 90 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
data
.env
.git
deploy
*.md
.DS_Store

View File

@ -18,6 +18,9 @@ TB_MQTT_URL=mqtts://tb-dvgo.datavolt.au:8883
# Milliseconds between reconnect attempts while a device is connected (default 3000) # Milliseconds between reconnect attempts while a device is connected (default 3000)
# TB_MQTT_RECONNECT_MS=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 protection (leave blank to disable auth locally)
PORTAL_USER= PORTAL_USER=
PORTAL_PASSWORD= PORTAL_PASSWORD=

18
Dockerfile Normal file
View File

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

View File

@ -40,6 +40,8 @@ Behaviour:
- While HW is on, `heatsink_s` rises at **Heat rate (°C / min)** (integer, default 1) - 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 - 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` - 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 ## Telemetry keys
@ -114,6 +116,10 @@ npm start
Open [http://localhost:3080](http://localhost:3080). 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: Optional env:
- `PORT` — portal port (default `3080`) - `PORT` — portal port (default `3080`)
@ -122,9 +128,10 @@ Optional env:
- `TB_DEVICE_TYPE` — default `DVgo` - `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_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 - `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` - `ALLOWED_IPS` — comma-separated allowlist for `/api`
- `TELEMETRY_INTERVAL_MS` — default `5000` - `TELEMETRY_INTERVAL_MS` — default `5000`
- `DB_PATH` — default `./data/emulator.db` - `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.

8
deploy/Caddyfile Normal file
View File

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

32
deploy/README.md Normal file
View File

@ -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://<PORTAL_HOST>`. 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'
```

31
deploy/docker-compose.yml Normal file
View File

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

77
deploy/ship.sh Executable file
View File

@ -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}}'"

View File

@ -41,6 +41,12 @@ const els = {
addProfile: document.getElementById("add-profile"), addProfile: document.getElementById("add-profile"),
addBusy: document.getElementById("add-busy"), addBusy: document.getElementById("add-busy"),
addBusyText: document.getElementById("add-busy-text"), 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"), confirmModal: document.getElementById("confirm-modal"),
confirmTitle: document.getElementById("confirm-title"), confirmTitle: document.getElementById("confirm-title"),
confirmMessage: document.getElementById("confirm-message"), confirmMessage: document.getElementById("confirm-message"),
@ -54,6 +60,9 @@ const els = {
heatRate: document.getElementById("heat-rate"), heatRate: document.getElementById("heat-rate"),
thermostat: document.getElementById("thermostat"), thermostat: document.getElementById("thermostat"),
tankTemp: document.getElementById("tank-temp"), tankTemp: document.getElementById("tank-temp"),
battLamp: document.getElementById("batt-lamp"),
battLampDetail: document.getElementById("batt-lamp-detail"),
chargeRate: document.getElementById("charge-rate"),
}; };
function headers(extra = {}) { function headers(extra = {}) {
@ -223,16 +232,70 @@ function renderPlant(device) {
const plant = device.plant || {}; const plant = device.plant || {};
els.hwLamp.classList.toggle("on", Boolean(view.hwOn)); els.hwLamp.classList.toggle("on", Boolean(view.hwOn));
els.auxLamp.classList.toggle("on", Boolean(view.auxOn)); els.auxLamp.classList.toggle("on", Boolean(view.auxOn));
els.battLamp.classList.toggle("on", Boolean(view.battCharging));
els.hwLampDetail.textContent = view.hwDetail || "Off"; els.hwLampDetail.textContent = view.hwDetail || "Off";
els.auxLampDetail.textContent = view.auxDetail || "Off"; els.auxLampDetail.textContent = view.auxDetail || "Off";
if (document.activeElement !== els.heatRate) { els.battLampDetail.textContent = view.battDetail || "Off";
els.heatRate.value = plant.heatRateCpm ?? 1; const heat = plant.heatRateCpm ?? 1;
} const thermo = plant.thermostatC ?? 65;
if (document.activeElement !== els.thermostat) { const charge = plant.chargeRatePpm ?? 10;
els.thermostat.value = plant.thermostatC ?? 65; 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; const tank = device.telemetry?.heatsink_s;
els.tankTemp.textContent = tank == null ? "—" : `${Number(tank).toFixed(1)} °C`; 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) { function publishedKeySet(device) {
@ -456,8 +519,25 @@ document.addEventListener("keydown", (event) => {
if (event.key === "Escape") setDeviceMenuOpen(false); 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", () => { document.getElementById("add-open").addEventListener("click", () => {
els.addError.classList.add("hidden"); els.addError.classList.add("hidden");
applyAddMode();
els.addModal.classList.remove("hidden"); els.addModal.classList.remove("hidden");
}); });
document.getElementById("add-cancel").addEventListener("click", () => { document.getElementById("add-cancel").addEventListener("click", () => {
@ -477,15 +557,27 @@ function setAddBusy(busy, text) {
els.addForm.addEventListener("submit", async (event) => { els.addForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
els.addError.classList.add("hidden"); els.addError.classList.add("hidden");
const payload = { const existing = els.addExisting.checked;
const payload = existing
? { accessToken: document.getElementById("add-token").value || undefined }
: {
name: document.getElementById("add-name").value, name: document.getElementById("add-name").value,
label: document.getElementById("add-label").value, label: document.getElementById("add-label").value,
type: document.getElementById("add-type").value, type: document.getElementById("add-type").value,
deviceProfileId: document.getElementById("add-profile").value || undefined, deviceProfileId: document.getElementById("add-profile").value || undefined,
accessToken: document.getElementById("add-token").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 { try {
const data = await api("/api/devices", { const data = await api("/api/devices", {
method: "POST", method: "POST",
@ -543,6 +635,7 @@ function finishAdd(device) {
els.addProfile.querySelector("option")?.value || els.addProfile.querySelector("option")?.value ||
""; "";
if (preferredId) els.addProfile.value = preferredId; if (preferredId) els.addProfile.value = preferredId;
applyAddMode();
renderDashboard(); renderDashboard();
} }
@ -692,13 +785,21 @@ async function savePlant() {
body: JSON.stringify({ body: JSON.stringify({
heatRateCpm: Number(els.heatRate.value), heatRateCpm: Number(els.heatRate.value),
thermostatC: Number(els.thermostat.value), thermostatC: Number(els.thermostat.value),
chargeRatePpm: Number(els.chargeRate.value),
}), }),
}); });
upsertDevice(data.device); upsertDevice(data.device);
} }
els.heatRate.addEventListener("change", () => savePlant()); document.getElementById("dashboard").addEventListener("click", (event) => {
els.thermostat.addEventListener("change", () => savePlant()); 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 () => { els.publishNow.addEventListener("click", async () => {
await flushTelemetry(); await flushTelemetry();

View File

@ -103,6 +103,46 @@
<section class="group plant-panel"> <section class="group plant-panel">
<h3>Virtual plant</h3> <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> <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>Solar</h4>
<div class="plant-setting">
<span class="lamp" data-power-lamp="solar" aria-hidden="true"></span>
<div class="plant-current">
<span>Current value</span>
<output data-power-value="solar">0 W</output>
</div>
<label>
New value (W)
<input id="power-solar" data-power-input="solar" type="number" min="0" max="100000" step="1" value="0" />
</label>
<button type="button" data-power-update="solar">Update</button>
</div>
</section>
<section class="plant-card">
<h4>Grid</h4>
<div class="plant-setting">
<span class="lamp" data-power-lamp="grid" aria-hidden="true"></span>
<div class="plant-current">
<span>Current value</span>
<output data-power-value="grid">0 W</output>
</div>
</div>
</section>
<section class="plant-card">
<h4>Home load</h4>
<div class="plant-setting">
<span class="lamp" data-power-lamp="home" aria-hidden="true"></span>
<div class="plant-current">
<span>Current value</span>
<output data-power-value="home">0 W</output>
</div>
<label>
New value (W)
<input id="power-home" data-power-input="home" type="number" min="0" max="100000" step="1" value="0" />
</label>
<button type="button" data-power-update="home">Update</button>
</div>
</section>
<section class="plant-card"> <section class="plant-card">
<h4>Hot water</h4> <h4>Hot water</h4>
<div class="lamps"> <div class="lamps">
@ -113,23 +153,46 @@
<small id="hw-lamp-detail">Off</small> <small id="hw-lamp-detail">Off</small>
</div> </div>
</div> </div>
<div class="lamp-card"> </div>
<span id="tank-temp" class="plant-temp-value">—</span> <div class="plant-setting">
<div> <div class="plant-current">
<strong>Hot Water Temp</strong> <span>Current value (°C)</span>
<small class="key">heatsink_s</small> <output id="tank-temp">—</output>
</div> </div>
</div> </div>
<div class="plant-setting">
<div class="plant-current">
<span>Current value (°C / min)</span>
<output id="heat-rate-current">1</output>
</div> </div>
<div class="meta-row plant-fields">
<label> <label>
Heat rate (°C / min) Heat rate (°C / min)
<input id="heat-rate" type="number" min="0" max="20" step="1" /> <input id="heat-rate" type="number" min="0" max="20" step="1" />
</label> </label>
<button type="button" data-plant-update="heat">Update</button>
</div>
<div class="plant-setting">
<div class="plant-current">
<span>Current value (°C)</span>
<output id="thermostat-current">65</output>
</div>
<label> <label>
Thermostat max (°C) Thermostat max (°C)
<input id="thermostat" type="number" min="0" max="99" step="1" /> <input id="thermostat" type="number" min="0" max="99" step="1" />
</label> </label>
<button type="button" data-plant-update="thermostat">Update</button>
</div>
<div class="plant-setting">
<span class="lamp" data-power-lamp="hotWater" aria-hidden="true"></span>
<div class="plant-current">
<span>Current value</span>
<output data-power-value="hotWater">0 W</output>
</div>
<label>
New value (W)
<input id="power-hot-water" data-power-input="hotWater" type="number" min="0" max="100000" step="1" value="0" />
</label>
<button type="button" data-power-update="hotWater">Update</button>
</div> </div>
</section> </section>
<section class="plant-card"> <section class="plant-card">
@ -143,6 +206,60 @@
</div> </div>
</div> </div>
</div> </div>
<div class="plant-setting">
<span class="lamp" data-power-lamp="aux" aria-hidden="true"></span>
<div class="plant-current">
<span>Current value</span>
<output data-power-value="aux">0 W</output>
</div>
<label>
New value (W)
<input id="power-aux" data-power-input="aux" type="number" min="0" max="100000" step="1" value="0" />
</label>
<button type="button" data-power-update="aux">Update</button>
</div>
</section>
<section class="plant-card">
<h4>Battery</h4>
<div class="lamps">
<div class="lamp-card">
<span id="batt-lamp" class="lamp" aria-hidden="true"></span>
<div>
<strong>Battery Charging</strong>
<small id="batt-lamp-detail">Off</small>
</div>
</div>
</div>
<div class="plant-setting">
<div class="plant-current">
<span>Current value (% / min)</span>
<output id="charge-rate-current">10</output>
</div>
<label>
Charge rate (% / min)
<input id="charge-rate" type="number" min="0" max="100" step="1" />
</label>
<button type="button" data-plant-update="charge">Update</button>
</div>
<div class="plant-setting">
<span class="lamp" data-power-lamp="battery" aria-hidden="true"></span>
<div class="plant-current">
<span>Current value</span>
<output data-power-value="battery">0 W</output>
</div>
<label>
Direction
<select id="power-battery-mode" data-power-mode="battery" aria-label="Battery direction">
<option value="draw">Draw</option>
<option value="supply">Supply</option>
</select>
</label>
<label>
New value (W)
<input id="power-battery" data-power-input="battery" type="number" min="0" max="100000" step="1" value="0" />
</label>
<button type="button" data-power-update="battery">Update</button>
</div>
</section> </section>
</section> </section>
@ -179,10 +296,16 @@
<p id="add-busy-text">Waiting for ThingsBoard…</p> <p id="add-busy-text">Waiting for ThingsBoard…</p>
</div> </div>
<p class="eyebrow">New virtual device</p> <p class="eyebrow">New virtual device</p>
<h2>Create in ThingsBoard</h2> <h2 id="add-title">Add to emulator</h2>
<p id="add-help" class="muted">Creates a new device in ThingsBoard and this emulator.</p>
<label class="add-existing-toggle">
<input id="add-existing" class="device-check" type="checkbox" />
Add existing device
</label>
<div id="add-create-fields">
<label> <label>
Name / serial Name / serial
<input id="add-name" name="name" required placeholder="EMU-AABBCCDDEEFF" /> <input id="add-name" name="name" placeholder="EMU-AABBCCDDEEFF" />
</label> </label>
<label> <label>
Label Label
@ -196,14 +319,15 @@
Device profile Device profile
<select id="add-profile"></select> <select id="add-profile"></select>
</label> </label>
<label> </div>
Existing access token (optional) <label id="add-token-field" class="hidden">
<input id="add-token" name="accessToken" placeholder="Skip TB create and attach this MQTT token" /> Access token
<input id="add-token" name="accessToken" placeholder="MQTT access token from ThingsBoard" />
</label> </label>
<p id="add-error" class="error hidden"></p> <p id="add-error" class="error hidden"></p>
<div class="row-actions"> <div class="row-actions">
<button type="button" id="add-cancel">Cancel</button> <button type="button" id="add-cancel">Cancel</button>
<button type="submit" class="primary">Create device</button> <button type="submit" id="add-submit" class="primary">Create device</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -517,16 +517,55 @@ select {
flex-shrink: 0; flex-shrink: 0;
} }
.lamp.on { .lamp.on,
.lamp.draw {
background: var(--danger); background: var(--danger);
opacity: 1; opacity: 1;
box-shadow: 0 0 0 4px rgba(232, 93, 76, 0.22), 0 0 16px rgba(232, 93, 76, 0.45); box-shadow: 0 0 0 4px rgba(232, 93, 76, 0.22), 0 0 16px rgba(232, 93, 76, 0.45);
} }
.lamp.supply {
background: var(--accent);
opacity: 1;
box-shadow: 0 0 0 4px rgba(61, 204, 138, 0.22), 0 0 16px rgba(61, 204, 138, 0.45);
}
.plant-fields { .plant-fields {
margin-top: 12px; margin-top: 12px;
} }
.plant-setting {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
margin-top: 12px;
}
.plant-setting .lamp {
margin-bottom: 9px;
}
.plant-current {
display: grid;
gap: 6px;
font-size: 13px;
color: var(--muted);
}
.plant-current output {
min-width: 140px;
margin: 0;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg-elev-2);
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
}
#telemetry-groups { #telemetry-groups {
display: grid; display: grid;
gap: 18px; gap: 18px;
@ -603,6 +642,7 @@ select {
flex-shrink: 0; flex-shrink: 0;
} }
.field-row.inactive { .field-row.inactive {
opacity: 0.55; opacity: 0.55;
} }
@ -671,6 +711,25 @@ select {
} }
} }
.login-card > #add-create-fields {
display: grid;
gap: 12px;
}
.add-existing-toggle {
display: flex;
align-items: center;
gap: 10px;
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.add-existing-toggle .device-check {
width: 18px;
height: 18px;
}
.row-actions { .row-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@ -1,4 +1,3 @@
const crypto = require("crypto");
const db = require("./db"); const db = require("./db");
const thingsboard = require("./thingsboard"); const thingsboard = require("./thingsboard");
const { VirtualDevice } = require("./virtualDevice"); const { VirtualDevice } = require("./virtualDevice");
@ -96,9 +95,25 @@ function registerLocalDevice(tbDevice) {
} }
async function create({ name, label, type, deviceProfileId, accessToken, id }) { async function create({ name, label, type, deviceProfileId, accessToken, id }) {
const existingToken = String(accessToken || "").trim();
if (existingToken) {
const found = await thingsboard.findDeviceByAccessToken(existingToken);
if (!found?.id) {
const error = new Error("No ThingsBoard device uses that access token");
error.status = 404;
throw error;
}
if (devices.has(found.id) || db.getDevice(found.id) || db.getDeviceByName(found.name)) {
const error = new Error(`Emulator already has "${found.name}"`);
error.status = 409;
throw error;
}
return registerLocalDevice(found);
}
const trimmed = String(name || "").trim(); const trimmed = String(name || "").trim();
if (!trimmed) { if (!trimmed) {
const error = new Error("Device name is required"); const error = new Error("Device name is required, or paste an existing access token");
error.status = 400; error.status = 400;
throw error; throw error;
} }
@ -110,16 +125,7 @@ async function create({ name, label, type, deviceProfileId, accessToken, id }) {
throw error; throw error;
} }
const existingToken = String(accessToken || "").trim(); const tbDevice = await thingsboard.createDevice({
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, name: trimmed,
label, label,
type, type,

View File

@ -1,3 +1,4 @@
const fs = require("fs");
const path = require("path"); const path = require("path");
const express = require("express"); const express = require("express");
const config = require("./config"); const config = require("./config");
@ -5,16 +6,47 @@ const { basicAuth, ipAllowlist } = require("./auth");
const deviceManager = require("./deviceManager"); const deviceManager = require("./deviceManager");
const routes = require("./routes"); const routes = require("./routes");
const publicDir = path.join(__dirname, "..", "public");
const assetVersion = String(
Math.max(
fs.statSync(path.join(publicDir, "app.js")).mtimeMs,
fs.statSync(path.join(publicDir, "styles.css")).mtimeMs
)
);
function noStore(res) {
res.set("Cache-Control", "no-store, no-cache, must-revalidate");
res.set("CDN-Cache-Control", "no-store");
res.set("Cloudflare-CDN-Cache-Control", "no-store");
res.set("Pragma", "no-cache");
}
function sendIndex(_req, res) {
noStore(res);
const html = fs
.readFileSync(path.join(publicDir, "index.html"), "utf8")
.replace('href="/styles.css"', `href="/styles.css?v=${assetVersion}"`)
.replace('src="/app.js"', `src="/app.js?v=${assetVersion}"`);
res.type("html").send(html);
}
const app = express(); const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
app.use(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
app.use("/api", ipAllowlist(config.allowedIps), basicAuth(config.portalUser, config.portalPassword), routes); app.use("/api", ipAllowlist(config.allowedIps), basicAuth(config.portalUser, config.portalPassword), routes);
app.use(express.static(path.join(__dirname, "..", "public"))); app.use(
express.static(publicDir, {
etag: false,
lastModified: false,
index: false,
setHeaders(res) {
noStore(res);
},
})
);
app.get("*", (_req, res) => { app.get("*", sendIndex);
res.sendFile(path.join(__dirname, "..", "public", "index.html"));
});
deviceManager.restore(); deviceManager.restore();

View File

@ -16,6 +16,13 @@ const SHARED_KEYS = [
const DEFAULT_PLANT = { const DEFAULT_PLANT = {
heatRateCpm: 1, heatRateCpm: 1,
thermostatC: 65, thermostatC: 65,
chargeRatePpm: 10,
solarW: 0,
homeW: 0,
auxW: 0,
batteryW: 0,
batteryMode: "draw",
hotWaterW: 0,
hwBoostUntil: null, hwBoostUntil: null,
auxBoostUntil: null, auxBoostUntil: null,
lastBoostNowHw: null, lastBoostNowHw: null,
@ -93,8 +100,16 @@ function normalizePlant(input) {
if (!input || typeof input !== "object") return next; if (!input || typeof input !== "object") return next;
const heat = Math.trunc(num(input.heatRateCpm ?? input.heatRateCps, next.heatRateCpm)); const heat = Math.trunc(num(input.heatRateCpm ?? input.heatRateCps, next.heatRateCpm));
const thermo = num(input.thermostatC, next.thermostatC); const thermo = num(input.thermostatC, next.thermostatC);
const charge = Math.trunc(num(input.chargeRatePpm, next.chargeRatePpm));
next.heatRateCpm = Math.max(0, Math.min(20, heat)); next.heatRateCpm = Math.max(0, Math.min(20, heat));
next.thermostatC = Math.max(0, Math.min(99, thermo)); next.thermostatC = Math.max(0, Math.min(99, thermo));
next.chargeRatePpm = Math.max(0, Math.min(100, charge));
next.solarW = watts(input.solarW, next.solarW);
next.homeW = watts(input.homeW, next.homeW);
next.auxW = watts(input.auxW, next.auxW);
next.batteryW = watts(input.batteryW, next.batteryW);
next.batteryMode = input.batteryMode === "supply" ? "supply" : "draw";
next.hotWaterW = watts(input.hotWaterW, next.hotWaterW);
next.hwBoostUntil = input.hwBoostUntil || null; next.hwBoostUntil = input.hwBoostUntil || null;
next.auxBoostUntil = input.auxBoostUntil || null; next.auxBoostUntil = input.auxBoostUntil || null;
next.lastBoostNowHw = input.lastBoostNowHw ?? null; next.lastBoostNowHw = input.lastBoostNowHw ?? null;
@ -102,22 +117,107 @@ function normalizePlant(input) {
return next; return next;
} }
const HW_HEAT_W = 800; function watts(value, fallback = 0) {
return Math.max(0, Math.min(100000, Math.trunc(num(value, fallback))));
}
function applyPlantOutputs(telemetry, { hwOn }) { function takePower(demand, solarLeft, batteryLeft) {
const next = { ...telemetry }; const fromSolar = Math.min(solarLeft, demand);
if (hwOn) { const afterSolar = demand - fromSolar;
if (!(Number(next.hotWaterInPower_s) > 0)) next.hotWaterInPower_s = HW_HEAT_W; const fromBattery = Math.min(batteryLeft, afterSolar);
if (!(Number(next.solarToHotWaterPower_s) > 0)) { const fromGrid = afterSolar - fromBattery;
next.solarToHotWaterPower_s = Number(next.hotWaterInPower_s) || HW_HEAT_W; return {
solar: fromSolar,
battery: fromBattery,
grid: fromGrid,
solarLeft: solarLeft - fromSolar,
batteryLeft: batteryLeft - fromBattery,
};
} }
} else {
next.hotWaterInPower_s = 0; function flowState(kind, wattsValue) {
next.solarToHotWaterPower_s = 0; if (!(wattsValue > 0)) return "idle";
next.gridToHotWaterPower_s = 0; if (kind === "supply") return "supply";
next.batteryToHotWaterPower_s = 0; return "draw";
} }
return next;
function resolvePowerSim({ plant, hwOn, auxOn }) {
const solar = watts(plant.solarW);
const home = watts(plant.homeW);
const aux = auxOn ? watts(plant.auxW) : 0;
const hw = hwOn ? watts(plant.hotWaterW) : 0;
const batterySet = watts(plant.batteryW);
const batterySupply = plant.batteryMode === "supply";
const battCharge = batterySupply ? 0 : batterySet;
const battDischarge = batterySupply ? batterySet : 0;
let solarLeft = solar;
let batteryLeft = battDischarge;
const homeTake = takePower(home, solarLeft, batteryLeft);
solarLeft = homeTake.solarLeft;
batteryLeft = homeTake.batteryLeft;
const auxTake = takePower(aux, solarLeft, batteryLeft);
solarLeft = auxTake.solarLeft;
batteryLeft = auxTake.batteryLeft;
const hwTake = takePower(hw, solarLeft, batteryLeft);
solarLeft = hwTake.solarLeft;
batteryLeft = hwTake.batteryLeft;
const battTake = takePower(battCharge, solarLeft, batteryLeft);
solarLeft = battTake.solarLeft;
batteryLeft = battTake.batteryLeft;
const solarToGrid = solarLeft;
const batteryToGrid = batteryLeft;
const gridImport = homeTake.grid + auxTake.grid + hwTake.grid + battTake.grid;
const gridExport = solarToGrid + batteryToGrid;
const gridNet = gridImport - gridExport;
const telemetry = {
solarPower_s: solar,
loadPower_s: home + aux,
hotWaterInPower_s: hw,
batteryPower_s: battDischarge - battCharge,
gridPower_s: gridNet,
solarToLoadPower_s: homeTake.solar + auxTake.solar,
solarToHotWaterPower_s: hwTake.solar,
solarToBatteryPower_s: battTake.solar,
solarToGridPower_s: solarToGrid,
gridToLoadPower_s: homeTake.grid + auxTake.grid,
gridToHotWaterPower_s: hwTake.grid,
gridToBatteryPower_s: battTake.grid,
batteryToLoadPower_s: homeTake.battery + auxTake.battery,
batteryToHotWaterPower_s: hwTake.battery,
batteryToGridPower_s: batteryToGrid,
};
const gridState = gridNet > 0 ? "draw" : gridNet < 0 ? "supply" : "idle";
const batteryState = battCharge > 0 ? "draw" : battDischarge > 0 ? "supply" : "idle";
return {
telemetry,
rows: [
{ id: "solar", name: "Solar", watts: solar, state: flowState("supply", solar), setpoint: solar },
{
id: "grid",
name: "Grid",
watts: Math.abs(gridNet),
state: gridState,
label: gridNet > 0 ? "importing" : gridNet < 0 ? "exporting" : "",
calculated: true,
},
{ id: "home", name: "Home load", watts: home, state: flowState("draw", home), setpoint: watts(plant.homeW) },
{ id: "aux", name: "Aux", watts: aux, state: flowState("draw", aux), setpoint: watts(plant.auxW) },
{
id: "battery",
name: "Battery",
watts: batterySupply ? battDischarge : battCharge,
state: batteryState,
setpoint: batterySet,
mode: batterySupply ? "supply" : "draw",
},
{ id: "hotWater", name: "Hot water", watts: hw, state: flowState("draw", hw), setpoint: watts(plant.hotWaterW) },
],
};
} }
function remainingMs(until, now) { function remainingMs(until, now) {
@ -172,6 +272,8 @@ function evaluatePlant({ plant, attrs, tankC, now = Date.now(), nowMinutes = min
cutout: false, cutout: false,
remaining: remainingMs(plant.auxBoostUntil, now), remaining: remainingMs(plant.auxBoostUntil, now),
}), }),
battCharging: false,
battDetail: "Off",
}; };
} }
@ -185,6 +287,6 @@ module.exports = {
minutesSinceMidnight, minutesSinceMidnight,
inScheduleWindow, inScheduleWindow,
applyOneShot, applyOneShot,
applyPlantOutputs, resolvePowerSim,
evaluatePlant, evaluatePlant,
}; };

View File

@ -107,6 +107,68 @@ async function findDeviceByName(name) {
} }
} }
async function listTenantDevicePage(token, page, pageSize) {
try {
const { data } = await http.get("/api/tenant/deviceInfos", {
headers: authHeaders(token),
params: { pageSize, page, sortProperty: "createdTime", sortOrder: "DESC" },
});
return data;
} catch (error) {
if (error.response?.status !== 404 && error.response?.status !== 400) throw error;
const { data } = await http.get("/api/tenant/devices", {
headers: authHeaders(token),
params: { pageSize, page },
});
return data;
}
}
function toLocalDevice(device, accessToken) {
return {
id: device.id?.id,
name: device.name,
label: device.label || device.name,
type: device.type || config.tbDeviceType,
accessToken,
};
}
async function findDeviceByAccessToken(accessToken) {
const wanted = String(accessToken || "").trim();
if (!wanted) return null;
try {
return await withAuth(async (token) => {
const pageSize = 100;
for (let page = 0; page < 50; page += 1) {
const data = await listTenantDevicePage(token, page, pageSize);
const devices = data?.data || [];
for (let i = 0; i < devices.length; i += 8) {
const chunk = devices.slice(i, i + 8);
const found = await Promise.all(
chunk.map(async (device) => {
const deviceId = device.id?.id;
if (!deviceId) return null;
try {
const creds = await getCredentials(token, deviceId);
return creds === wanted ? toLocalDevice(device, wanted) : null;
} catch {
return null;
}
}),
);
const match = found.find(Boolean);
if (match) return match;
}
if (!data?.hasNext || devices.length === 0) break;
}
return null;
});
} catch (error) {
throw tbError(error, "Failed to look up ThingsBoard device by access token");
}
}
async function getDeviceCredentials(deviceId) { async function getDeviceCredentials(deviceId) {
try { try {
return await withAuth(async (token) => getCredentials(token, deviceId)); return await withAuth(async (token) => getCredentials(token, deviceId));
@ -255,6 +317,7 @@ module.exports = {
deleteDevice, deleteDevice,
listDeviceProfiles, listDeviceProfiles,
findDeviceByName, findDeviceByName,
findDeviceByAccessToken,
getDeviceCredentials, getDeviceCredentials,
adoptDeviceByName, adoptDeviceByName,
writeSharedAttributes, writeSharedAttributes,

View File

@ -6,7 +6,7 @@ const {
normalizePlant, normalizePlant,
unwrapAttr, unwrapAttr,
applyOneShot, applyOneShot,
applyPlantOutputs, resolvePowerSim,
evaluatePlant, evaluatePlant,
roundTenths, roundTenths,
} = require("./plant"); } = require("./plant");
@ -224,11 +224,13 @@ class VirtualDevice {
} }
plantSnapshot() { plantSnapshot() {
return evaluatePlant({ const view = evaluatePlant({
plant: this.plant, plant: this.plant,
attrs: this.sharedAttributes, attrs: this.sharedAttributes,
tankC: Number(this.telemetry.heatsink_s) || 0, tankC: Number(this.telemetry.heatsink_s) || 0,
}); });
const power = resolvePowerSim({ plant: this.plant, hwOn: view.hwOn, auxOn: view.auxOn });
return { ...view, power };
} }
expireOneShot(channel) { expireOneShot(channel) {
@ -285,16 +287,14 @@ class VirtualDevice {
const hw = after.hwOn ? 1 : 0; const hw = after.hwOn ? 1 : 0;
const aux = after.auxOn ? 1 : 0; const aux = after.auxOn ? 1 : 0;
const withPower = applyPlantOutputs(this.telemetry, { hwOn: after.hwOn }); const power = resolvePowerSim({ plant: this.plant, hwOn: after.hwOn, auxOn: after.auxOn });
const withPower = { ...this.telemetry, ...power.telemetry };
const changed = const changed =
this.telemetry.heatsink_s !== heatsink || this.telemetry.heatsink_s !== heatsink ||
this.telemetry.rlyInSel_s !== hw || this.telemetry.rlyInSel_s !== hw ||
this.telemetry.auxOn_s !== aux || this.telemetry.auxOn_s !== aux ||
this.telemetry.auxRelay_s !== aux || this.telemetry.auxRelay_s !== aux ||
this.telemetry.hotWaterInPower_s !== withPower.hotWaterInPower_s || Object.entries(power.telemetry).some(([key, value]) => this.telemetry[key] !== value);
this.telemetry.solarToHotWaterPower_s !== withPower.solarToHotWaterPower_s ||
this.telemetry.gridToHotWaterPower_s !== withPower.gridToHotWaterPower_s ||
this.telemetry.batteryToHotWaterPower_s !== withPower.batteryToHotWaterPower_s;
this.telemetry = { this.telemetry = {
...withPower, ...withPower,