Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html

### Changed

- **`nibe_local` 1.1.3** — heat-pump diagnostic metrics convert vendor kW/kWh to W/Wh at emit (case and surrounding spaces folded, so `kW ` still converts). Headline names `hp_energy_consumed_kwh` and `hp_energy_produced_kwh` stay so existing series keys do not move; the unit field is Wh. `DRIVER.read_only = true` so the signed artifact matches the observe-only command path. HTTP GET and JSON decode wrap in `pcall`.
- **`myuplink` 1.2.1** — bulk kW/kWh points and the `hp_power_w` headline convert to W/Wh at emit. There are no `hp_energy_*_kwh` headlines; energy, if the pump reports it, is a sanitized bulk name with unit Wh.
- **acuvim** 0.4.2, **50-125k-svk** 0.2.3, **50-125k-svk-slew** 0.1.12,
**50-125k-svk-ac-slew** 0.2.4, **deye-svk** 0.2.1, **konja-261-svk** 0.3.1 —
comments and metadata only: remove references to internal services and
Expand Down
8 changes: 4 additions & 4 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ Catalog source is not proof that a target can install or run a driver.
| kstar | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| mennekes | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| mennekes | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| myuplink | 1.2.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| myuplink | 1.2.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| nibe_local | 1.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| nibe_local | 1.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| myuplink | 1.2.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| myuplink | 1.2.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| nibe_local | 1.1.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| nibe_local | 1.1.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| opendtu | 1.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| opendtu | 1.0.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| opendtu_mqtt | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
Expand Down
4 changes: 2 additions & 2 deletions devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,7 @@ manufacturers:
protocols:
- protocol: http
driver: "myuplink"
version: "1.2.0"
version: "1.2.1"
ders: [heatpump]
control: false
firmware_versions: ""
Expand All @@ -993,7 +993,7 @@ manufacturers:
protocols:
- protocol: http
driver: "nibe_local"
version: "1.1.2"
version: "1.1.3"
ders: [heatpump]
control: false
firmware_versions: ""
Expand Down
41 changes: 36 additions & 5 deletions drivers/lua/myuplink.lua
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ DRIVER = {
id = "myuplink",
name = "MyUplink Heat Pump (telemetry)",
manufacturer = "MyUplink (NIBE, Bosch, Atlantic, Daikin, ...)",
version = "1.2.0",
version = "1.2.1",
protocols = { "http" },
capabilities = { "apicreds" },
-- Says what the header, the description and driver_command have always
Expand Down Expand Up @@ -222,6 +222,30 @@ local function scale_value(raw, unit)
return raw
end

-- Fold vendor unit strings so "kW " / "KW" still convert. Empty → already SI.
local function fold_unit(unit)
if type(unit) ~= "string" then return "" end
unit = unit:gsub("^%s+", "")
unit = unit:gsub("%s+$", "")
return string.lower(unit)
end

-- kW → W. Unknown non-empty units keep their vendor string so we do not
-- relabel a missed kilowatt reading as watts.
local function to_watts(value, unit)
local folded = fold_unit(unit)
if folded == "kw" then return value * 1000.0, "W" end
if folded == "w" or folded == "" then return value, "W" end
return value, unit
end

local function to_wh(value, unit)
local folded = fold_unit(unit)
if folded == "kwh" then return value * 1000.0, "Wh" end
if folded == "wh" or folded == "" then return value, "Wh" end
return value, unit
end

-- Turn a MyUplink parameterName into a stable snake_case metric name,
-- prefixed hp_. Non-ASCII and punctuation collapse to single underscores;
-- empty names fall back to the parameterId.
Expand Down Expand Up @@ -323,9 +347,9 @@ function driver_poll()
if by_id[PARAM_POWER] then
local raw = tonumber(by_id[PARAM_POWER].value) or 0
-- MyUplink points report the unit in "parameterUnit" (not "unit").
local unit = by_id[PARAM_POWER].parameterUnit or by_id[PARAM_POWER].unit
local power_w = (unit == "kW") and raw * 1000 or raw
host.emit_metric("hp_power_w", power_w, "W")
local unit = by_id[PARAM_POWER].parameterUnit or by_id[PARAM_POWER].unit or ""
local power_w, out_unit = to_watts(raw, unit)
host.emit_metric("hp_power_w", power_w, out_unit)
end
if by_id[PARAM_HW_TEMP] then host.emit_metric("hp_hw_top_temp_c", decode_temp(by_id[PARAM_HW_TEMP]) or 0, "°C") end
if by_id[PARAM_INDOOR_TEMP] then host.emit_metric("hp_indoor_temp_c", decode_temp(by_id[PARAM_INDOOR_TEMP]) or 0, "°C") end
Expand All @@ -348,7 +372,14 @@ function driver_poll()
local name = sanitize_metric_name(pt.parameterName, pid)
if seen[name] then name = name .. "_" .. pid end
seen[name] = true
host.emit_metric(name, scale_value(raw, unit), unit)
local value = scale_value(raw, unit)
local folded = fold_unit(unit)
if folded == "kw" or folded == "w" then
value, unit = to_watts(value, unit)
elseif folded == "kwh" or folded == "wh" then
value, unit = to_wh(value, unit)
end
host.emit_metric(name, value, unit)
end
end
end
Expand Down
58 changes: 43 additions & 15 deletions drivers/lua/nibe_local.lua
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@ DRIVER = {
id = "nibe-local",
name = "NIBE REST API S-series",
manufacturer = "NIBE",
version = "1.0.0",
version = "1.1.3",
protocols = { "http" },
capabilities = { "apicreds" },
-- Without this the channel infers control from driver_command and
-- publishes a write-capable artifact. The command path refuses every call.
read_only = true,
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", "FTW contributors" },
Expand Down Expand Up @@ -94,20 +97,21 @@ local last_emitted = {}
-- 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
-- (hp_power_w, hp_outdoor_temp_c, …) that hosts 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? }.
-- Logical headline -> { config override key, emitted metric name, watts?, wh? }.
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" },
-- Name stays _kwh so existing series keys do not move. Unit at emit is Wh.
{ key = "econs", cfg = "param_energy_consumed_id", name = "hp_energy_consumed_kwh", wh = true },
{ key = "eprod", cfg = "param_energy_produced_id", name = "hp_energy_produced_kwh", wh = true },
{ key = "dm", cfg = "param_degree_minutes_id", name = "hp_degree_minutes" },
}

