diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9df36..fec41b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## [Unreleased] +### myuplink 1.2.4 + +One slow request no longer marks the heat pump offline. Core marks a driver +offline after 60 s without a reading, and a host HTTP request may take 15 s +before it times out. After a failed read the driver waited 30 s, so one +timed-out request left 75 s without a reading. On one home box from 22 to 25 +October 2026 that happened 124 times; in 109 of them the next read worked. +The driver now retries a failed read or sign-in once after 2 s, which keeps +that gap near 47 s. A second failure in a row waits the full 30 s, so an +outage of a minute or more still shows as offline, and the driver stays far +below myUplink's limit of 25 requests a minute. A good read now asks for +30 s, the wait Core already used: Core holds a returned wait to 30 s at most. +A failed read still emits nothing. + +A rotated `refresh_token` that the host could not store is now stored on the +next poll. Before, a failed store (SQLite busy, seen once on 25 October) left +the new token only in memory until the next rotation, about an hour later. A +reload or restart in that hour started from the old token; if myUplink no +longer accepts it, the driver cannot sign in until the owner connects again. +The store comes after the poll's reading, so a slow store does not hold the +reading back. The driver warns once, and logs when the store succeeds. It +does not store the token in `driver_cleanup`, where a reconnect may just have +stored a newer one. Tested in the Lua harness and against FTW's Go host with a read that +stalls past the 15 s timeout; not yet run on the box. + ### easee_cloud 1.3.8 Two fixes to the 7 A start from 1.3.7, from its review. A reloaded driver diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 309df45..cb7210d 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -96,8 +96,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 adbf916..6b4cabd 100644 --- a/devices.yaml +++ b/devices.yaml @@ -969,7 +969,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..b4a5195 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 @@ -76,6 +76,33 @@ local BASE_URL = "https://api.myuplink.com" local access_token = nil local token_expires_at = 0 local refresh_token = nil -- rotated on each refresh; persisted via host.persist_secret +-- A rotated refresh_token the host has not stored yet. If myUplink retires +-- the old token when it issues a new one, a token held only in memory is +-- lost on the next reload or restart, and signing in again needs the browser +-- consent. So the token is stored after each poll's reading, and a failed +-- store is retried on every poll until it succeeds. After the reading: once +-- Core waits out a busy database, one store can take 5 s, and it must not +-- hold a reading back. Not at driver_cleanup: a reconnect has just stored a +-- fresh token by then, and this older one must not replace it. +local unsaved_token = nil +local persist_failures = 0 + +-- Poll timing. Core marks a driver offline when no reading has arrived for +-- 60 s (site.watchdog_timeout_s) and caps a wait returned from driver_poll at +-- 30 s. One host HTTP request may take up to 15 s before it times out. +-- myUplink normally answers in under half a second, but on one home box in +-- October 2026 about two requests an hour hit that 15 s timeout, and in 109 +-- of 124 cases the next poll worked. Waiting the full 30 s after such a +-- failure left 75 s without a reading (30 s wait, 15 s timeout, 30 s wait), +-- so every slow request marked the pump offline. A failed poll is now +-- retried once after 2 s, so the gap stays near 47 s plus the next answer. +-- A second failure in a row waits the full 30 s: two failures in a row are +-- an outage of a minute or more, and Core should show that. At most one +-- quick retry per outage keeps the driver far below myUplink's documented +-- limit of 25 requests a minute. +local POLL_MS = 30000 +local RETRY_SOON_MS = 2000 +local retried = false -- the last poll failed and was retried soon -- Self-heal: NIBE/MyUplink is touchy right after consent (token propagation / -- rate-limit), so the first auth or device-detect can fail. Rather than idle @@ -104,6 +131,28 @@ end -- ---- Auth ---------------------------------------------------------------- +-- Store the rotated refresh_token if the host has not got it yet. Warn on +-- the first failure only, so a host that can never store it warns once per +-- rotation, as before, rather than on every poll. +local function save_rotated_token() + if not unsaved_token then return end + local ok, perr = host.persist_secret("refresh_token", unsaved_token) + if ok then + if persist_failures > 0 then + host.log("info", "MyUplink: rotated refresh_token persisted after " + .. persist_failures .. " failed attempt(s)") + end + unsaved_token = nil + persist_failures = 0 + return + end + persist_failures = persist_failures + 1 + if persist_failures == 1 then + host.log("warn", "MyUplink: could not persist rotated refresh_token, will retry on each poll: " + .. tostring(perr)) + end +end + local function fetch_token() if not refresh_token then host.log("warn", "MyUplink: not connected — complete the OAuth connect in Settings → Devices (no refresh_token)") @@ -144,10 +193,8 @@ local function fetch_token() -- KV, so this never triggers a config-reload loop. if data.refresh_token and data.refresh_token ~= "" and data.refresh_token ~= refresh_token then refresh_token = data.refresh_token - local ok, perr = host.persist_secret("refresh_token", refresh_token) - if not ok then - host.log("warn", "MyUplink: could not persist rotated refresh_token: " .. tostring(perr)) - end + unsaved_token = refresh_token + persist_failures = 0 end return true end @@ -325,22 +372,33 @@ function driver_init(config) if not try_setup() then host.log("warn", "MyUplink: initial setup did not complete — will retry automatically") end + save_rotated_token() end -function driver_poll() - if not client_id or not client_secret or not refresh_token then return 30000 end +-- The wait after a failed sign-in or read: 2 s the first time, then the +-- full poll. See POLL_MS. +local function after_failure() + if retried then return POLL_MS end + retried = true + return RETRY_SOON_MS +end + +-- Sign in if needed, read, emit. Returns the wait before the next poll. +local function read_and_emit() -- Not yet set up (first auth/detect failed, or just connected) → retry -- with backoff. Self-heals the "only works after a manual restart" case. if not device_id then if not try_setup() then return 30000 end end - if not ensure_auth() then return 30000 end + if not ensure_auth() then return after_failure() end local data, by_id, err = fetch_all_points() if err then - host.log("warn", "MyUplink: poll failed: " .. err) - return 30000 + local wait = after_failure() + host.log("warn", "MyUplink: poll failed (next try in " .. math.floor(wait / 1000) .. " s): " .. err) + return wait end + retried = false -- Canonical headline metrics: stable names the dashboard card + history -- depend on, mapped from fixed parameter IDs. @@ -384,7 +442,14 @@ function driver_poll() end end - return 60000 + return POLL_MS +end + +function driver_poll() + if not client_id or not client_secret or not refresh_token then return 30000 end + local wait = read_and_emit() + save_rotated_token() + return wait end function driver_command(_action, _power_w, _cmd) @@ -403,4 +468,8 @@ function driver_cleanup() -- next driver_init; clear it so a hot-reload starts from a clean slate. refresh_token = nil last_setup_ms = nil + -- Not stored here on purpose; see unsaved_token. + unsaved_token = nil + persist_failures = 0 + retried = false end diff --git a/drivers/tests/lua_harness/test_myuplink_poll_timing.lua b/drivers/tests/lua_harness/test_myuplink_poll_timing.lua new file mode 100644 index 0000000..e1b8386 --- /dev/null +++ b/drivers/tests/lua_harness/test_myuplink_poll_timing.lua @@ -0,0 +1,310 @@ +-- myuplink: one slow request must not leave a 60 s gap, and a rotated +-- refresh_token must reach the host's store even when the first write fails. +-- +-- Usage: lua55 drivers/tests/lua_harness/test_myuplink_poll_timing.lua + +local script_dir = arg[0]:match("(.*/)") or "./" +dofile(script_dir .. "host_mock.lua") + +local ROOT = script_dir .. "../../../" +local failed = 0 + +local function fail(msg) + failed = failed + 1 + io.stderr:write("FAIL " .. msg .. "\n") +end + +local function expect(cond, msg) + if not cond then fail(msg) end +end + +local TOKEN_URL = "https://api.myuplink.com/oauth/token" +local POINTS_URL = "https://api.myuplink.com/v2/devices/DEV1/points" +local POINTS = [[ +[ + {"parameterId": 10012, "value": 1.5, "parameterUnit": "kW", "parameterName": "Compressor"}, + {"parameterId": 40004, "value": 5, "parameterUnit": "°C", "parameterName": "Outdoor"} +] +]] +local BUSY = "database is locked (5) (SQLITE_BUSY)" +local TIMEOUT = "context deadline exceeded (Client.Timeout exceeded while awaiting headers)" + +-- A fake myUplink. `points_fail` makes the next reads fail; each refresh +-- issues a new refresh_token, as myUplink does. +local fake + +local function start(opts) + if driver_cleanup then driver_cleanup() end + host.reset() + fake = { + points_fail = 0, + token_fail = 0, + refreshes = 0, + persist_fail = 0, + persisted = {}, + expires_in = 3600, + } + function host.http_get(url, headers) + assert(url == POINTS_URL, "unexpected GET " .. tostring(url)) + if fake.points_fail > 0 then + fake.points_fail = fake.points_fail - 1 + return nil, "Get \"" .. url .. "\": " .. TIMEOUT + end + return POINTS + end + function host.http_post(url, body, headers) + assert(url == TOKEN_URL, "unexpected POST " .. tostring(url)) + if fake.token_fail > 0 then + fake.token_fail = fake.token_fail - 1 + return nil, "Post \"" .. url .. "\": " .. TIMEOUT + end + fake.refreshes = fake.refreshes + 1 + return host.json_encode({ + access_token = "access-" .. fake.refreshes, + expires_in = fake.expires_in, + refresh_token = "rotated-" .. fake.refreshes, + }) + end + function host.persist_secret(key, value) + assert(key == "refresh_token", "unexpected secret " .. tostring(key)) + if fake.persist_fail > 0 then + fake.persist_fail = fake.persist_fail - 1 + return false, BUSY + end + table.insert(fake.persisted, value) + return true + end + for k, v in pairs(opts or {}) do fake[k] = v end + dofile(ROOT .. "drivers/lua/myuplink.lua") + driver_init({ + client_id = "id", + client_secret = "secret", + refresh_token = "refresh-0", + device_id = "DEV1", + setup_retry_ms = 0, + }) +end + +local function count_logs(text) + local n = 0 + for _, line in ipairs(host._logs) do + if line:find(text, 1, true) then n = n + 1 end + end + return n +end + +local function poll() + local ok, wait = pcall(driver_poll) + if not ok then + fail("driver_poll threw: " .. tostring(wait)) + return nil + end + return wait +end + +--------------------------------------------------------------------------- +-- A good poll waits 30 s: Core's ceiling, half its 60 s watchdog window. +--------------------------------------------------------------------------- + +start() +local wait = poll() +expect(wait == 30000, "good poll waits 30000 ms, got " .. tostring(wait)) +expect(host._metrics["hp_power_w"] ~= nil, "good poll emits hp_power_w") + +--------------------------------------------------------------------------- +-- One failed read is retried after 2 s; a second failure in a row waits the +-- full poll; a good read resets it, so the next outage gets one quick retry. +--------------------------------------------------------------------------- + +start() +expect(poll() == 30000, "first poll is good") +host._metrics = {} +fake.points_fail = 2 +wait = poll() +expect(wait == 2000, "first failure retries after 2000 ms, got " .. tostring(wait)) +expect(host._metrics["hp_power_w"] == nil, "a failed read emits nothing") +wait = poll() +expect(wait == 30000, "second failure in a row waits 30000 ms, got " .. tostring(wait)) +wait = poll() +expect(wait == 30000, "recovery waits 30000 ms, got " .. tostring(wait)) +expect(host._metrics["hp_power_w"] ~= nil, "recovery emits again") +fake.points_fail = 1 +wait = poll() +expect(wait == 2000, "a new outage gets a quick retry again, got " .. tostring(wait)) +expect(count_logs("next try in 2 s") == 2, "each quick retry is logged") +expect(count_logs("next try in 30 s") == 1, "the slow retry is logged") + +--------------------------------------------------------------------------- +-- A failed token refresh follows the same rule and emits nothing. +--------------------------------------------------------------------------- + +start({ token_fail = 2 }) +host._metrics = {} +wait = poll() +expect(wait == 2000, "failed sign-in retries after 2000 ms, got " .. tostring(wait)) +expect(host._metrics["hp_power_w"] == nil, "failed sign-in emits nothing") +wait = poll() +expect(wait == 30000, "second failed sign-in waits 30000 ms, got " .. tostring(wait)) +wait = poll() +expect(wait == 30000 and host._metrics["hp_power_w"] ~= nil, "sign-in recovers") + +--------------------------------------------------------------------------- +-- The watchdog: Core marks the driver offline after 60 s without a reading. +-- Model the run loop: the next poll starts `wait` after the last one ended, +-- held within the 1–30 s Core allows a returned wait. A read takes 0.3 s, or +-- 15 s when it times out. +--------------------------------------------------------------------------- + +local function longest_gap(outcomes) + start() + local t, last_emit, gap = 0, nil, 0 + for _, outcome in ipairs(outcomes) do + host._metrics = {} + if outcome == "timeout" then fake.points_fail = 1 end + local w = math.min(math.max(poll() or 30000, 1000), 30000) + t = t + (outcome == "timeout" and 15 or 0.3) + if host._metrics["hp_power_w"] then + if last_emit then gap = math.max(gap, t - last_emit) end + last_emit = t + end + t = t + w / 1000 + end + return gap +end + +local gap = longest_gap({ "ok", "timeout", "ok", "ok" }) +expect(gap < 60, "one timed-out read keeps the gap under 60 s, got " .. gap) +gap = longest_gap({ "ok", "timeout", "timeout", "ok" }) +expect(gap > 60, "two timed-out reads in a row still show as offline, got " .. gap) + +--------------------------------------------------------------------------- +-- A rotated refresh_token that the host could not store is stored on a +-- later poll. Reading carries on meanwhile; the warning appears once. +--------------------------------------------------------------------------- + +start({ persist_fail = 3 }) +-- device_id is configured, so the first sign-in happens on the first poll. +expect(poll() == 30000, "poll works when the first store fails") +expect(host._metrics["hp_power_w"] ~= nil, "readings flow while the token is unsaved") +expect(#fake.persisted == 0, "first store failed") +expect(count_logs("could not persist rotated refresh_token") == 1, "first failure warns") +expect(poll() == 30000, "poll works while the token is unsaved") +expect(poll() == 30000, "poll works while the token is unsaved") +expect(#fake.persisted == 0, "store still failing after two retries") +expect(count_logs("could not persist rotated refresh_token") == 1, "warns once, not on every poll") +poll() +expect(#fake.persisted == 1 and fake.persisted[1] == "rotated-1", + "the rotated token is stored once the host accepts it") +expect(count_logs("persisted after 3 failed attempt(s)") == 1, "recovery is logged") +poll() +expect(#fake.persisted == 1, "a stored token is not written again") + +-- The next rotation is stored in the same poll and replaces the old one. +start({ expires_in = 0 }) +poll() +expect(fake.persisted[#fake.persisted] == "rotated-" .. fake.refreshes, + "each rotation is stored: " .. tostring(fake.persisted[#fake.persisted])) + +-- Rotations while the host keeps refusing: once it accepts, the newest token +-- is stored, never an older one, and never an older one after a newer one. +start({ expires_in = 0, persist_fail = 100 }) +poll(); poll(); poll() +expect(fake.refreshes == 3, "three polls, three rotations, got " .. fake.refreshes) +expect(#fake.persisted == 0, "nothing stored while the host refuses") +fake.persist_fail = 0 +poll() +local newest = 0 +for _, v in ipairs(fake.persisted) do + local n = tonumber(v:match("^rotated%-(%d+)$")) or -1 + expect(n >= newest, "older token " .. v .. " stored after a newer one") + newest = math.max(newest, n) +end +expect(fake.persisted[#fake.persisted] == "rotated-" .. fake.refreshes, + "the last stored token is the latest, got " .. tostring(fake.persisted[#fake.persisted])) + +--------------------------------------------------------------------------- +-- A store comes after the reading. Once Core waits out a busy database, one +-- store can take 5 s; it must not hold back the reading of that poll. +--------------------------------------------------------------------------- + +start({ expires_in = 0 }) +local emitted_before_store = nil +function host.persist_secret(key, value) + if emitted_before_store == nil then + emitted_before_store = host._metrics["hp_power_w"] ~= nil + end + return true +end +host._metrics = {} +poll() +expect(emitted_before_store == true, "the reading is emitted before the rotated token is stored") + +start({ expires_in = 0, persist_fail = 1 }) +host._metrics = {} +fake.points_fail = 1 +poll() +expect(#fake.persisted == 0 and fake.persist_fail == 0, + "a poll whose read failed still tries the store") +poll() +expect(#fake.persisted >= 1, "and stores on the next poll") + +-- The same run loop as above, with a clock: every store blocks 5 s and +-- fails, and every poll signs in again. One timed-out read must still keep +-- the gap between readings under the 60 s watchdog. +local function gap_with_slow_store(outcomes) + start({ expires_in = 0 }) + local now = 0 + function host.millis() return now end + function host.persist_secret() now = now + 5000; return false, BUSY end + local get = host.http_get + local post = host.http_post + local timeout_next = false + function host.http_get(url, headers) + if timeout_next then now = now + 15000; timeout_next = false; return nil, TIMEOUT end + now = now + 300 + return get(url, headers) + end + function host.http_post(url, body, headers) + now = now + 300 + return post(url, body, headers) + end + local last_emit, longest = nil, 0 + for _, outcome in ipairs(outcomes) do + host._metrics = {} + timeout_next = outcome == "timeout" + local w = math.min(math.max(poll() or 30000, 1000), 30000) + local emit_at = host._metrics["hp_power_w"] and host._metrics["hp_power_w"].at + if emit_at then + if last_emit then longest = math.max(longest, emit_at - last_emit) end + last_emit = emit_at + end + now = now + w + end + return longest / 1000 +end +local mock_emit_metric, mock_millis = host.emit_metric, host.millis +function host.emit_metric(name, value, unit) + mock_emit_metric(name, value, unit) + host._metrics[name].at = host.millis() +end +local slow_gap = gap_with_slow_store({ "ok", "ok", "timeout", "ok", "ok" }) +expect(slow_gap < 60, "a 5 s store and one timed-out read stay under 60 s, got " .. slow_gap) +host.emit_metric, host.millis = mock_emit_metric, mock_millis + +--------------------------------------------------------------------------- +-- driver_cleanup never stores: a reconnect may just have stored a fresh +-- token, and this older one must not replace it. +--------------------------------------------------------------------------- + +start({ persist_fail = 1 }) +poll() +local stored_before = #fake.persisted +expect(stored_before == 0, "token unsaved before cleanup") +driver_cleanup() +expect(#fake.persisted == stored_before, "cleanup does not store the unsaved token") + +if failed > 0 then + io.stderr:write(failed .. " checks failed\n") + os.exit(1) +end +print("PASS") diff --git a/drivers/tests/test_myuplink_poll_timing.py b/drivers/tests/test_myuplink_poll_timing.py new file mode 100644 index 0000000..83c656d --- /dev/null +++ b/drivers/tests/test_myuplink_poll_timing.py @@ -0,0 +1,24 @@ +"""myuplink retries a failed read soon and stores a rotated token later.""" + +from pathlib import Path +import subprocess + +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_poll_timing_and_token_store(): + result = subprocess.run( + [str(LUA), "drivers/tests/lua_harness/test_myuplink_poll_timing.lua"], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "PASS" in result.stdout diff --git a/index.yaml b/index.yaml index 36094da..bced342 100644 --- a/index.yaml +++ b/index.yaml @@ -423,15 +423,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: 20684 + sha256: "bd28dbb35a9f5c811b7f1e76f3ee3be1df6f67ab0b84d47b062febb10c844828" - name: "nibe_local" version: "1.2.1" tier: core diff --git a/manifests/myuplink.yaml b/manifests/myuplink.yaml index 9312a56..a38a726 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: 20684 dkb_id: "myuplink" -sha256: "1eca0cdd1cd2dabf772cba90b359d55e7c8f6e350e403623227e9a5a03b20d9c" +sha256: "bd28dbb35a9f5c811b7f1e76f3ee3be1df6f67ab0b84d47b062febb10c844828" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 903ad1f..6898d8e 100644 --- a/support-status.json +++ b/support-status.json @@ -968,7 +968,7 @@ }, { "catalog_source": true, - "catalog_version": "1.2.3", + "catalog_version": "1.2.4", "driver_id": "myuplink", "targets": { "blixt-l1": {