diff --git a/.changeset/nibe-local-driver.md b/.changeset/nibe-local-driver.md new file mode 100644 index 00000000..96f7a248 --- /dev/null +++ b/.changeset/nibe-local-driver.md @@ -0,0 +1,9 @@ +--- +"forty-two-watts": minor +--- + +Add a read-only NIBE S-series heat-pump driver (`drivers/nibe_local.lua`) that reads the pump's on-prem **Local REST API** directly over the LAN — no cloud, no OAuth, no internet round-trip. It auto-detects the device serial, pulls the full ~980-point register map in one bulk request, and emits every point into the long-format TS DB with **exact per-point divisor scaling** (the local API ships each point's unit + divisor, so no °C×10 heuristic). It reuses the same `hp_*` headline metrics as the MyUplink cloud driver (`hp_power_w`, `hp_hw_top_temp_c`, `hp_outdoor_temp_c`, `hp_used_power_w`, `hp_energy_consumed_kwh`, …), so the heating dashboard works with either source. The headline variable ids are auto-selected per pump from a built-in model/`firmwareId` profile map (generic S-series default, config-overridable), so one driver covers the whole S-series. Observe-only — it never writes to the pump. + +Each emitted signal also carries its source **Modbus register id**, surfaced as a **Register** column in the per-driver "all signals" detail view. This is plumbed through a new optional 4th argument to `host.emit_metric(name, value, unit, register)` (generic for any driver with addressable points; drivers that omit it are unaffected). + +Also adds **opt-in TLS certificate pinning** to the Lua HTTP host capability: `capabilities.http.tls_pin_sha256` pins an HTTPS endpoint's leaf certificate by SHA-256 fingerprint. This lets a driver reach a self-signed endpoint (the heat pump's local API) by trusting exactly one certificate — rejecting a swapped cert (LAN MITM) at the handshake — instead of disabling verification. Drivers without a pin are unaffected (standard system-root verification). diff --git a/.changeset/nibe-scan-8443.md b/.changeset/nibe-scan-8443.md new file mode 100644 index 00000000..73c096c3 --- /dev/null +++ b/.changeset/nibe-scan-8443.md @@ -0,0 +1,9 @@ +--- +"forty-two-watts": minor +--- + +Network scan now probes port 8443 (HTTPS) in addition to Modbus 502 / MQTT 1883 / +HTTP 80. On-prem device APIs that only listen on HTTPS — notably the NIBE S-series +heat pump's Local REST API — now show up in Settings → Scan and the setup wizard. +Previously a NIBE pump was pingable but invisible to discovery because its API +port wasn't probed. diff --git a/.changeset/nibe-signal-titles.md b/.changeset/nibe-signal-titles.md new file mode 100644 index 00000000..cc427b74 --- /dev/null +++ b/.changeset/nibe-signal-titles.md @@ -0,0 +1,11 @@ +--- +"forty-two-watts": minor +--- + +The heat-pump "all signals" detail view can now explain every signal. +`host.emit_metric` gained an optional 5th `title` argument — the device's own +human-readable point label — which threads through the telemetry snapshot and +`/api/drivers/{name}` (as `title`) so the UI can show a plain-language line under +each of the ~960 raw signals. The NIBE local driver passes each point's NIBE +title (e.g. "Frånluft (BT20)"); other drivers are unaffected (the arg defaults +to empty). diff --git a/config.example.yaml b/config.example.yaml index a42fae53..1e2a46e2 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -76,6 +76,25 @@ drivers: # host: sid-os.local # port: 1883 + # NIBE S-series heat pump — read-only telemetry over the on-prem Local + # REST API (HTTPS + Basic auth). The pump presents a SELF-SIGNED cert, so + # pin it: tls_pin_sha256 is the cert SHA-256 fingerprint (the + # "fingeravtryck" shown in the myUplink app, or from `openssl s_client + # -connect :8443 | openssl x509 -fingerprint -sha256`). The host then + # trusts exactly that one cert — never a blanket insecure-skip-verify. + # Leave the pump in read-only mode (aidMode off); this driver never writes. + # - name: nibe + # lua: drivers/nibe_local.lua + # config: + # host: 192.168.1.180 + # port: 8443 + # username: + # password: + # capabilities: + # http: + # allowed_hosts: ["192.168.1.180:8443"] + # tls_pin_sha256: "<64-hex-char certificate fingerprint>" + api: port: 8080 diff --git a/docs/nibe-local.md b/docs/nibe-local.md new file mode 100644 index 00000000..0135c107 --- /dev/null +++ b/docs/nibe-local.md @@ -0,0 +1,143 @@ +# NIBE S-series — Local REST API driver + +`drivers/nibe_local.lua` reads a NIBE S-series heat pump (S735, S1255, S1155, +S320, …) directly over your LAN through the pump's **Local REST API**. It is +the on-prem twin of the MyUplink cloud driver (`drivers/myuplink.lua`): same +`hp_*` telemetry, no cloud account, no OAuth, no internet round-trip. + +It is **read-only**. The pump is left in `aidMode: off` and the driver issues +no writes — it cannot actuate anything, so it cannot cause harm. + +## Why the local API (vs. the cloud or raw Modbus) + +The Local REST API returns, in one bulk request, ~980 data points — each with +its own metadata: modbus register, unit, **exact divisor**, and a writable +flag. That means: + +- **Exact scaling.** A temperature with `divisor: 10` becomes °C precisely; a + power with `divisor: 100` becomes kW precisely. No °C×10 guessing like the + cloud driver has to do. +- **The whole register map, for life.** Every point lands in the long-format + TS DB via `host.emit_metric`, queryable forever (see `docs/tsdb.md`). To keep + a Pi-sized database bounded, headline metrics are sampled every minute while + the bulk map records changes plus an hourly full snapshot. +- **No cloud dependency.** Works on an isolated LAN with no WAN. + +## One-time setup on the pump + +1. In the NIBE **myUplink** app, enable the **Local REST API** for the pump and + note the generated **username** and **password**. +2. The app shows the API's certificate **fingerprint** ("fingeravtryck"). You + will pin this. You can also read it yourself: + + ```bash + openssl s_client -connect :8443 -servername /dev/null \ + | openssl x509 -noout -fingerprint -sha256 + ``` + + Use the hex value (colons and case don't matter — they're normalised). +3. Leave the pump in **read-only** mode for this driver. + +## Configuration + +```yaml +drivers: + - name: nibe + lua: drivers/nibe_local.lua + config: + host: 192.168.1.180 + port: 8443 # default + username: + password: # masked via config_secrets + # device_id: "06613225140002" # optional; auto-detected if omitted + capabilities: + http: + allowed_hosts: ["192.168.1.180:8443"] + tls_pin_sha256: "<64-hex-char certificate fingerprint>" +``` + +### Why pin the certificate + +The pump presents a **self-signed** certificate, which the system trust store +cannot validate. The wrong fix is to disable verification — that would accept +*any* certificate, letting a LAN man-in-the-middle present its own cert and +capture the Basic-auth password (Basic auth sends `base64(user:pass)` on every +request — reversible, not encryption). + +Instead, `tls_pin_sha256` pins **exactly one** leaf certificate. A swapped cert +is rejected at the TLS handshake even if it chains to a real CA. This is the +same fingerprint-pinning approach the project uses for its DTLS/WebRTC identity. +Drivers without a pin keep standard system-root verification — pinning is +strictly opt-in and changes nothing for other HTTP drivers. + +Keep the pump on a trusted LAN / IoT VLAN and never expose port 8443 to the +internet. + +## Telemetry + +Canonical headline metrics (stable names the heating dashboard + thermal twin +read). Their variable ids are **auto-selected per pump** from a built-in +profile map keyed by the device's `firmwareId`/model reported by +`GET /api/v1/devices` — the generic S-series default below is verified on the +S735 and, because the S-series shares the core register ids, covers the whole +family. To support a model that genuinely renumbers a headline, add an entry to +`PROFILES` in `drivers/nibe_local.lua`. You can always override any id per +instance via `param_power_id`, `param_hw_temp_id`, `param_indoor_temp_id`, +`param_outdoor_temp_id`, etc. in `config` (override > profile > default): + +| Metric | Unit | Source (S735 id) | +|---|---|---| +| `hp_power_w` | W | Compressor power input (1801) | +| `hp_used_power_w` | W | Instantaneous used power (22130) | +| `hp_hw_top_temp_c` | °C | Hot water top BT7 (11) | +| `hp_outdoor_temp_c` | °C | Outdoor BT1 (4) | +| `hp_indoor_temp_c` | °C | Room BT50 (158) — absent if no room sensor | +| `hp_energy_consumed_kwh` | kWh | Tot. consumption (28393) | +| `hp_energy_produced_kwh` | kWh | Tot. production (28392) | +| `hp_degree_minutes` | DM | Degree minutes (781) | + +Every other point auto-emits as `hp_` with its unit, so the +UI can group temperatures / power / frequency / state automatically. + +**Not-connected sensors.** An unconnected sensor reports a per-size sentinel +(`-32768` for s16, etc.) and the API still flags it `isOk: true`, so the driver +filters by variable size — a missing BT50 room sensor simply doesn't emit, +rather than logging −3276.8 °C. + +## Site sign convention + +A heat pump is a **load**. Its electrical draw is positive watts into the site +at the grid boundary — but this driver emits **diagnostics only** +(`host.emit_metric`), never `host.emit("meter"|"pv"|"battery")`. So it performs +no sign conversion and never double-counts against the real grid meter; the +thermal/load models consume `hp_power_w` etc. as twins. + +## Troubleshooting + +- **`tls pin mismatch`** in the logs → the pump's cert changed (firmware update + / factory reset). Re-read the fingerprint (command above) and update + `tls_pin_sha256`. +- **`host … not in allowed_hosts`** → add `":8443"` to + `capabilities.http.allowed_hosts`. +- **No metrics / `points poll failed`** → confirm the Local REST API is still + enabled in myUplink and the username/password are current; the driver + self-heals device detection on a 30 s backoff. +- **Verify by hand**: + + ```bash + curl -sk -u ':' https://:8443/api/v1/devices + ``` + +## Testing + +A live integration test exercises the driver against a real pump +(`go/internal/drivers/nibe_local_test.go`, `TestNibeLocalLive`), skipped unless +`NIBE_LIVE=1`: + +```bash +NIBE_LIVE=1 NIBE_HOST=192.168.1.180 NIBE_USER=… NIBE_PASS=… \ + NIBE_PIN= go test ./go/internal/drivers/ -run TestNibeLocalLive -v +``` + +`TestNibeLocalEmitsTelemetry` is the hermetic version (fake pump) that runs in +CI. diff --git a/drivers/nibe_local.lua b/drivers/nibe_local.lua new file mode 100644 index 00000000..2afa19bd --- /dev/null +++ b/drivers/nibe_local.lua @@ -0,0 +1,423 @@ +-- NIBE S-series Heat Pump — LOCAL REST API driver (READ-ONLY telemetry) +-- Emits: metrics only (compressor power, energy meters, temperatures, …) +-- into the long-format TS DB via host.emit_metric. NO control. +-- Protocol: HTTPS (NIBE "Local REST API", self-described at https://:8443/) +-- +-- This is the local-network twin of drivers/myuplink.lua. Instead of the +-- MyUplink cloud (OAuth + internet round-trip), it reads the pump directly +-- over the LAN. The local API is RICHER than the cloud one: every point +-- ships its own metadata (modbus register, unit, exact divisor, writable +-- flag), so scaling is exact — no °C×10 heuristic. ~980 points come back in +-- one bulk GET. Headline metrics land every minute; the bulk map records +-- changes plus an hourly full snapshot so the TS DB stays bounded on a Pi. +-- +-- Observe-only by design: the pump is left in read-only mode (aidMode=off), +-- so this driver cannot actuate anything and cannot cause harm. +-- +-- Site sign convention: a heat pump is a LOAD. Its electrical draw would be +-- positive W flowing into the site at the grid boundary — but this driver +-- emits diagnostics via host.emit_metric only (never host.emit("meter"|…)), +-- so it performs NO sign conversion and never double-counts against the real +-- grid meter. The thermal/load models consume hp_power_w etc. as twins. +-- +-- AUTH + TRANSPORT: +-- The local API uses HTTP Basic auth over HTTPS with a SELF-SIGNED +-- certificate. The system trust store can't validate it, so the driver +-- relies on certificate PINNING in the host: grant +-- capabilities.http.tls_pin_sha256 with the pump's cert fingerprint +-- (the "fingeravtryck" shown in the myUplink app, or from +-- `openssl s_client -connect :8443 | openssl x509 -fingerprint -sha256`). +-- That pins exactly one leaf cert — a swapped cert (MITM on the LAN, which +-- would otherwise capture the Basic-auth password) is rejected at the +-- handshake. Do NOT fall back to blanket insecure-skip-verify. +-- +-- Config example (config.yaml): +-- drivers: +-- - name: nibe +-- lua: drivers/nibe_local.lua +-- config: +-- host: "192.168.1.180" +-- port: 8443 +-- username: "" +-- password: "" # masked via config_secrets +-- # device_id: "..." # optional; auto-detected if omitted +-- capabilities: +-- http: +-- allowed_hosts: ["192.168.1.180:8443"] +-- tls_pin_sha256: "<64-hex-char certificate fingerprint>" +-- +-- The four heating-UI headline metrics map to NIBE S735 variable ids by +-- default; override per model via param_power_id / param_hw_temp_id / +-- param_indoor_temp_id / param_outdoor_temp_id if yours differs (find them +-- in the bulk GET /api/v1/devices//points). + +DRIVER = { + id = "nibe-local", + name = "NIBE REST API S-series", + manufacturer = "NIBE", + version = "1.0.0", + protocols = { "http" }, + capabilities = { "apicreds" }, + description = "Read-only NIBE S-series heat-pump telemetry over the on-prem Local REST API (HTTPS + Basic auth, self-signed cert pinned via tls_pin_sha256). Emits compressor/used power, lifetime energy meters, and the full ~980-point register map. Observe-only — no control.", + homepage = "https://www.nibe.eu", + authors = { "HuggeK", "forty-two-watts contributors" }, + tested_models = { "NIBE S735" }, + verification_status = "beta", + config_secrets = { "password" }, + connection_defaults = { port = 8443 }, +} + +PROTOCOL = "http" + +-- ---- Runtime state ------------------------------------------------------- + +local base_url = nil -- https://: +local auth_value = nil -- "Basic " +local serial = nil -- device id (NIBE serial number) used in the path + +-- Self-heal: the pump can be briefly unreachable at boot / after a network +-- blip. Rather than wedge on a nil serial (which needed a manual restart), +-- driver_poll retries device detection on this backoff. +local SETUP_RETRY_MS = 30000 +local last_setup_ms = nil +local POLL_INTERVAL_MS = 60000 +-- Headline metrics are emitted every poll. The remaining ~980-point map is +-- change-only, with an hourly full refresh so latest-value timestamps stay +-- useful without writing ~1.4 million mostly-duplicate TS rows per day. +local FULL_REFRESH_MS = 3600000 +local last_full_emit_ms = nil +local last_emitted = {} + +-- ---- Headline metrics + per-model profiles ------------------------------- +-- The BULK of telemetry is metadata-driven (every point self-describes its +-- unit + divisor), so reading any S-series pump needs NO per-model code. The +-- only model-specific knobs are the handful of STABLE headline aliases +-- (hp_power_w, hp_outdoor_temp_c, …) that web/heating.js + the thermal twin +-- read by fixed name. Each maps to a local-API variableId, resolved per pump +-- in priority order: explicit config override > model profile > generic +-- S-series default. + +-- Logical headline -> { config override key, emitted metric name, watts? }. +local HEADLINES = { + { key = "power", cfg = "param_power_id", name = "hp_power_w", watts = true }, + { key = "used", cfg = "param_used_id", name = "hp_used_power_w", watts = true }, + { key = "hw", cfg = "param_hw_temp_id", name = "hp_hw_top_temp_c" }, + { key = "indoor", cfg = "param_indoor_temp_id", name = "hp_indoor_temp_c" }, + { key = "outdoor", cfg = "param_outdoor_temp_id", name = "hp_outdoor_temp_c" }, + { key = "econs", cfg = "param_energy_consumed_id", name = "hp_energy_consumed_kwh" }, + { key = "eprod", cfg = "param_energy_produced_id", name = "hp_energy_produced_kwh" }, + { key = "dm", cfg = "param_degree_minutes_id", name = "hp_degree_minutes" }, +} + +-- Per-model headline variable-id profiles, auto-selected from the pump's +-- product.name / firmwareId (GET /api/v1/devices), matched case-insensitively +-- as a substring. The S-series shares the core register ids, so `default` +-- covers the whole family (verified on an S735); add a model entry ONLY when +-- a specific model is confirmed to renumber a headline. A profile may set +-- just the keys that differ — the rest fall back to default. +local PROFILES = { + default = { -- generic NIBE S-series (verified: S735) + power = "1801", used = "22130", hw = "11", indoor = "158", + outdoor = "4", econs = "28393", eprod = "28392", dm = "781", + }, + -- Example — uncomment + verify the ids against GET …/points before use: + -- ["s320"] = { power = "1801", hw = "11" }, +} + +local CANON = {} -- id(string) -> { name = "...", watts = bool } +local driver_config = nil -- kept so CANON can be rebuilt once the model is known +local device_model = nil -- product.name reported by the pump (may be "" / nil) +local device_firmware = nil -- product.firmwareId (e.g. "nibe-n") + +-- ---- Helpers ------------------------------------------------------------- + +-- Pure-Lua base64 (no host builtin). Used once per init to build the +-- Basic-auth header value. +local b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +local function base64_encode(data) + return ((data:gsub('.', function(x) + local r, b = '', x:byte() + for i = 8, 1, -1 do r = r .. (b % 2 ^ i - b % 2 ^ (i - 1) > 0 and '1' or '0') end + return r + end) .. '0000'):gsub('%d%d%d?%d?%d?%d?', function(x) + if #x < 6 then return '' end + local c = 0 + for i = 1, 6 do c = c + (x:sub(i, i) == '1' and 2 ^ (6 - i) or 0) end + return b64chars:sub(c + 1, c + 1) + end) .. ({ '', '==', '=' })[#data % 3 + 1]) +end + +-- The "not connected" sentinel a NIBE variable reports per size. An +-- unconnected sensor returns this (e.g. an absent BT50 room sensor is +-- -32768 for s16) — and the API marks it isOk=true anyway, so we filter +-- by size, not by isOk. +local function size_sentinel(size) + if size == "s8" then return -128 end + if size == "s16" then return -32768 end + if size == "s32" then return -2147483648 end + if size == "u8" then return 255 end + if size == "u16" then return 65535 end + if size == "u32" then return 4294967295 end + return nil +end + +-- Turn a point title into a stable hp_ snake_case metric name. NIBE titles +-- embed soft hyphens (U+00AD = bytes 0xC2 0xAD) inside long words +-- ("Compres­sor", "Instant­aneous"); strip them first so the name reads +-- "compressor", not "compres_sor". Remaining non-ASCII / punctuation +-- collapses to single underscores. Empty falls back to the id. +local function sanitize_metric_name(title, id) + local s = title or "" + s = string.gsub(s, "\194\173", "") -- soft hyphen + s = string.lower(s) + s = string.gsub(s, "[^a-z0-9]+", "_") + s = string.gsub(s, "^_+", "") + s = string.gsub(s, "_+$", "") + if s == "" then s = "p" .. tostring(id) end + return "hp_" .. s +end + +-- Watts normalisation for the power headline metrics: some models report +-- compressor power in kW, others in W. Emit W either way. +local function to_watts(value, unit) + if unit == "kW" then return value * 1000.0, "W" end + return value, (unit ~= "" and unit or "W") +end + +-- The NIBE Modbus register id for a point (metadata.modbusRegisterID), formatted +-- as a string for host.emit_metric's optional 4th (register) arg. Surfaced in +-- the per-driver "all signals" detail view so each signal shows its source +-- register. 0 / absent means the point has no Modbus mapping (menu-only) — emit +-- "" so the column stays blank rather than showing a misleading "0". +local function register_str(m) + local r = tonumber(m.modbusRegisterID) + if r and r ~= 0 then return string.format("%d", r) end + return "" +end + +-- Validate and scale one API point. Returns nil for malformed values and the +-- per-size "not connected" sentinel. +local function scaled_point(pt) + local m = pt and pt.metadata + local v = pt and pt.value + if type(m) ~= "table" or type(v) ~= "table" or type(v.integerValue) ~= "number" then + return nil + end + local raw = v.integerValue + local sentinel = size_sentinel(m.variableSize) + if sentinel and raw == sentinel then return nil end + local div = tonumber(m.divisor) or 1 + if div == 0 then div = 1 end + return raw / div, m.unit or "", register_str(m) +end + +-- Build the id -> canonical-metric lookup. Each headline's id is resolved +-- explicit config override > model profile > generic default. +local function build_canon(profile, config) + config = config or {} + profile = profile or PROFILES.default + local function s(v) return (v ~= nil and v ~= "") and tostring(v) or nil end + CANON = {} + for _, h in ipairs(HEADLINES) do + local id = s(config[h.cfg]) or s(profile[h.key]) or s(PROFILES.default[h.key]) + if id then CANON[id] = { name = h.name, watts = h.watts } end + end +end + +-- Pick the model profile whose key appears (case-insensitively, as a +-- substring) in the pump's product.name or firmwareId. Falls back to the +-- generic default, which covers the whole S-series. +local function select_profile(model_name, firmware_id) + local n = string.lower(model_name or "") + local f = string.lower(firmware_id or "") + for k, prof in pairs(PROFILES) do + if k ~= "default" then + if (n ~= "" and string.find(n, k, 1, true)) or + (f ~= "" and string.find(f, k, 1, true)) then + return k, prof + end + end + end + return "default", PROFILES.default +end + +local function auth_headers() + return { Authorization = auth_value, Accept = "application/json" } +end + +local function api_get(path) + local resp, err = host.http_get(base_url .. path, auth_headers()) + if err then return nil, tostring(err) end + local data = host.json_decode(resp) + if not data then return nil, "json decode failed" end + return data, nil +end + +-- ---- Setup --------------------------------------------------------------- + +local function detect_serial() + local data, err = api_get("/api/v1/devices") + if err then + host.log("warn", "NIBE: /api/v1/devices failed: " .. err) + return nil + end + local devs = data.devices + if type(devs) == "table" and devs[1] and devs[1].product then + local p = devs[1].product + device_model = p.name + device_firmware = p.firmwareId + if p.serialNumber and p.serialNumber ~= "" then + host.log("info", "NIBE: detected " .. tostring(p.manufacturer) .. + " '" .. tostring(p.name) .. "' " .. tostring(p.serialNumber) .. + " (fw " .. tostring(p.firmwareId) .. ")") + return p.serialNumber + end + end + host.log("error", "NIBE: no device serial in /api/v1/devices response") + return nil +end + +-- Bring the driver to "ready" (serial known). Safe to call repeatedly; +-- rate-limited by SETUP_RETRY_MS. Returns true once serial is established. +local function try_setup() + if serial then return true end + local now = host.millis() + if last_setup_ms ~= nil and (now - last_setup_ms) < SETUP_RETRY_MS then + return false + end + last_setup_ms = now + serial = detect_serial() + if not serial then return false end + host.set_sn(serial) + -- Now that the model is known, refine the headline ids (config overrides + -- still win inside build_canon). + local pkey, prof = select_profile(device_model, device_firmware) + build_canon(prof, driver_config) + host.log("info", "NIBE: ready (read-only) serial=" .. serial .. " profile=" .. pkey) + return true +end + +-- ---- Lifecycle ----------------------------------------------------------- + +function driver_init(config) + host.set_make("NIBE") + config = config or {} + + local function s(v) return (v ~= nil and v ~= "") and tostring(v) or nil end + local username = s(config.username) or "" + local password = s(config.password) or "" + serial = s(config.device_id) + + -- base_url override exists for tests; production builds it from host:port. + base_url = s(config.base_url) + if not base_url then + local host_ip = s(config.host) + local port = s(config.port) or "8443" + if host_ip then base_url = "https://" .. host_ip .. ":" .. port end + end + auth_value = "Basic " .. base64_encode(username .. ":" .. password) + + if config.poll_interval_ms ~= nil then + POLL_INTERVAL_MS = tonumber(config.poll_interval_ms) or POLL_INTERVAL_MS + end + if config.setup_retry_ms ~= nil then + SETUP_RETRY_MS = tonumber(config.setup_retry_ms) or SETUP_RETRY_MS + end + if config.full_refresh_ms ~= nil then + FULL_REFRESH_MS = tonumber(config.full_refresh_ms) or FULL_REFRESH_MS + end + + -- Build the headline lookup with the generic profile now; try_setup + -- refines it to the detected model's profile once the pump answers. + -- Config overrides (param_*_id) win in build_canon either way. + driver_config = config + build_canon(PROFILES.default, config) + + if not base_url then + host.log("error", "NIBE: 'host' (pump IP) is required") + return + end + if username == "" or password == "" then + host.log("error", "NIBE: username and password are required") + return + end + + host.set_poll_interval(POLL_INTERVAL_MS) + -- Best-effort initial detection; driver_poll self-heals if it fails. + if not try_setup() then + host.log("warn", "NIBE: initial setup did not complete — will retry automatically") + end +end + +function driver_poll() + if not base_url then return SETUP_RETRY_MS end + if not serial then + if not try_setup() then return SETUP_RETRY_MS end + end + + local data, err = api_get("/api/v1/devices/" .. serial .. "/points") + if err then + host.log("warn", "NIBE: points poll failed: " .. err) + return POLL_INTERVAL_MS + end + + local now = host.millis() + local full_refresh = last_full_emit_ms == nil or (now - last_full_emit_ms) >= FULL_REFRESH_MS + + -- Titles are not guaranteed unique. Count sanitized names first so every + -- collision gets an id suffix deterministically; the old one-pass `seen` + -- approach made whichever point happened to appear first change names + -- across polls because Lua table iteration order is unspecified. + local name_counts = {} + for id, pt in pairs(data) do + if not CANON[tostring(id)] then + local scaled = scaled_point(pt) + if scaled ~= nil then + local base = sanitize_metric_name(pt.title, id) + name_counts[base] = (name_counts[base] or 0) + 1 + end + end + end + + for id, pt in pairs(data) do + local scaled, unit, reg = scaled_point(pt) + if scaled ~= nil then + local canon = CANON[tostring(id)] + local name = canon and canon.name or sanitize_metric_name(pt.title, id) + if not canon and (name_counts[name] or 0) > 1 then + name = name .. "_" .. tostring(id) + end + local value = scaled + if canon and canon.watts then value, unit = to_watts(scaled, unit) end + + -- Stable headline series retain one-minute resolution. The bulk + -- map records transitions plus an hourly complete snapshot. + if canon or full_refresh or last_emitted[name] ~= value then + host.emit_metric(name, value, unit, reg, pt.title) + last_emitted[name] = value + end + end + end + if full_refresh then last_full_emit_ms = now end + -- Guarantees watchdog freshness even on a model whose configured headline + -- ids are absent and whose non-headline values remain unchanged. + host.emit_metric("hp_poll_ok", 1, "") + + return POLL_INTERVAL_MS +end + +function driver_command(_action, _power_w, _cmd) + -- Read-only: no actuation. The pump stays in aidMode=off. + return false +end + +function driver_default_mode() + -- Read-only: nothing to release. +end + +function driver_cleanup() + serial = nil + last_setup_ms = nil + last_full_emit_ms = nil + last_emitted = {} +end diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 6ca6d376..89e9d9c3 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1748,6 +1748,35 @@ func parseRange(s string) int64 { case "3d": return 3 * 24 * 60 * 60 * 1000 } + // Generic "" (m/h/d/w/y) so charts + energy-period windows can ask + // for longer spans (e.g. 30d for the month temp graph, 366d for "this year" + // energy). Capped at 2 years to bound the TS-DB scan. + if len(s) >= 2 { + if n, err := strconv.Atoi(s[:len(s)-1]); err == nil && n > 0 { + var mult int64 + switch s[len(s)-1] { + case 'm': + mult = 60 * 1000 + case 'h': + mult = 60 * 60 * 1000 + case 'd': + mult = 24 * 60 * 60 * 1000 + case 'w': + mult = 7 * 24 * 60 * 60 * 1000 + case 'y': + mult = 365 * 24 * 60 * 60 * 1000 + } + if mult > 0 { + maxMs := int64(2) * 365 * 24 * 60 * 60 * 1000 + // Cap before multiplication so an attacker-controlled, very large + // N cannot overflow int64 and turn the requested window negative. + if int64(n) > maxMs/mult { + return maxMs + } + return int64(n) * mult + } + } + } return 5 * 60 * 1000 } diff --git a/go/internal/api/api_test.go b/go/internal/api/api_test.go index 5eaff89b..becb091d 100644 --- a/go/internal/api/api_test.go +++ b/go/internal/api/api_test.go @@ -31,6 +31,13 @@ func TestParseRangeSupports48h(t *testing.T) { } } +func TestParseRangeCapsBeforeMultiplication(t *testing.T) { + const max = int64(2) * 365 * 24 * 60 * 60 * 1000 + if got := parseRange("999999999999999999d"); got != max { + t.Fatalf("parseRange(huge) = %d, want capped %d", got, max) + } +} + // handleEVCommand rejects anything not in the allowlist with 400. The Lua // driver's command hook silently returns nil for unknown actions, so // without this gate the API would 200-OK typos. diff --git a/go/internal/config/config.go b/go/internal/config/config.go index eec62216..b2ecbd1c 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -598,6 +598,16 @@ type ModbusConfig struct { // HTTPCapability grants HTTP access to specific hostnames (future). type HTTPCapability struct { AllowedHosts []string `yaml:"allowed_hosts" json:"allowed_hosts"` + // TLSPinSHA256, when set, pins the HTTPS server's leaf certificate to + // this SHA-256 fingerprint (hex; colons/whitespace ignored, case- + // insensitive). It is the SHA-256 over the DER certificate — identical + // to `openssl x509 -fingerprint -sha256`. Use it for HTTPS endpoints + // that present a self-signed certificate the system trust store cannot + // validate (e.g. a NIBE heat pump's local REST API). When set, normal + // chain/hostname verification is REPLACED by an exact fingerprint match + // for this driver only; when empty, standard verification against the + // system roots applies (unchanged for every existing HTTP driver). + TLSPinSHA256 string `yaml:"tls_pin_sha256,omitempty" json:"tls_pin_sha256,omitempty"` } // WSCapability grants WebSocket (ws://, wss://) access. Same allowlist diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index e9e69133..b66842dc 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -78,6 +78,16 @@ type HostEnv struct { // backward compat with existing drivers that didn't declare a list. // Populated from driver config `capabilities.http.allowed_hosts`. HTTPAllowedHosts []string + // HTTPTLSPinSHA256, when non-empty, pins the HTTPS leaf certificate to + // this SHA-256 fingerprint (hex; colons/whitespace ignored, case- + // insensitive — same value as `openssl x509 -fingerprint -sha256`). + // When set, the http_* client for THIS driver replaces system-root + // chain verification with an exact leaf-fingerprint match, so a driver + // can talk to a self-signed HTTPS endpoint (e.g. a heat pump's local + // REST API) without trusting any other certificate. Empty = standard + // verification (unchanged default for all existing HTTP drivers). + // Populated from driver config `capabilities.http.tls_pin_sha256`. + HTTPTLSPinSHA256 string WS WSCap // nil → ws_* calls return ErrNoCapability // WSAllowedHosts mirrors HTTPAllowedHosts but for ws://+wss:// URLs // passed to host.ws_open. Same matching semantics; empty = any host. @@ -149,6 +159,14 @@ func (h *HostEnv) WithHTTPAllowedHosts(hosts []string) *HostEnv { return h } +// WithHTTPTLSPin pins the HTTPS leaf certificate this driver's http_* +// calls will accept, by SHA-256 fingerprint. Empty string = no pin +// (standard system-root verification). See HostEnv.HTTPTLSPinSHA256. +func (h *HostEnv) WithHTTPTLSPin(fp string) *HostEnv { + h.HTTPTLSPinSHA256 = fp + return h +} + // WithWS binds a WebSocket capability. func (h *HostEnv) WithWS(w WSCap) *HostEnv { h.WS = w; return h } @@ -278,14 +296,14 @@ func (h *HostEnv) emitTelemetry(rawJSON []byte) error { // Driver authors call this for anything beyond the standard pv/battery/meter // shape — temperatures, voltages, frequencies, MPPT currents, etc. unit is an // optional display unit (e.g. "°C", "Hz") used by the UI to group + label. -func (h *HostEnv) emitMetric(name string, value float64, unit string) error { +func (h *HostEnv) emitMetric(name string, value float64, unit, register, title string) error { if math.IsNaN(value) || math.IsInf(value, 0) { return fmt.Errorf("emit_metric: %s is non-finite: %v", name, value) } if h.Telemetry == nil { return nil } - h.Telemetry.EmitMetric(h.DriverName, name, value, unit) + h.Telemetry.EmitMetric(h.DriverName, name, value, unit, register, title) // A metric emission is fresh telemetry just like a structured emit, so // it counts as a health success. Without this, a read-only driver that // only uses emit_metric (e.g. the MyUplink heat-pump telemetry driver) diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 66ed2b6d..9e47873c 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -44,6 +44,10 @@ package drivers import ( "context" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "encoding/hex" "encoding/json" "fmt" "io" @@ -267,18 +271,24 @@ func registerHost(L *lua.LState, env *HostEnv) { return 0 })) - // host.emit_metric("battery_temp_c", 23.5 [, "°C"]) — record an arbitrary - // scalar diagnostic into the long-format TS DB. Use for anything that - // doesn't fit the structured pv/battery/meter shape: temperatures, DC + // host.emit_metric("battery_temp_c", 23.5 [, "°C" [, "40004"]]) — record an + // arbitrary scalar diagnostic into the long-format TS DB. Use for anything + // that doesn't fit the structured pv/battery/meter shape: temperatures, DC // voltages, MPPT currents, grid frequency, inverter heat-sink, etc. // The metric name is the column name in the time-series — pick a stable - // snake_case identifier with the unit as a suffix. The optional 3rd arg - // is a display unit (e.g. "°C", "Hz", "kW") the UI uses to group + label. + // snake_case identifier with the unit as a suffix. The optional 3rd arg is a + // display unit (e.g. "°C", "Hz", "kW") the UI uses to group + label; the + // optional 4th arg is a source register/address (e.g. a Modbus register id) + // surfaced in the per-driver detail view; the optional 5th arg is a + // human-readable title (the device's own point label) shown as the + // per-signal explanation in the detail view. host.RawSetString("emit_metric", L.NewFunction(func(L *lua.LState) int { name := L.CheckString(1) val := float64(L.CheckNumber(2)) unit := L.OptString(3, "") - if err := env.emitMetric(name, val, unit); err != nil { + register := L.OptString(4, "") + title := L.OptString(5, "") + if err := env.emitMetric(name, val, unit, register, title); err != nil { L.Push(lua.LString(err.Error())) return 1 } @@ -525,6 +535,9 @@ func registerHost(L *lua.LState, env *HostEnv) { // host.http_get(url, headers?) → (body, nil) or (nil, error_string) // host.http_post(url, body, headers?) → (body, nil) or (nil, error_string) // headers is an optional Lua table {["Content-Type"]="application/json", ...} + rawTLSPin := strings.TrimSpace(env.HTTPTLSPinSHA256) + tlsPin := normalizeHexFingerprint(rawTLSPin) + // hostAllowed checks the URL's host component against the // per-driver allowlist. Empty allowlist = any host (legacy // behaviour). Matched case-insensitively. @@ -553,6 +566,18 @@ func registerHost(L *lua.LState, env *HostEnv) { default: return false, fmt.Sprintf("scheme %q not supported (http/https only)", u.Scheme) } + // A configured pin is a security requirement, not a hint. Reject a + // malformed value before making any request, and never let a pinned + // driver downgrade to clear-text HTTP (which would expose Basic-auth + // credentials on the LAN). + if rawTLSPin != "" { + if tlsPin == "" { + return false, "tls_pin_sha256 must contain exactly 64 hexadecimal characters" + } + if !strings.EqualFold(u.Scheme, "https") { + return false, "tls_pin_sha256 requires an https URL" + } + } if len(env.HTTPAllowedHosts) == 0 { return true, "" } @@ -597,6 +622,36 @@ func registerHost(L *lua.LState, env *HostEnv) { }, } + // TLS certificate pinning (opt-in, per driver). When the driver was + // granted capabilities.http.tls_pin_sha256, we DON'T trust the system + // roots — instead we accept exactly the one leaf certificate whose + // SHA-256 matches the pin. This is how a driver reaches an HTTPS + // endpoint with a self-signed cert (a NIBE heat pump's local REST API) + // without the SSRF-grade hole of blanket InsecureSkipVerify: a swapped + // cert (MITM) is rejected at the handshake even if it chains to a real + // CA. Drivers WITHOUT a pin keep Go's default transport untouched, so + // nothing about existing HTTP drivers changes. + if pin := tlsPin; pin != "" { + tr := net_http.DefaultTransport.(*net_http.Transport).Clone() + tr.TLSClientConfig = &tls.Config{ + // We replace chain/hostname verification with our own exact + // fingerprint check below, so the stdlib check must be off. + InsecureSkipVerify: true, //nolint:gosec // verified by pin in VerifyConnection + VerifyConnection: func(cs tls.ConnectionState) error { + if len(cs.PeerCertificates) == 0 { + return fmt.Errorf("tls pin: server presented no certificate") + } + sum := sha256.Sum256(cs.PeerCertificates[0].Raw) + got := hex.EncodeToString(sum[:]) + if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) != 1 { + return fmt.Errorf("tls pin mismatch: leaf %s does not match pinned %s", got, pin) + } + return nil + }, + } + httpClient.Transport = tr + } + applyHeaders := func(req *net_http.Request, L *lua.LState, argIdx int) { tbl := L.OptTable(argIdx, nil) if tbl == nil { @@ -881,6 +936,35 @@ func splitHostPortLower(s string) (host, port string, hasPort bool) { return s[:i], maybePort, true } +// normalizeHexFingerprint canonicalises a SHA-256 certificate fingerprint +// for comparison: strips the colons / spaces that `openssl -fingerprint` +// emits and lower-cases the hex, so "73:D1:AC:..." and +// "73d1ac..." compare equal. A value that doesn't reduce to exactly 64 +// hex chars returns "" (treated as "no usable pin"). +func normalizeHexFingerprint(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= '0' && r <= '9': + b.WriteRune(r) + case r >= 'a' && r <= 'f': + b.WriteRune(r) + case r >= 'A' && r <= 'F': + b.WriteRune(r + ('a' - 'A')) + case r == ':' || r == ' ' || r == '\t' || r == '\n' || r == '\r': + // separators in openssl-style fingerprints — skip + default: + // any other character makes the pin unusable + return "" + } + } + out := b.String() + if len(out) != 64 { + return "" + } + return out +} + func modbusKindFromString(s string) (int32, bool) { switch s { case "coil": diff --git a/go/internal/drivers/lua_http_test.go b/go/internal/drivers/lua_http_test.go index 44e18ad6..6df6b292 100644 --- a/go/internal/drivers/lua_http_test.go +++ b/go/internal/drivers/lua_http_test.go @@ -2,6 +2,8 @@ package drivers import ( "context" + "crypto/sha256" + "encoding/hex" "net/http" "net/http/httptest" "os" @@ -20,11 +22,18 @@ import ( func runHTTPTestDriver(t *testing.T, allowed []string, targetURL string) (gotBody bool, errMsg string) { t.Helper() - tel := telemetry.NewStore() - env := NewHostEnv("httptest", tel).WithHTTP() + env := NewHostEnv("httptest", telemetry.NewStore()).WithHTTP() if allowed != nil { env.WithHTTPAllowedHosts(allowed) } + return runHTTPDriverWithEnv(t, env, targetURL) +} + +// runHTTPDriverWithEnv runs a minimal http_get driver against targetURL +// with a caller-supplied HostEnv, so tests can exercise TLS pinning and +// other per-driver HTTP settings, not just the allowlist. +func runHTTPDriverWithEnv(t *testing.T, env *HostEnv, targetURL string) (gotBody bool, errMsg string) { + t.Helper() src := ` function driver_init() end function driver_poll() @@ -57,10 +66,10 @@ func runHTTPTestDriver(t *testing.T, allowed []string, targetURL string) (gotBod if _, err := d.Poll(context.Background()); err != nil { t.Fatalf("poll: %v", err) } - if v, _, ok := tel.LatestMetric("httptest", "result_ok"); ok && v == 1 { + if v, _, ok := env.Telemetry.LatestMetric(env.DriverName, "result_ok"); ok && v == 1 { return true, "" } - if v, _, ok := tel.LatestMetric("httptest", "result_err"); ok && v == 1 { + if v, _, ok := env.Telemetry.LatestMetric(env.DriverName, "result_err"); ok && v == 1 { return false, "errored" } return false, "neither metric set — driver did not run" @@ -187,3 +196,102 @@ func TestHTTPTestRigSelfCheck(t *testing.T) { t.Errorf("rig sanity: server saw %d hits, want 1", atomic.LoadInt32(&hits)) } } + +// TLS pinning lets a driver reach a self-signed HTTPS endpoint (e.g. a +// NIBE heat pump's local REST API) by accepting exactly one leaf cert. +// httptest.NewTLSServer mints a self-signed cert NOT in the system root +// store, so it stands in perfectly for the pump: rejected without a pin, +// accepted with the right pin, rejected with a wrong one. +func TestLuaHTTPTLSPinning(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`ok`)) + })) + defer srv.Close() + + sum := sha256.Sum256(srv.Certificate().Raw) + goodPin := hex.EncodeToString(sum[:]) + + t.Run("no pin rejects self-signed cert", func(t *testing.T) { + env := NewHostEnv("nopin", telemetry.NewStore()).WithHTTP() + if got, _ := runHTTPDriverWithEnv(t, env, srv.URL); got { + t.Fatal("self-signed cert must be rejected when no pin is configured") + } + }) + + t.Run("correct pin accepts (openssl colon/upper form)", func(t *testing.T) { + // Feed the openssl-style "AB:CD:..." upper-case form to prove the + // host normalises it before comparing. + env := NewHostEnv("goodpin", telemetry.NewStore()).WithHTTP(). + WithHTTPTLSPin(colonizeHex(strings.ToUpper(goodPin))) + if got, errMsg := runHTTPDriverWithEnv(t, env, srv.URL); !got { + t.Fatalf("pinned fetch must succeed, err=%s", errMsg) + } + }) + + t.Run("wrong pin rejects", func(t *testing.T) { + env := NewHostEnv("badpin", telemetry.NewStore()).WithHTTP(). + WithHTTPTLSPin(strings.Repeat("ab", 32)) // 64 valid hex chars, wrong cert + if got, _ := runHTTPDriverWithEnv(t, env, srv.URL); got { + t.Fatal("a wrong pin must reject the connection") + } + }) + + t.Run("malformed pin fails closed", func(t *testing.T) { + // A configured-but-invalid pin is rejected as configuration error; + // it must never silently fall back to the system trust store. + env := NewHostEnv("junkpin", telemetry.NewStore()).WithHTTP().WithHTTPTLSPin("not-a-fingerprint") + if got, _ := runHTTPDriverWithEnv(t, env, srv.URL); got { + t.Fatal("malformed pin must reject the request") + } + }) + + t.Run("configured pin forbids clear-text downgrade", func(t *testing.T) { + var hits atomic.Int32 + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`ok`)) + })) + defer plain.Close() + env := NewHostEnv("cleartext", telemetry.NewStore()).WithHTTP(). + WithHTTPTLSPin(goodPin) + if got, _ := runHTTPDriverWithEnv(t, env, plain.URL); got { + t.Fatal("a pinned driver must not use clear-text HTTP") + } + if hits.Load() != 0 { + t.Fatalf("clear-text server received %d requests, want 0", hits.Load()) + } + }) +} + +// colonizeHex turns "abcd..." into "ab:cd:..." to mimic the openssl +// `-fingerprint` output format the operator copy-pastes. +func colonizeHex(h string) string { + var parts []string + for i := 0; i+2 <= len(h); i += 2 { + parts = append(parts, h[i:i+2]) + } + return strings.Join(parts, ":") +} + +func TestNormalizeHexFingerprint(t *testing.T) { + const canonical = "0011223344556677889900aabbccddeeff0123456789abcdef0011223344abcd" + cases := []struct { + name string + in string + want string + }{ + {"already canonical", canonical, canonical}, + {"openssl colon+upper", colonizeHex(strings.ToUpper(canonical)), canonical}, + {"spaces tolerated", canonical[:8] + " " + canonical[8:24] + " " + canonical[24:], canonical}, + {"too short → empty", "deadbeef", ""}, + {"non-hex char → empty", "zz" + canonical[2:], ""}, + {"empty → empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeHexFingerprint(tc.in); got != tc.want { + t.Errorf("normalizeHexFingerprint(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/go/internal/drivers/nibe_local_test.go b/go/internal/drivers/nibe_local_test.go new file mode 100644 index 00000000..f423d190 --- /dev/null +++ b/go/internal/drivers/nibe_local_test.go @@ -0,0 +1,325 @@ +package drivers + +import ( + "context" + "encoding/base64" + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/frahlg/forty-two-watts/go/internal/telemetry" +) + +// nibeLocalDriverPath resolves drivers/nibe_local.lua from the repo root +// regardless of the test's working directory (tests run in go/internal/drivers/). +func nibeLocalDriverPath(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repo := filepath.Join(filepath.Dir(thisFile), "..", "..", "..") + return filepath.Join(repo, "drivers", "nibe_local.lua") +} + +func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-6 } + +// TestNibeLocalEmitsTelemetry loads the real driver against a fake NIBE +// Local REST API and asserts: (1) it sends HTTP Basic auth, (2) it detects +// the serial from /api/v1/devices and sets the SN, (3) canonical headline +// metrics land with EXACT per-point divisor scaling (no °C×10 guessing), +// (4) the "not connected" s16 sentinel (-32768) is filtered out even though +// the API reports isOk=true, and (5) every other point auto-emits as a +// sanitized hp_ with soft hyphens stripped. +func TestNibeLocalEmitsTelemetry(t *testing.T) { + const wantUser, wantPass = "localuser", "secret-pass" + wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(wantUser+":"+wantPass)) + + // Build the points map exactly as the pump returns it: object keyed by + // variableId, each with metadata{divisor,unit,variableSize} + value. + point := func(title, size, unit string, divisor, integerValue int) map[string]any { + return map[string]any{ + "title": title, + "metadata": map[string]any{ + "variableSize": size, + "unit": unit, + "divisor": divisor, + "isWritable": false, + }, + "value": map[string]any{"type": "datavalue", "isOk": true, "integerValue": integerValue}, + } + } + points := map[string]any{ + "1801": point("Compres­sor power input", "u16", "W", 1, 1500), // hp_power_w + "4": point("Current outdoor temper­ature (BT1)", "s16", "°C", 10, 294), // 29.4 + "11": point("Hot water top (BT7)", "s16", "°C", 10, 570), // 57.0 + "8": point("Supply line (BT2)", "s16", "°C", 10, 449), // auto → hp_supply_line_bt2 44.9 + "5": point("Supply line (EP23-BT2)", "s16", "°C", 10, -32768), // sentinel → skipped + "28393": point("Tot. consump­tion", "u32", "kWh", 10, 53999), // hp_energy_consumed_kwh 5399.9 + "9001": point("Duplicate title", "u16", "", 1, 10), + "9002": point("Duplicate title", "u16", "", 1, 20), + } + + // Attach a Modbus register id to each point's metadata (the pump reports + // modbusRegisterID per point); the driver threads it into the live snapshot. + // Injected here so the soft-hyphen titles above stay byte-for-byte untouched. + for id, reg := range map[string]int{"1801": 1048, "4": 1, "11": 8, "8": 5, "28393": 2166} { + points[id].(map[string]any)["metadata"].(map[string]any)["modbusRegisterID"] = reg + } + + var sawAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawAuth = r.Header.Get("Authorization") + switch r.URL.Path { + case "/api/v1/devices": + _ = json.NewEncoder(w).Encode(map[string]any{ + "devices": []map[string]any{ + {"product": map[string]any{ + "serialNumber": "06613225140002", + "manufacturer": "NIBE", + "firmwareId": "nibe-n", + }, "deviceIndex": 0, "aidMode": "off"}, + }, + }) + case "/api/v1/devices/06613225140002/points": + _ = json.NewEncoder(w).Encode(points) + default: + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + } + })) + defer srv.Close() + + tel := telemetry.NewStore() + env := NewHostEnv("nibe", tel).WithHTTP() + d, err := NewLuaDriver(nibeLocalDriverPath(t), env) + if err != nil { + t.Fatalf("load driver: %v", err) + } + defer d.Cleanup() + + cfg := map[string]any{ + "username": wantUser, + "password": wantPass, + "base_url": srv.URL, + } + if err := d.Init(context.Background(), cfg); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("poll: %v", err) + } + + if sawAuth != wantAuth { + t.Errorf("Authorization header = %q, want %q", sawAuth, wantAuth) + } + if got := env.SN; got != "06613225140002" { + t.Errorf("serial not anchored: SN=%q, want 06613225140002", got) + } + + // Canonical headline metrics with exact scaling. + wantMetric := map[string]float64{ + "hp_power_w": 1500, // W, div 1 + "hp_outdoor_temp_c": 29.4, // 294 / 10 + "hp_hw_top_temp_c": 57.0, // 570 / 10 + "hp_energy_consumed_kwh": 5399.9, // 53999 / 10 + "hp_supply_line_bt2": 44.9, // auto-named, 449 / 10 + } + for name, want := range wantMetric { + v, _, ok := tel.LatestMetric("nibe", name) + if !ok { + t.Errorf("metric %s missing", name) + continue + } + if !approxEq(v, want) { + t.Errorf("metric %s = %v, want %v", name, v, want) + } + } + + // The Modbus register id rides along into the live snapshot so the + // per-driver "all signals" detail view can show each signal's source + // register — for canonical aliases (hp_power_w) and auto-named metrics alike. + regByName := map[string]string{} + for _, m := range tel.LatestMetricsByDriver("nibe") { + regByName[m.Name] = m.Register + } + for name, want := range map[string]string{ + "hp_power_w": "1048", // canonical alias keeps its point's register + "hp_supply_line_bt2": "5", // auto-named metric + } { + if regByName[name] != want { + t.Errorf("metric %s register = %q, want %q", name, regByName[name], want) + } + } + + // The disconnected supply-line sensor (-32768 sentinel, isOk=true) must + // NOT be emitted — filtering by size is the whole point. + if _, _, ok := tel.LatestMetric("nibe", "hp_supply_line_ep23_bt2"); ok { + t.Error("hp_supply_line_ep23_bt2 was emitted; the -32768 sentinel should be filtered") + } + // Canonical ids must not ALSO leak under their auto-sanitized names. + if _, _, ok := tel.LatestMetric("nibe", "hp_compressor_power_input"); ok { + t.Error("compressor power leaked under auto name; canonical id should be emitted once as hp_power_w") + } + // Sanitized-title collisions must be deterministic: both colliding points + // carry their id, independent of Lua table iteration order. + for name, want := range map[string]float64{ + "hp_duplicate_title_9001": 10, + "hp_duplicate_title_9002": 20, + } { + if v, _, ok := tel.LatestMetric("nibe", name); !ok || !approxEq(v, want) { + t.Errorf("metric %s = %v (ok=%v), want %v", name, v, ok, want) + } + } + if _, _, ok := tel.LatestMetric("nibe", "hp_duplicate_title"); ok { + t.Error("colliding metric leaked under an unstable unsuffixed name") + } + + // A second unchanged poll keeps headline resolution but does not enqueue + // every non-headline point again. This bounds TS growth for the ~980-point + // map while hp_poll_ok still keeps the watchdog fresh. + _ = tel.FlushSamples() + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("second poll: %v", err) + } + second := tel.FlushSamples() + seenSecond := map[string]bool{} + for _, s := range second { + seenSecond[s.Metric] = true + } + if seenSecond["hp_supply_line_bt2"] || seenSecond["hp_duplicate_title_9001"] { + t.Fatalf("unchanged non-headline metrics were re-emitted: %#v", seenSecond) + } + if !seenSecond["hp_power_w"] || !seenSecond["hp_poll_ok"] { + t.Fatalf("second poll must retain headline + health heartbeat: %#v", seenSecond) + } +} + +// TestNibeLocalLive exercises the driver against a real NIBE pump. Skipped +// unless NIBE_LIVE=1; provide NIBE_HOST, NIBE_PORT (8443), NIBE_USER, +// NIBE_PASS, NIBE_PIN (the cert SHA-256 fingerprint). +func TestNibeLocalLive(t *testing.T) { + if os.Getenv("NIBE_LIVE") != "1" { + t.Skip("set NIBE_LIVE=1 + NIBE_USER/NIBE_PASS/NIBE_PIN to run against a real pump") + } + envOr := func(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d + } + host := envOr("NIBE_HOST", "192.168.1.180") + port := envOr("NIBE_PORT", "8443") + + env := NewHostEnv("nibe", telemetry.NewStore()).WithHTTP(). + WithHTTPAllowedHosts([]string{host + ":" + port}). + WithHTTPTLSPin(os.Getenv("NIBE_PIN")) + d, err := NewLuaDriver(nibeLocalDriverPath(t), env) + if err != nil { + t.Fatalf("load driver: %v", err) + } + defer d.Cleanup() + + cfg := map[string]any{ + "host": host, + "port": port, + "username": os.Getenv("NIBE_USER"), + "password": os.Getenv("NIBE_PASS"), + } + if err := d.Init(context.Background(), cfg); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("poll: %v", err) + } + + if env.SN == "" { + t.Fatal("no serial detected from the live pump") + } + t.Logf("live pump serial=%s", env.SN) + for _, name := range []string{ + "hp_power_w", "hp_used_power_w", "hp_outdoor_temp_c", + "hp_hw_top_temp_c", "hp_energy_consumed_kwh", "hp_energy_produced_kwh", + "hp_degree_minutes", + } { + if v, _, ok := env.Telemetry.LatestMetric("nibe", name); ok { + t.Logf(" %-24s = %v", name, v) + } else { + t.Logf(" %-24s = (absent)", name) + } + } + if _, _, ok := env.Telemetry.LatestMetric("nibe", "hp_outdoor_temp_c"); !ok { + t.Error("expected hp_outdoor_temp_c from a live pump") + } +} + +// TestNibeLocalHeadlineOverride proves per-model headline resolution: a +// config override (param_power_id) wins over the built-in profile default, +// the model name is captured, and the override survives the post-detection +// profile rebuild in try_setup. The overridden default id then falls through +// to its auto-generated name instead of vanishing. +func TestNibeLocalHeadlineOverride(t *testing.T) { + point := func(title, size, unit string, divisor, integerValue int) map[string]any { + return map[string]any{ + "title": title, + "metadata": map[string]any{ + "variableSize": size, "unit": unit, "divisor": divisor, "isWritable": false, + }, + "value": map[string]any{"type": "datavalue", "isOk": true, "integerValue": integerValue}, + } + } + points := map[string]any{ + "1801": point("Compressor power input", "u16", "W", 1, 1500), // built-in default id + "9999": point("Custom whole-house power", "u32", "W", 1, 2222), // override target + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/devices": + _ = json.NewEncoder(w).Encode(map[string]any{ + "devices": []map[string]any{ + {"product": map[string]any{ + "serialNumber": "06613225140002", "manufacturer": "NIBE", + "name": "NIBE S735", "firmwareId": "nibe-n", + }}, + }, + }) + case "/api/v1/devices/06613225140002/points": + _ = json.NewEncoder(w).Encode(points) + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + defer srv.Close() + + tel := telemetry.NewStore() + d, err := NewLuaDriver(nibeLocalDriverPath(t), NewHostEnv("nibe", tel).WithHTTP()) + if err != nil { + t.Fatalf("load driver: %v", err) + } + defer d.Cleanup() + cfg := map[string]any{ + "username": "u", + "password": "p", + "base_url": srv.URL, + "param_power_id": "9999", // override the default compressor-power id + } + if err := d.Init(context.Background(), cfg); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("poll: %v", err) + } + + // hp_power_w must follow the override (id 9999 = 2222 W), not the default 1801. + if v, _, ok := tel.LatestMetric("nibe", "hp_power_w"); !ok || !approxEq(v, 2222) { + t.Errorf("hp_power_w = %v (ok=%v), want 2222 from override id 9999", v, ok) + } + // The now-unmapped default id 1801 should surface under its auto name. + if v, _, ok := tel.LatestMetric("nibe", "hp_compressor_power_input"); !ok || !approxEq(v, 1500) { + t.Errorf("overridden default id should fall through to hp_compressor_power_input=1500, got %v (ok=%v)", v, ok) + } +} diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 10acfab6..3c3c87b1 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -180,6 +180,9 @@ func (r *Registry) Add(ctx context.Context, cfg config.Driver) error { if len(hosts) > 0 { env.WithHTTPAllowedHosts(hosts) } + if pin := strings.TrimSpace(cfg.Capabilities.HTTP.TLSPinSHA256); pin != "" { + env.WithHTTPTLSPin(pin) + } } if cfg.Capabilities.WebSocket != nil { env.WithWS(NewGorillaWS(cfg.Name)) diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index b2b35c07..42ec442f 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -68,11 +68,11 @@ func (h *Handler) Snapshot() map[string]ChargerView { out := make(map[string]ChargerView, len(h.chargers)) for id, s := range h.chargers { out[id] = ChargerView{ - Connected: s.connected, - Charging: s.charging, - PowerW: s.lastPowerW, - SessionWh: s.sessionMeterWh, - TxID: s.transactionID, + Connected: s.connected, + Charging: s.charging, + PowerW: s.lastPowerW, + SessionWh: s.sessionMeterWh, + TxID: s.transactionID, } } return out @@ -167,7 +167,7 @@ func (h *Handler) OnStatusNotification(id string, req *core.StatusNotificationRe h.mu.Unlock() if req.Status == core.ChargePointStatusFaulted { - h.tel.EmitMetric(id, "ev_fault", 1, "") + h.tel.EmitMetric(id, "ev_fault", 1, "", "", "") slog.Warn("OCPP charger faulted", "charger", id, "errorCode", req.ErrorCode, "info", req.Info) } slog.Info("OCPP status", @@ -251,7 +251,7 @@ func (h *Handler) OnStopTransaction(id string, req *core.StopTransactionRequest) "charger", id, "txid", req.TransactionId, "session_wh", sessionWh, "reason", req.Reason) h.pushReading(id, s) - h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh") + h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh", "", "") h.tel.RecordDriverSuccess(id) return core.NewStopTransactionConfirmation(), nil } diff --git a/go/internal/scanner/scanner.go b/go/internal/scanner/scanner.go index af330ac0..501efa2d 100644 --- a/go/internal/scanner/scanner.go +++ b/go/internal/scanner/scanner.go @@ -1,6 +1,6 @@ // Package scanner discovers devices on the local network by TCP-dialing -// common energy-device ports (Modbus 502, MQTT 1883, HTTP 80) across all -// /24 subnets attached to non-loopback, non-virtual interfaces. +// common energy-device ports (Modbus 502, MQTT 1883, HTTP 80, HTTPS 8443) +// across all /24 subnets attached to non-loopback, non-virtual interfaces. // // Used by the bootstrap wizard to help users find their inverters/meters // and by the settings UI for re-scanning. @@ -21,7 +21,7 @@ import ( type FoundDevice struct { IP string `json:"ip"` Port int `json:"port"` - Protocol string `json:"protocol"` // "modbus", "mqtt", "http" + Protocol string `json:"protocol"` // "modbus", "mqtt", "http", "https" LatencyMs int `json:"latency_ms"` } @@ -30,6 +30,7 @@ var wellKnownPorts = map[int]string{ 502: "modbus", 1883: "mqtt", 80: "http", + 8443: "https", // NIBE S-series Local REST API + other on-prem HTTPS device APIs } // Scan probes all /24 subnets on local interfaces for open ports. diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index 7d8591ca..44651b68 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -219,6 +219,8 @@ type Store struct { type metricSnap struct { Value float64 Unit string + Register string + Title string UpdatedAt time.Time } @@ -330,9 +332,12 @@ func (s *Store) Update(driver string, t DerType, rawW float64, soc *float64, dat // Drained by the control loop via FlushSamples. // EmitMetric records a scalar metric. unit is an optional display unit // (e.g. "°C", "Hz", "kW") carried into the live snapshot so the UI can group -// and label metrics; pass "" when unknown. Existing callers updated to the -// 4-arg form. -func (s *Store) EmitMetric(driver, name string, value float64, unit string) { +// and label metrics; pass "" when unknown. register is an optional source +// address (e.g. a Modbus register id) carried into the live snapshot for the +// per-driver detail view; pass "" when not applicable. title is an optional +// human-readable label (e.g. the device's own point title) surfaced as the +// per-signal explanation in the detail view; pass "" when not available. +func (s *Store) EmitMetric(driver, name string, value float64, unit, register, title string) { if !finite(value) { return } @@ -343,7 +348,7 @@ func (s *Store) EmitMetric(driver, name string, value float64, unit string) { }) s.pendingMu.Unlock() s.latestMu.Lock() - s.latestMetric[driver+":"+name] = metricSnap{Value: value, UpdatedAt: now, Unit: unit} + s.latestMetric[driver+":"+name] = metricSnap{Value: value, UpdatedAt: now, Unit: unit, Register: register, Title: title} s.latestMu.Unlock() } @@ -352,6 +357,8 @@ type MetricSnapshot struct { Name string `json:"name"` Value float64 `json:"value"` Unit string `json:"unit,omitempty"` + Register string `json:"register,omitempty"` + Title string `json:"title,omitempty"` UpdatedAt time.Time `json:"updated_at"` } @@ -372,6 +379,8 @@ func (s *Store) LatestMetricsByDriver(driver string) []MetricSnapshot { Name: k[len(prefix):], Value: v.Value, Unit: v.Unit, + Register: v.Register, + Title: v.Title, UpdatedAt: v.UpdatedAt, }) } diff --git a/go/internal/telemetry/telemetry_test.go b/go/internal/telemetry/telemetry_test.go index 7333174b..498b960f 100644 --- a/go/internal/telemetry/telemetry_test.go +++ b/go/internal/telemetry/telemetry_test.go @@ -186,12 +186,12 @@ func TestStoreRejectsInvalidReadings(t *testing.T) { func TestEmitMetricRejectsNonFinite(t *testing.T) { s := NewStore() - s.EmitMetric("driver", "bad", math.Inf(1), "") + s.EmitMetric("driver", "bad", math.Inf(1), "", "", "") if samples := s.FlushSamples(); len(samples) != 0 { t.Fatalf("non-finite metric should be dropped, got %+v", samples) } - s.EmitMetric("driver", "ok", 42, "") + s.EmitMetric("driver", "ok", 42, "", "", "") samples := s.FlushSamples() if len(samples) != 1 || samples[0].Metric != "ok" || samples[0].Value != 42 { t.Fatalf("valid metric not buffered as expected: %+v", samples) @@ -202,9 +202,9 @@ func TestEmitMetricRejectsNonFinite(t *testing.T) { // snapshot so the UI can group + label metrics (heat-pump drill-in). func TestEmitMetricCarriesUnit(t *testing.T) { s := NewStore() - s.EmitMetric("hp", "hp_outdoor_temp_c", 7.3, "°C") - s.EmitMetric("hp", "hp_compressor_hz", 42, "Hz") - s.EmitMetric("hp", "hp_no_unit", 1, "") + s.EmitMetric("hp", "hp_outdoor_temp_c", 7.3, "°C", "", "") + s.EmitMetric("hp", "hp_compressor_hz", 42, "Hz", "", "") + s.EmitMetric("hp", "hp_no_unit", 1, "", "", "") got := map[string]string{} for _, m := range s.LatestMetricsByDriver("hp") { @@ -221,6 +221,46 @@ func TestEmitMetricCarriesUnit(t *testing.T) { } } +// TestEmitMetricCarriesRegister verifies the optional 5th register arg threads +// into the live snapshot (used by the heat-pump drill-in to show each signal's +// source Modbus register) and is omitted when not provided. +func TestEmitMetricCarriesRegister(t *testing.T) { + s := NewStore() + s.EmitMetric("hp", "hp_power_w", 84, "W", "1048", "") + s.EmitMetric("hp", "hp_no_register", 1, "", "", "") + + reg := map[string]string{} + for _, m := range s.LatestMetricsByDriver("hp") { + reg[m.Name] = m.Register + } + if reg["hp_power_w"] != "1048" { + t.Errorf("register = %q, want 1048", reg["hp_power_w"]) + } + if reg["hp_no_register"] != "" { + t.Errorf("missing register should be empty, got %q", reg["hp_no_register"]) + } +} + +// TestEmitMetricCarriesTitle verifies the optional 6th title arg threads into +// the live snapshot (used by the heat-pump drill-in to show a human-readable +// explanation per signal) and is omitted when not provided. +func TestEmitMetricCarriesTitle(t *testing.T) { + s := NewStore() + s.EmitMetric("hp", "hp_fr_nluft_bt20", 12.3, "°C", "1234", "Frånluft (BT20)") + s.EmitMetric("hp", "hp_no_title", 1, "", "", "") + + title := map[string]string{} + for _, m := range s.LatestMetricsByDriver("hp") { + title[m.Name] = m.Title + } + if title["hp_fr_nluft_bt20"] != "Frånluft (BT20)" { + t.Errorf("title = %q, want Frånluft (BT20)", title["hp_fr_nluft_bt20"]) + } + if title["hp_no_title"] != "" { + t.Errorf("missing title should be empty, got %q", title["hp_no_title"]) + } +} + // ---- DerType ---- func TestDerTypeRoundtrip(t *testing.T) {