another commit
This commit is contained in:
parent
b83211eb80
commit
b138466843
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
data
|
||||
.env
|
||||
.git
|
||||
deploy
|
||||
*.md
|
||||
.DS_Store
|
||||
@ -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=
|
||||
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal 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"]
|
||||
11
README.md
11
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.
|
||||
|
||||
8
deploy/Caddyfile
Normal file
8
deploy/Caddyfile
Normal 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
32
deploy/README.md
Normal 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
31
deploy/docker-compose.yml
Normal 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
77
deploy/ship.sh
Executable 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}}'"
|
||||
133
public/app.js
133
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();
|
||||
|
||||
@ -103,6 +103,46 @@
|
||||
<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>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">
|
||||
<h4>Hot water</h4>
|
||||
<div class="lamps">
|
||||
@ -113,23 +153,46 @@
|
||||
<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 class="plant-setting">
|
||||
<div class="plant-current">
|
||||
<span>Current value (°C)</span>
|
||||
<output id="tank-temp">—</output>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-row plant-fields">
|
||||
<div class="plant-setting">
|
||||
<div class="plant-current">
|
||||
<span>Current value (°C / min)</span>
|
||||
<output id="heat-rate-current">1</output>
|
||||
</div>
|
||||
<label>
|
||||
Heat rate (°C / min)
|
||||
<input id="heat-rate" type="number" min="0" max="20" step="1" />
|
||||
</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>
|
||||
Thermostat max (°C)
|
||||
<input id="thermostat" type="number" min="0" max="99" step="1" />
|
||||
</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>
|
||||
</section>
|
||||
<section class="plant-card">
|
||||
@ -143,6 +206,60 @@
|
||||
</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>
|
||||
|
||||
@ -179,31 +296,38 @@
|
||||
<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" />
|
||||
<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>
|
||||
<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" />
|
||||
<div id="add-create-fields">
|
||||
<label>
|
||||
Name / serial
|
||||
<input id="add-name" name="name" 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>
|
||||
</div>
|
||||
<label id="add-token-field" class="hidden">
|
||||
Access token
|
||||
<input id="add-token" name="accessToken" placeholder="MQTT access token from ThingsBoard" />
|
||||
</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>
|
||||
<button type="submit" id="add-submit" class="primary">Create device</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -517,16 +517,55 @@ select {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lamp.on {
|
||||
.lamp.on,
|
||||
.lamp.draw {
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@ -603,6 +642,7 @@ select {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.field-row.inactive {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
const crypto = require("crypto");
|
||||
const db = require("./db");
|
||||
const thingsboard = require("./thingsboard");
|
||||
const { VirtualDevice } = require("./virtualDevice");
|
||||
@ -96,9 +95,25 @@ function registerLocalDevice(tbDevice) {
|
||||
}
|
||||
|
||||
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();
|
||||
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;
|
||||
throw error;
|
||||
}
|
||||
@ -110,21 +125,12 @@ async function create({ name, label, type, deviceProfileId, accessToken, id }) {
|
||||
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,
|
||||
});
|
||||
const tbDevice = await thingsboard.createDevice({
|
||||
name: trimmed,
|
||||
label,
|
||||
type,
|
||||
deviceProfileId,
|
||||
});
|
||||
|
||||
return registerLocalDevice(tbDevice);
|
||||
}
|
||||
|
||||
40
src/index.js
40
src/index.js
@ -1,3 +1,4 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const express = require("express");
|
||||
const config = require("./config");
|
||||
@ -5,16 +6,47 @@ const { basicAuth, ipAllowlist } = require("./auth");
|
||||
const deviceManager = require("./deviceManager");
|
||||
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();
|
||||
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.use(
|
||||
express.static(publicDir, {
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
index: false,
|
||||
setHeaders(res) {
|
||||
noStore(res);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.get("*", (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, "..", "public", "index.html"));
|
||||
});
|
||||
app.get("*", sendIndex);
|
||||
|
||||
deviceManager.restore();
|
||||
|
||||
|
||||
134
src/plant.js
134
src/plant.js
@ -16,6 +16,13 @@ const SHARED_KEYS = [
|
||||
const DEFAULT_PLANT = {
|
||||
heatRateCpm: 1,
|
||||
thermostatC: 65,
|
||||
chargeRatePpm: 10,
|
||||
solarW: 0,
|
||||
homeW: 0,
|
||||
auxW: 0,
|
||||
batteryW: 0,
|
||||
batteryMode: "draw",
|
||||
hotWaterW: 0,
|
||||
hwBoostUntil: null,
|
||||
auxBoostUntil: null,
|
||||
lastBoostNowHw: null,
|
||||
@ -93,8 +100,16 @@ function normalizePlant(input) {
|
||||
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);
|
||||
const charge = Math.trunc(num(input.chargeRatePpm, next.chargeRatePpm));
|
||||
next.heatRateCpm = Math.max(0, Math.min(20, heat));
|
||||
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.auxBoostUntil = input.auxBoostUntil || null;
|
||||
next.lastBoostNowHw = input.lastBoostNowHw ?? null;
|
||||
@ -102,22 +117,107 @@ function normalizePlant(input) {
|
||||
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 }) {
|
||||
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 takePower(demand, solarLeft, batteryLeft) {
|
||||
const fromSolar = Math.min(solarLeft, demand);
|
||||
const afterSolar = demand - fromSolar;
|
||||
const fromBattery = Math.min(batteryLeft, afterSolar);
|
||||
const fromGrid = afterSolar - fromBattery;
|
||||
return {
|
||||
solar: fromSolar,
|
||||
battery: fromBattery,
|
||||
grid: fromGrid,
|
||||
solarLeft: solarLeft - fromSolar,
|
||||
batteryLeft: batteryLeft - fromBattery,
|
||||
};
|
||||
}
|
||||
|
||||
function flowState(kind, wattsValue) {
|
||||
if (!(wattsValue > 0)) return "idle";
|
||||
if (kind === "supply") return "supply";
|
||||
return "draw";
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -172,6 +272,8 @@ function evaluatePlant({ plant, attrs, tankC, now = Date.now(), nowMinutes = min
|
||||
cutout: false,
|
||||
remaining: remainingMs(plant.auxBoostUntil, now),
|
||||
}),
|
||||
battCharging: false,
|
||||
battDetail: "Off",
|
||||
};
|
||||
}
|
||||
|
||||
@ -185,6 +287,6 @@ module.exports = {
|
||||
minutesSinceMidnight,
|
||||
inScheduleWindow,
|
||||
applyOneShot,
|
||||
applyPlantOutputs,
|
||||
resolvePowerSim,
|
||||
evaluatePlant,
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
try {
|
||||
return await withAuth(async (token) => getCredentials(token, deviceId));
|
||||
@ -255,6 +317,7 @@ module.exports = {
|
||||
deleteDevice,
|
||||
listDeviceProfiles,
|
||||
findDeviceByName,
|
||||
findDeviceByAccessToken,
|
||||
getDeviceCredentials,
|
||||
adoptDeviceByName,
|
||||
writeSharedAttributes,
|
||||
|
||||
@ -6,7 +6,7 @@ const {
|
||||
normalizePlant,
|
||||
unwrapAttr,
|
||||
applyOneShot,
|
||||
applyPlantOutputs,
|
||||
resolvePowerSim,
|
||||
evaluatePlant,
|
||||
roundTenths,
|
||||
} = require("./plant");
|
||||
@ -224,11 +224,13 @@ class VirtualDevice {
|
||||
}
|
||||
|
||||
plantSnapshot() {
|
||||
return evaluatePlant({
|
||||
const view = evaluatePlant({
|
||||
plant: this.plant,
|
||||
attrs: this.sharedAttributes,
|
||||
tankC: Number(this.telemetry.heatsink_s) || 0,
|
||||
});
|
||||
const power = resolvePowerSim({ plant: this.plant, hwOn: view.hwOn, auxOn: view.auxOn });
|
||||
return { ...view, power };
|
||||
}
|
||||
|
||||
expireOneShot(channel) {
|
||||
@ -285,16 +287,14 @@ class VirtualDevice {
|
||||
|
||||
const hw = after.hwOn ? 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 =
|
||||
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;
|
||||
Object.entries(power.telemetry).some(([key, value]) => this.telemetry[key] !== value);
|
||||
|
||||
this.telemetry = {
|
||||
...withPower,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user