From 366474a5fe7644f36246699e4f4fdeaddc5d0d8f Mon Sep 17 00:00:00 2001 From: Segran Date: Mon, 5 Oct 2026 00:12:58 +0200 Subject: [PATCH] feat(myuplink): improve NIBE F750 telemetry Signed-off-by: Segran --- CHANGELOG.md | 10 +++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/myuplink.lua | 45 +++++++--- .../tests/lua_harness/test_myuplink_f750.lua | 86 +++++++++++++++++++ drivers/tests/test_myuplink.py | 22 +++++ index.yaml | 6 +- manifests/myuplink.yaml | 6 +- support-status.json | 2 +- 9 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 drivers/tests/lua_harness/test_myuplink_f750.lua create mode 100644 drivers/tests/test_myuplink.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b358dd3..b8a7371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## myuplink 1.2.4 + +Improve NIBE F750 telemetry from hardware-observed MyUplink parameter data. +Parameter 40013 is documented as BT7 hot-water top temperature, and parameter +40940 is exposed as the canonical hp_degree_minutes reading. Ignore the NIBE +-32768 unavailable-value sentinel instead of emitting bogus telemetry, and +remove embedded Unicode soft hyphens from generated metric names. The driver +remains read-only. No F750 electrical compressor-power mapping is claimed. + + ## heishamon 0.8.0 Read `hp_power_w` from `main/Heat_Power_Consumption` (TOP16), with a diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 40e329d..0900a12 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -94,8 +94,8 @@ 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 | | mennekes | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | mennekes | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| myuplink | 1.2.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| myuplink | 1.2.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| myuplink | 1.2.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| myuplink | 1.2.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | nibe_local | 1.2.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | nibe_local | 1.2.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | opendtu | 1.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 592c11c..43d5fc9 100644 --- a/devices.yaml +++ b/devices.yaml @@ -956,7 +956,7 @@ manufacturers: protocols: - protocol: http driver: "myuplink" - version: "1.2.3" + version: "1.2.4" ders: [heatpump] control: false firmware_versions: "" diff --git a/drivers/lua/myuplink.lua b/drivers/lua/myuplink.lua index 85e549a..27f4e8c 100644 --- a/drivers/lua/myuplink.lua +++ b/drivers/lua/myuplink.lua @@ -48,7 +48,7 @@ DRIVER = { id = "myuplink", name = "MyUplink Heat Pump (telemetry)", manufacturer = "MyUplink (NIBE, Bosch, Atlantic, Daikin, ...)", - version = "1.2.3", + version = "1.2.4", protocols = { "http" }, capabilities = { "apicreds" }, -- Says what the header, the description and driver_command have always @@ -90,9 +90,10 @@ local device_id = nil -- Parameter IDs (NIBE defaults, overridable via config) local PARAM_POWER = "10012" -- compressor power (W) -local PARAM_HW_TEMP = "40013" -- BT6 hot water top temp -local PARAM_INDOOR_TEMP = "40033" -- BT50 room temperature -local PARAM_OUTDOOR_TEMP = "40004" -- BT1 outdoor temperature +local PARAM_HW_TEMP = "40013" -- BT7 hot water top temperature +local PARAM_INDOOR_TEMP = "40033" -- BT50 room temperature +local PARAM_OUTDOOR_TEMP = "40004" -- BT1 outdoor temperature +local PARAM_DEGREE_MINUTES = "40940" -- F750 current degree-minute value -- ---- Helpers ------------------------------------------------------------- @@ -206,10 +207,16 @@ local function fetch_all_points() return data, by_id, nil end +local function valid_raw(raw) + raw = tonumber(raw) + if raw == nil or raw == -32768 then return nil end + return raw +end + local function decode_temp(pt) if not pt then return nil end - local raw = tonumber(pt.value) - if not raw then return nil end + local raw = valid_raw(pt.value) + if raw == nil then return nil end if math.abs(raw) > 100 then return raw / 10 end -- NIBE °C×10 encoding return raw end @@ -251,6 +258,10 @@ end -- empty names fall back to the parameterId. local function sanitize_metric_name(name, pid) local s = string.lower(name or "") + -- MyUplink/NIBE parameter names sometimes contain U+00AD SOFT HYPHEN + -- inside ordinary words. Remove its UTF-8 bytes instead of turning it + -- into an underscore (for example "com­pressor" -> "compressor"). + s = string.gsub(s, string.char(194, 173), "") s = string.gsub(s, "[^a-z0-9]+", "_") s = string.gsub(s, "^_+", "") s = string.gsub(s, "_+$", "") @@ -301,7 +312,8 @@ function driver_init(config) PARAM_POWER = ov("param_power_id", PARAM_POWER) PARAM_HW_TEMP = ov("param_hw_temp_id", PARAM_HW_TEMP) PARAM_INDOOR_TEMP = ov("param_indoor_temp_id", PARAM_INDOOR_TEMP) - PARAM_OUTDOOR_TEMP = ov("param_outdoor_temp_id", PARAM_OUTDOOR_TEMP) + PARAM_OUTDOOR_TEMP = ov("param_outdoor_temp_id", PARAM_OUTDOOR_TEMP) + PARAM_DEGREE_MINUTES = ov("param_degree_minutes_id", PARAM_DEGREE_MINUTES) -- base_url override exists for tests; production uses api.myuplink.com. if config.base_url and config.base_url ~= "" then BASE_URL = config.base_url end -- setup_retry_ms override exists for tests (0 = retry immediately). @@ -351,9 +363,19 @@ function driver_poll() 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 - if by_id[PARAM_OUTDOOR_TEMP] then host.emit_metric("hp_outdoor_temp_c", decode_temp(by_id[PARAM_OUTDOOR_TEMP]) or 0, "°C") end + local hw_temp = decode_temp(by_id[PARAM_HW_TEMP]) + if hw_temp ~= nil then host.emit_metric("hp_hw_top_temp_c", hw_temp, "°C") end + + local indoor_temp = decode_temp(by_id[PARAM_INDOOR_TEMP]) + if indoor_temp ~= nil then host.emit_metric("hp_indoor_temp_c", indoor_temp, "°C") end + + local outdoor_temp = decode_temp(by_id[PARAM_OUTDOOR_TEMP]) + if outdoor_temp ~= nil then host.emit_metric("hp_outdoor_temp_c", outdoor_temp, "°C") end + + if by_id[PARAM_DEGREE_MINUTES] then + local dm = valid_raw(by_id[PARAM_DEGREE_MINUTES].value) + if dm ~= nil then host.emit_metric("hp_degree_minutes", dm, "DM") end + end -- Everything else → hp_ with its unit, so the UI can -- auto-group (temperatures / power / frequency / state / …). Skip the @@ -361,12 +383,13 @@ function driver_poll() local canonical = { [tostring(PARAM_POWER)] = true, [tostring(PARAM_HW_TEMP)] = true, [tostring(PARAM_INDOOR_TEMP)] = true, [tostring(PARAM_OUTDOOR_TEMP)] = true, + [tostring(PARAM_DEGREE_MINUTES)] = true, } local seen = {} for _, pt in ipairs(data) do local pid = tostring(pt.parameterId or "") if pt.parameterId and not canonical[pid] then - local raw = tonumber(pt.value) + local raw = valid_raw(pt.value) if raw ~= nil then local unit = pt.parameterUnit or pt.unit or "" local name = sanitize_metric_name(pt.parameterName, pid) diff --git a/drivers/tests/lua_harness/test_myuplink_f750.lua b/drivers/tests/lua_harness/test_myuplink_f750.lua new file mode 100644 index 0000000..7b43159 --- /dev/null +++ b/drivers/tests/lua_harness/test_myuplink_f750.lua @@ -0,0 +1,86 @@ +local ROOT = "./" + +dofile(ROOT .. "drivers/tests/lua_harness/host_mock.lua") + +local failed = 0 + +local function fail(msg) + io.stderr:write("FAIL: " .. msg .. "\n") + failed = failed + 1 +end + +local function expect_metric(name, value, unit) + local metric = host._metrics[name] + if not metric then + fail("missing metric " .. name) + return + end + if metric.value ~= value then + fail(name .. ": expected value " .. tostring(value) + .. ", got " .. tostring(metric.value)) + end + if metric.unit ~= unit then + fail(name .. ": expected unit " .. tostring(unit) + .. ", got " .. tostring(metric.unit)) + end +end + +local function expect_absent(name) + if host._metrics[name] then + fail("unexpected metric " .. name) + end +end + +dofile(ROOT .. "drivers/lua/myuplink.lua") + +host._http_responses["https://api.myuplink.com/oauth/token"] = + [[{"access_token":"t","expires_in":3600}]] + +host._http_responses["https://api.myuplink.com/v2/devices/F750/points"] = [[ +[ + {"parameterId":40004,"value":11.6,"parameterUnit":"°C","parameterName":"Outdoor temperature BT1"}, + {"parameterId":40013,"value":50.3,"parameterUnit":"°C","parameterName":"Hot water top BT7"}, + {"parameterId":40014,"value":49.1,"parameterUnit":"°C","parameterName":"Hot water charging BT6"}, + {"parameterId":40033,"value":22.8,"parameterUnit":"°C","parameterName":"Room temperature BT50"}, + {"parameterId":40940,"value":86,"parameterUnit":"DM","parameterName":"current value"}, + {"parameterId":41778,"value":0,"parameterUnit":"Hz","parameterName":"Current com­pressor fre­quency"}, + {"parameterId":43084,"value":0,"parameterUnit":"kW","parameterName":"Power internal add. heat"}, + {"parameterId":43124,"value":-32768,"parameterUnit":"m3/h","parameterName":"Reference air speed sensor"}, + {"parameterId":44298,"value":10494.7,"parameterUnit":"kWh","parameterName":"Hot water incl internal add heat"}, + {"parameterId":50225,"value":-32768,"parameterUnit":"°C","parameterName":"Current temperature system 1"}, + {"parameterId":50233,"value":-32768,"parameterUnit":"°C","parameterName":"Set point temp system 1 heat"} +] +]] + +driver_init({ + client_id = "id", + client_secret = "secret", + refresh_token = "refresh", + device_id = "F750", + setup_retry_ms = 0, +}) + +local ok, interval = pcall(driver_poll) +if not ok then + fail("myuplink poll threw: " .. tostring(interval)) +else + expect_metric("hp_hw_top_temp_c", 50.3, "°C") + expect_metric("hp_indoor_temp_c", 22.8, "°C") + expect_metric("hp_outdoor_temp_c", 11.6, "°C") + + expect_metric("hp_degree_minutes", 86, "DM") + expect_metric("hp_current_compressor_frequency", 0, "Hz") + expect_metric("hp_power_internal_add_heat", 0, "W") + expect_metric("hp_hot_water_incl_internal_add_heat", 10494700, "Wh") + + expect_absent("hp_reference_air_speed_sensor") + expect_absent("hp_current_temperature_system_1") + expect_absent("hp_set_point_temp_system_1_heat") +end + +if failed > 0 then + io.stderr:write(failed .. " checks failed\n") + os.exit(1) +end + +print("PASS") diff --git a/drivers/tests/test_myuplink.py b/drivers/tests/test_myuplink.py new file mode 100644 index 0000000..5ff5623 --- /dev/null +++ b/drivers/tests/test_myuplink.py @@ -0,0 +1,22 @@ +"""MyUplink driver regression tests for NIBE F750 telemetry.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LUA = ROOT / "lua55" + +pytestmark = pytest.mark.skipif( + not LUA.exists(), reason="run make check to build ./lua55") + + +def test_myuplink_f750_telemetry(): + result = subprocess.run( + [str(LUA), "drivers/tests/lua_harness/test_myuplink_f750.lua"], + capture_output=True, text=True, cwd=ROOT) + assert result.returncode == 0, result.stdout + result.stderr + assert "PASS" in result.stdout diff --git a/index.yaml b/index.yaml index 3dff6c9..0c52320 100644 --- a/index.yaml +++ b/index.yaml @@ -413,15 +413,15 @@ drivers: size_bytes: 3766 sha256: "5f985b8917aea7b08fba13c02506b45e40232a763393dc6af7c6eeb6ab5853af" - name: "myuplink" - version: "1.2.3" + version: "1.2.4" tier: core protocol: http connectivity: cloud setup: [vendor_portal] ders: [heatpump] control: false - size_bytes: 17562 - sha256: "1eca0cdd1cd2dabf772cba90b359d55e7c8f6e350e403623227e9a5a03b20d9c" + size_bytes: 18431 + sha256: "5667359d514403650634d6a2ca120a99d391dce2a01d63bf7e45a64a3f6054a3" - name: "nibe_local" version: "1.2.1" tier: core diff --git a/manifests/myuplink.yaml b/manifests/myuplink.yaml index 9312a56..9fe283b 100644 --- a/manifests/myuplink.yaml +++ b/manifests/myuplink.yaml @@ -1,5 +1,5 @@ name: "myuplink" -version: "1.2.3" +version: "1.2.4" tier: core author: "Sourceful Labs AB" protocol: http @@ -21,9 +21,9 @@ upstream_docs: kind: changelog url_stability: stable min_host_version: "2.0.0" -size_bytes: 17562 +size_bytes: 18431 dkb_id: "myuplink" -sha256: "1eca0cdd1cd2dabf772cba90b359d55e7c8f6e350e403623227e9a5a03b20d9c" +sha256: "5667359d514403650634d6a2ca120a99d391dce2a01d63bf7e45a64a3f6054a3" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 5179564..963a623 100644 --- a/support-status.json +++ b/support-status.json @@ -947,7 +947,7 @@ }, { "catalog_source": true, - "catalog_version": "1.2.3", + "catalog_version": "1.2.4", "driver_id": "myuplink", "targets": { "blixt-l1": {