Expand Down Expand Up @@ -179,11 +183,28 @@ local function sanitize_metric_name(title, id)
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.
-- Fold vendor unit strings so "kW " / "KW" still convert. Empty → already SI.
local function fold_unit(unit)
if type(unit) ~= "string" then return "" end
unit = unit:gsub("^%s+", "")
unit = unit:gsub("%s+$", "")
return string.lower(unit)
end

-- kW → W. Unknown non-empty units keep their vendor string so we do not
-- relabel a missed kilowatt reading as watts.
local function to_watts(value, unit)
if unit == "kW" then return value * 1000.0, "W" end
return value, (unit ~= "" and unit or "W")
local folded = fold_unit(unit)
if folded == "kw" then return value * 1000.0, "W" end
if folded == "w" or folded == "" then return value, "W" end
return value, unit
end

local function to_wh(value, unit)
local folded = fold_unit(unit)
if folded == "kwh" then return value * 1000.0, "Wh" end
if folded == "wh" or folded == "" then return value, "Wh" end
return value, unit
end

-- The NIBE Modbus register id for a point (metadata.modbusRegisterID), formatted
Expand Down Expand Up @@ -222,7 +243,7 @@ local function build_canon(profile, config)
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
if id then CANON[id] = { name = h.name, watts = h.watts, wh = h.wh } end
end
end

Expand All @@ -248,10 +269,12 @@ local function auth_headers()
end

local function api_get(path)
local resp, err = host.http_get(base_url .. path, auth_headers())
local get_ok, resp, err = pcall(host.http_get, base_url .. path, auth_headers())
if not get_ok then return nil, tostring(resp) end
if err then return nil, tostring(err) end
local data = host.json_decode(resp)
if not data then return nil, "json decode failed" end
local decode_ok, data, derr = pcall(host.json_decode, resp)
if not decode_ok then return nil, tostring(data) end
if not data then return nil, tostring(derr or "json decode failed") end
return data, nil
end

Expand Down Expand Up @@ -390,7 +413,12 @@ function driver_poll()
name = name .. "_" .. tostring(id)
end
local value = scaled
if canon and canon.watts then value, unit = to_watts(scaled, unit) end
local folded = fold_unit(unit)
if folded == "kw" or folded == "w" or (canon and canon.watts) then
value, unit = to_watts(scaled, unit)
elseif folded == "kwh" or folded == "wh" or (canon and canon.wh) then
value, unit = to_wh(scaled, unit)
end

-- Stable headline series retain one-minute resolution. The bulk
-- map records transitions plus an hourly complete snapshot.
Expand Down
23 changes: 23 additions & 0 deletions drivers/tests/lua_harness/host_mock.lua
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,29 @@ function host.http_get(url)
error("http_get: no mock response for URL: " .. tostring(url))
end

function host.http_post(url, body, headers)
record_call("http_post", url, body, headers)
local resp = host._http_responses[url]
if resp then
return resp
end
for pattern_url, posted in pairs(host._http_responses) do
if string.find(url, pattern_url, 1, true) then
return posted
end
end
error("http_post: no mock response for URL: " .. tostring(url))
end

function host.set_poll_interval(interval_ms)
record_call("set_poll_interval", interval_ms)
end

function host.persist_secret(key, value)
record_call("persist_secret", key, value)
return true
end

---------------------------------------------------------------------------
-- Serial functions
---------------------------------------------------------------------------
Expand Down
Loading