From 7440208c1906f59181dca6a409f73cd2010aa1e2 Mon Sep 17 00:00:00 2001 From: Artur Date: Sat, 1 Aug 2026 22:31:36 -0300 Subject: [PATCH] fix(hassio): recover from CPU budget kills that froze the panel on (#205) "Connecting" --- hassio/panel.luau | 177 +++++++++++--- hassio/plugin.toml | 2 +- hassio/service_sse.luau | 411 +++++++++++++++++++++------------ hassio/shortcut.luau | 8 +- hassio/shortcut_2.luau | 8 +- hassio/shortcut_3.luau | 8 +- hassio/shortcut_4.luau | 8 +- hassio/translations/en.json | 1 + hassio/translations/pt-BR.json | 1 + 9 files changed, 431 insertions(+), 193 deletions(-) diff --git a/hassio/panel.luau b/hassio/panel.luau index b7a24d9..13064b4 100644 --- a/hassio/panel.luau +++ b/hassio/panel.luau @@ -17,6 +17,10 @@ local PENDING_TIMEOUT_SECONDS = 8 local view = "list" -- "list" | "browser" local allEntities = {} local browserLoading = false +local browserLoadingSince = nil +local browserFailed = false + +local BROWSER_TIMEOUT_SECONDS = 20 local searchText = "" local monitoredEntities = {} -- current monitored list (owned here, saved to file) local panelOpenCount = 0 @@ -51,11 +55,13 @@ local function getCleanToken() return haToken:gsub("^%s+", ""):gsub("%s+$", "") end -local function haPost(path, body) +local function haPost(path, body, onFailure) local url = getCleanUrl() - if not url or url == "" then return end local cleanToken = getCleanToken() - if cleanToken == "" then return end + if not url or url == "" or cleanToken == "" then + if onFailure then onFailure() end + return + end local encodedBody = noctalia.json.encode(body) noctalia.http({ @@ -66,11 +72,16 @@ local function haPost(path, body) "Content-Type: application/json", }, body = encodedBody, - }, function(response: HttpResponse)end) + }, function(response: HttpResponse) + local httpStatus = response and response.status or 0 + if httpStatus >= 200 and httpStatus < 300 then return end + noctalia.log("Home Assistant rejected " .. path .. ": status " .. tostring(httpStatus)) + if onFailure then onFailure() end + end) end -local function callService(domain, service, entityId) - haPost("/api/services/" .. domain .. "/" .. service, { entity_id = entityId }) +local function callService(domain, service, entityId, onFailure) + haPost("/api/services/" .. domain .. "/" .. service, { entity_id = entityId }, onFailure) end local MANAGED_ENTITIES_FILE = "managed_entities.json" @@ -135,19 +146,34 @@ local function toggleBrowserPin(entityId) render() end -local function fetchAllEntities() - if browserLoading then return end +-- /api/states is megabytes on a large instance and decoding it overruns the host's +-- per-callback CPU budget. The tab and newline outside the {{ }} are the record +-- separators; the ones inside replace() are escapes for Jinja to interpret. +local ENTITY_LIST_TEMPLATE = + "{% for s in states %}{{ s.entity_id }}\t{{ s.name | replace('\\t',' ') | replace('\\n',' ') }}\n{% endfor %}" + +-- HA iterates `states` in entity_id order, so this is normally just the linear check. +local function sortEntitiesIfNeeded(list) + for i = 2, #list do + if list[i].entity_id < list[i - 1].entity_id then + table.sort(list, function(a, b) return a.entity_id < b.entity_id end) + return + end + end +end + +local function finishBrowserLoad(list, failed) + allEntities = list + browserLoading = false + browserLoadingSince = nil + browserFailed = failed == true + render() +end + +-- Continues an already-started load, so it deliberately doesn't re-check browserLoading. +local function fetchAllEntitiesFromStates() local cleanToken = getCleanToken() local url = getCleanUrl() - if not url or url == "" or cleanToken == "" then - allEntities = {} - browserLoading = false - render() - return - end - - browserLoading = true - render() noctalia.http({ url = url .. "/api/states", @@ -155,23 +181,88 @@ local function fetchAllEntities() "Authorization: Bearer " .. cleanToken, } }, function(response: HttpResponse) - browserLoading = false - if response and response.body and response.body ~= "" then - local states = noctalia.json.decode(response.body) - if type(states) == "table" then - allEntities = {} - for _, s in ipairs(states) do - local attrs = s.attributes or {} - table.insert(allEntities, { - entity_id = s.entity_id, - friendly_name = attrs.friendly_name or s.entity_id, - domain = s.entity_id:match("^([^.]+)"), - }) - end - table.sort(allEntities, function(a, b) return a.entity_id < b.entity_id end) - end + local httpStatus = response and response.status or 0 + local body = response and response.body or "" + if httpStatus < 200 or httpStatus >= 300 or body == "" then + noctalia.log("Could not list entities: /api/states returned " .. tostring(httpStatus)) + finishBrowserLoad({}, true) + return end + + local states = noctalia.json.decode(body) + if type(states) ~= "table" then + noctalia.log("Could not list entities: /api/states was not decodable") + finishBrowserLoad({}, true) + return + end + + local list = {} + for _, s in ipairs(states) do + local attrs = s.attributes or {} + table.insert(list, { + entity_id = s.entity_id, + friendly_name = attrs.friendly_name or s.entity_id, + domain = s.entity_id:match("^([^.]+)"), + }) + end + sortEntitiesIfNeeded(list) + finishBrowserLoad(list) + end) +end + +local function fetchAllEntities() + if browserLoading then return end + local cleanToken = getCleanToken() + local url = getCleanUrl() + if not url or url == "" or cleanToken == "" then + allEntities = {} + browserLoading = false + browserLoadingSince = nil render() + return + end + + browserLoading = true + browserLoadingSince = os.clock() + browserFailed = false + render() + + noctalia.http({ + url = url .. "/api/template", + method = "POST", + headers = { + "Authorization: Bearer " .. cleanToken, + "Content-Type: application/json", + }, + body = noctalia.json.encode({ template = ENTITY_LIST_TEMPLATE }), + }, function(response: HttpResponse) + local httpStatus = response and response.status or 0 + local body = response and response.body or "" + + if httpStatus < 200 or httpStatus >= 300 or body == "" then + noctalia.log("Entity list template unavailable (status " .. tostring(httpStatus) + .. "), falling back to /api/states") + fetchAllEntitiesFromStates() + return + end + + local list = {} + for entityId, name in body:gmatch("([^\t\n]+)\t([^\n]*)") do + table.insert(list, { + entity_id = entityId, + friendly_name = name ~= "" and name or entityId, + domain = entityId:match("^([^.]+)"), + }) + end + + if #list == 0 then + noctalia.log("Entity list template returned nothing usable, falling back to /api/states") + fetchAllEntitiesFromStates() + return + end + + sortEntitiesIfNeeded(list) + finishBrowserLoad(list) end) end @@ -229,7 +320,10 @@ local function actEntity(eid) local newState = entity.state == "on" and "off" or "on" pendingToggle[eid] = newState pendingSince[eid] = os.clock() - callService(d, "toggle", eid) + callService(d, "toggle", eid, function() + clearPending(eid) + render() + end) render() end end @@ -309,6 +403,7 @@ function onBackToList() view = "list" allEntities = {} searchText = "" + browserFailed = false render() end @@ -495,6 +590,9 @@ local function buildBrowserBody() if browserLoading then return ui.label({ text = tr("loading_entities"), color = "on_surface_variant" }) end + if browserFailed then + return ui.label({ text = tr("load_entities_failed"), color = "error" }) + end if #allEntities == 0 then return ui.label({ text = tr("no_entities_found"), color = "on_surface_variant" }) end @@ -573,7 +671,18 @@ function onClose() end function update() - if reconcilePending(os.clock()) then + local now = os.clock() + + if browserLoading and browserLoadingSince and (now - browserLoadingSince) > BROWSER_TIMEOUT_SECONDS then + browserLoading = false + browserLoadingSince = nil + browserFailed = true + reconcilePending(now) + render() + return + end + + if reconcilePending(now) then render() end end diff --git a/hassio/plugin.toml b/hassio/plugin.toml index 6319ec3..042f525 100644 --- a/hassio/plugin.toml +++ b/hassio/plugin.toml @@ -1,6 +1,6 @@ id = "pozzoo/hassio" name = "Home Assistant" -version = "2.0.4" +version = "2.0.5" plugin_api = 9 author = "Pozzoo" license = "MIT" diff --git a/hassio/service_sse.luau b/hassio/service_sse.luau index e06da54..82b6a4a 100644 --- a/hassio/service_sse.luau +++ b/hassio/service_sse.luau @@ -10,6 +10,15 @@ local function getCleanUrl() return (haUrlRaw:gsub("/$", "")) end +local function getCleanToken() + if type(haToken) ~= "string" then return "" end + return noctalia.string.trim(haToken) +end + +local function isConfigured() + return getCleanUrl() ~= "" and getCleanToken() ~= "" +end + local function loadManagedEntities() local dataDir, dirErr = noctalia.pluginDataDir() if not dataDir then @@ -25,7 +34,18 @@ local function loadManagedEntities() return {} end -local entities = loadManagedEntities() +local entities = {} +local managedSet = {} + +local function setEntities(list) + entities = list + managedSet = {} + for _, id in ipairs(entities) do + managedSet[id] = true + end +end + +setEntities(loadManagedEntities()) local status = "unconfigured" local entityStates = {} @@ -33,19 +53,31 @@ local sseActive = false local streamHandle = nil local MAX_RECONNECT_RETRIES = 10 -local CONNECTED_POLL_INTERVAL_MS = 30000 +local CONNECTED_POLL_INTERVAL_MS = 1000 local RECONNECT_POLL_INTERVAL_MS = 1000 local BACKOFF_POLL_INTERVAL_MS = 60000 +-- The host allows 30s transfer + 10s connect, so past 35s the callback is gone. +local REQUEST_TIMEOUT_SECONDS = 35 +-- The host allows 8 async callbacks per runtime. +local MAX_SNAPSHOT_REQUESTS_IN_FLIGHT = 4 +local ENTITY_FLUSH_INTERVAL_SECONDS = 0.2 + local retryCount = 0 local maxRetriesNotified = false +local authFailureNotified = false local backoff = false local connectionRequestInFlight = false -local connectingSince = nil +local requestStartedAt = nil -local function getCleanToken() - if type(haToken) ~= "string" then return "" end - return noctalia.string.trim(haToken) -end +local entitiesDirty = false +local lastEntityFlush = 0 + +local snapshotToken = 0 +local snapshotActive = false +local snapshotStartedAt = 0 +local snapshotQueue = {} +local snapshotPending = {} +local snapshotInFlight = 0 local function syncUpdateInterval() if status == "connected" and sseActive then @@ -57,9 +89,20 @@ local function syncUpdateInterval() end end -local function isConfigured() - local url = getCleanUrl() - return url and url ~= "" and getCleanToken() ~= "" +local function setStatus(next, reason) + if status == next then return end + status = next + noctalia.state.set("connection_status", status) + noctalia.log("connection status -> " .. status .. (reason and (" (" .. reason .. ")") or "")) + + if status == "connected" then + authFailureNotified = false + elseif status == "auth_failed" and not authFailureNotified then + authFailureNotified = true + noctalia.notifyError(noctalia.tr("notifications.auth_failed")) + end + + syncUpdateInterval() end local function haRequest(endpoint, method, body, callback) @@ -76,12 +119,11 @@ local function haRequest(endpoint, method, body, callback) local headers = { "Authorization: Bearer " .. cleanToken, } - -- Only advertise a JSON body when we actually send one. if jsonBody then table.insert(headers, "Content-Type: application/json") end - local launched = noctalia.http({ + return noctalia.http({ url = url, method = method, body = jsonBody, @@ -98,9 +140,6 @@ local function haRequest(endpoint, method, body, callback) body = response.body }) end) - - return launched - end local function supportsColorMode(modes, targets) @@ -148,43 +187,60 @@ local function processEntity(state) } end -local function updateEntity(entityId, newState) - if #entities > 0 then - local found = false - for _, id in ipairs(entities) do - if id == entityId then - found = true - break - end - end - if not found then return end +local function publishEntities() + local count = 0 + for _ in pairs(entityStates) do + count = count + 1 end + noctalia.state.set("entities", entityStates) + noctalia.state.set("entity_count", count) + entitiesDirty = false + lastEntityFlush = os.clock() +end - local processed = processEntity(newState) - if processed then - entityStates[entityId] = processed - noctalia.state.set("entities", entityStates) - - local count = 0 - for _ in pairs(entityStates) do - count = count + 1 - end - noctalia.state.set("entity_count", count) +-- Every publish is re-decoded by every watcher, so bursts get coalesced. +local function markEntitiesDirty() + entitiesDirty = true + if (os.clock() - lastEntityFlush) >= ENTITY_FLUSH_INTERVAL_SECONDS then + publishEntities() end end --- HA embeds event_type inside the data JSON rather than a separate SSE "event:" field +local function isManaged(entityId) + return managedSet[entityId] == true +end + +local function updateEntity(entityId, newState) + if not isManaged(entityId) then return end + + local processed = processEntity(newState) + if not processed then return end + + entityStates[entityId] = processed + if snapshotActive then + -- don't let the in-flight round overwrite fresher live data + snapshotPending[entityId] = processed + end + markEntitiesDirty() +end + local currentEvent = { data = "", event = "", } +local function eventTypeOf(parsed) + if currentEvent.event and currentEvent.event ~= "" then + return currentEvent.event + end + return parsed.event_type +end + local function tryDispatch() if not currentEvent.data then return end local parsed = noctalia.json.decode(currentEvent.data) if not parsed then return end - local evType = currentEvent.event or parsed.event_type - if evType ~= "state_changed" then return end + if eventTypeOf(parsed) ~= "state_changed" then return end local inner = parsed.data or {} if inner.entity_id and inner.new_state then updateEntity(inner.entity_id, inner.new_state) @@ -202,8 +258,7 @@ local function processSSELine(line) local data = line:match("^data:%s*(.+)") or "" local parsed = noctalia.json.decode(data) if parsed then - local evType = currentEvent.event or parsed.event_type - if evType == "state_changed" then + if eventTypeOf(parsed) == "state_changed" then local inner = parsed.data or {} if inner.entity_id and inner.new_state then updateEntity(inner.entity_id, inner.new_state) @@ -220,98 +275,127 @@ local function processSSELine(line) end end -local function fetchInitialStates() +local pumpSnapshot +local finishSnapshot +local fetchSnapshot +local startSSEStream +local connect + +function finishSnapshot() + snapshotActive = false + snapshotToken = snapshotToken + 1 + + local merged = {} + for _, id in ipairs(entities) do + merged[id] = snapshotPending[id] or entityStates[id] + end + entityStates = merged + snapshotPending = {} + snapshotQueue = {} + publishEntities() +end + +function pumpSnapshot() + if not snapshotActive then return end + + local token = snapshotToken + + while snapshotInFlight < MAX_SNAPSHOT_REQUESTS_IN_FLIGHT and #snapshotQueue > 0 do + local entityId = snapshotQueue[1] + snapshotInFlight = snapshotInFlight + 1 + + local launched = haRequest("/api/states/" .. entityId, "GET", nil, function(res) + if token ~= snapshotToken then return end + snapshotInFlight = snapshotInFlight - 1 + + if res.status == 404 then + noctalia.log("Entity not found in Home Assistant: " .. entityId) + elseif res.ok and res.body and res.body ~= "" then + local state = noctalia.json.decode(res.body) + if type(state) == "table" and state.entity_id then + snapshotPending[entityId] = processEntity(state) + end + end + + pumpSnapshot() + if snapshotActive and snapshotInFlight == 0 and #snapshotQueue == 0 then + finishSnapshot() + end + end) + + if not launched then + snapshotInFlight = snapshotInFlight - 1 + return + end + + table.remove(snapshotQueue, 1) + end + + if snapshotInFlight == 0 and #snapshotQueue == 0 then + finishSnapshot() + end +end + +function fetchSnapshot() + snapshotToken = snapshotToken + 1 + snapshotActive = true + snapshotStartedAt = os.clock() + snapshotInFlight = 0 + snapshotPending = {} + snapshotQueue = {} + for _, id in ipairs(entities) do + if type(id) == "string" and id ~= "" then + table.insert(snapshotQueue, id) + end + end + pumpSnapshot() +end + +function connect() if not isConfigured() or connectionRequestInFlight then return end + + requestStartedAt = os.clock() connectionRequestInFlight = true - connectingSince = os.clock() - local launched = haRequest("/api/states", "GET", nil, function(res) + + local launched = haRequest("/api/", "GET", nil, function(res) connectionRequestInFlight = false - connectingSince = nil + requestStartedAt = nil - -- status 0 = no HTTP response at all (couldn't connect / DNS / timeout): the - -- server is unreachable, which is a distinct case from an auth or HTTP error. if res.status == 0 then - status = "unreachable" noctalia.log("Cannot reach Home Assistant (connection failed)") - noctalia.state.set("connection_status", status) - syncUpdateInterval() + setStatus("unreachable", "transport failure") return end - if res.status == 401 or (res.body and (res.body:match("^401") or res.body:match("Unauthorized"))) then - status = "auth_failed" - noctalia.log("Authentication failed - check your token") - noctalia.notifyError(noctalia.tr("notifications.auth_failed")) - noctalia.state.set("connection_status", status) - syncUpdateInterval() + if res.status == 401 or res.status == 403 then + setStatus("auth_failed", "http " .. tostring(res.status)) return end - if not res.body or res.body == "" then - noctalia.log("Empty response body from /api/states") - status = "disconnected" - noctalia.state.set("connection_status", status) - syncUpdateInterval() + if not res.ok then + noctalia.log("Home Assistant API probe failed: status " .. tostring(res.status)) + setStatus("disconnected", "http " .. tostring(res.status)) return end - if not res.ok or res.status >= 400 then - status = "disconnected" - noctalia.log("HTTP request failed: status " .. (res.status or "unknown")) - noctalia.state.set("connection_status", status) - syncUpdateInterval() - return - end - - local allStates, err = noctalia.json.decode(res.body) - if not allStates then - noctalia.log("Failed to parse states: " .. (err or "unknown error")) - status = "disconnected" - noctalia.state.set("connection_status", status) - syncUpdateInterval() - return - end - - status = "connected" - noctalia.state.set("connection_status", status) retryCount = 0 maxRetriesNotified = false backoff = false + setStatus("connected", "api reachable") - entityStates = {} - local count = 0 - - for _, state in ipairs(allStates) do - if #entities == 0 then break end - for _, entityId in ipairs(entities) do - if state.entity_id == entityId then - entityStates[entityId] = processEntity(state) - count = count + 1 - break - end - end + if not startSSEStream() then + setStatus("disconnected", "stream failed to start") + return end - noctalia.state.set("entities", entityStates) - noctalia.state.set("entity_count", count) - - if status == "connected" then - if not startSSEStream() then - status = "disconnected" - noctalia.state.set("connection_status", status) - syncUpdateInterval() - else - syncUpdateInterval() - end - end + fetchSnapshot() + syncUpdateInterval() end) if not launched then connectionRequestInFlight = false - connectingSince = nil - status = "unreachable" - noctalia.state.set("connection_status", status) - syncUpdateInterval() + requestStartedAt = nil + setStatus("unreachable", "request not launched") end end @@ -344,18 +428,14 @@ function startSSEStream() sseActive = false currentEvent = {} - if result and result.ok and result.status == 401 then - status = "auth_failed" - noctalia.log("Authentication failed - check your token") - noctalia.notifyError(noctalia.tr("notifications.auth_failed")) - noctalia.state.set("connection_status", status) - syncUpdateInterval() + if result and (result.status == 401 or result.status == 403) then + setStatus("auth_failed", "stream http " .. tostring(result.status)) return end - status = "disconnected" - noctalia.log("SSE stream closed") - noctalia.state.set("connection_status", status) + -- 404 here means this HA serves no /api/stream, which otherwise looks like a flaky link. + noctalia.log("SSE stream closed (status " .. tostring(result and result.status or 0) .. ")") + setStatus("disconnected", "stream closed") syncUpdateInterval() end) @@ -372,24 +452,26 @@ end noctalia.state.watch("command", function(cmd) if cmd == "refresh" then - -- Just refetch a fresh snapshot over HTTP. Do NOT touch sseActive here: - -- the running stream (if any) is untouched by this, and forcing the - -- flag false would bypass startSSEStream()'s duplicate guard and spawn - -- a second stream on top of one that's still alive. - fetchInitialStates() + -- Do NOT clear sseActive: that bypasses startSSEStream()'s duplicate guard. + if status == "connected" then + if not sseActive then startSSEStream() end + fetchSnapshot() + else + connect() + end elseif cmd == "reload_entities" then local override = noctalia.state.get("entities_override") if type(override) == "table" then - entities = override + setEntities(override) else - entities = loadManagedEntities() + setEntities(loadManagedEntities()) + end + -- Filtering is client-side, so only the snapshot needs refetching. + if status == "connected" then + fetchSnapshot() + else + connect() end - -- Entity filtering happens client-side in updateEntity(), so the - -- existing stream keeps working for the new entity list as-is. - -- The initial snapshot needs refetching, though, to account for any - -- entities that were removed from the filter. See the note above for why - -- sseActive must not be forced here. - fetchInitialStates() end end) @@ -398,48 +480,67 @@ noctalia.state.watch("panel_open_signal", function(value) retryCount = 0 maxRetriesNotified = false backoff = false + syncUpdateInterval() end end) function update() - syncUpdateInterval() - if not isConfigured() then - status = "unconfigured" - noctalia.state.set("connection_status", status) + setStatus("unconfigured", "no url or token") + if streamHandle then + streamHandle.stop() + streamHandle = nil + end sseActive = false + syncUpdateInterval() return end - -- noctalia.http exposes no transport timeout, so a hung request would otherwise - -- pin status on "connecting" indefinitely. Treat 15s without a callback as a - -- failed attempt and fall through to the reconnect path below. - if status == "connecting" and connectingSince and (os.clock() - connectingSince) > 15 then - connectionRequestInFlight = false - connectingSince = nil - status = "disconnected" - noctalia.state.set("connection_status", status) - syncUpdateInterval() + if entitiesDirty and (os.clock() - lastEntityFlush) >= ENTITY_FLUSH_INTERVAL_SECONDS then + publishEntities() end - if status == "disconnected" or status == "unreachable" then + if snapshotActive then + if (os.clock() - snapshotStartedAt) > REQUEST_TIMEOUT_SECONDS then + noctalia.log("Entity snapshot timed out; publishing what arrived") + finishSnapshot() + else + pumpSnapshot() + end + end + + if connectionRequestInFlight and (os.clock() - (requestStartedAt or 0)) > REQUEST_TIMEOUT_SECONDS then + connectionRequestInFlight = false + requestStartedAt = nil + setStatus("disconnected", "request timed out") + end + + if status == "connected" and not sseActive then + setStatus("disconnected", "stream missing") + end + + -- Reconverge the store in case a publish was dropped mid-callback. + if noctalia.state.get("connection_status") ~= status then + noctalia.state.set("connection_status", status) + end + + if status ~= "connected" and not connectionRequestInFlight then retryCount = retryCount + 1 if retryCount >= MAX_RECONNECT_RETRIES and not backoff then - -- After the initial burst of fast retries, fall back to a - -- slow poll and keep trying, so the service reconnects on its own once HA is - -- reachable again (previously it suspended and only revived on panel open). backoff = true if not maxRetriesNotified then maxRetriesNotified = true - noctalia.notifyError(noctalia.tr("notifications.reconnect_failed", { count = MAX_RECONNECT_RETRIES })) + if not authFailureNotified then + noctalia.notifyError(noctalia.tr("notifications.reconnect_failed", { count = MAX_RECONNECT_RETRIES })) + end end - syncUpdateInterval() end - status = "connecting" - noctalia.state.set("connection_status", status) - fetchInitialStates() + setStatus("connecting", "attempt " .. tostring(retryCount)) + connect() end + + syncUpdateInterval() end function onExit() @@ -448,6 +549,7 @@ function onExit() streamHandle = nil end sseActive = false + snapshotActive = false end noctalia.state.set("connection_status", status) @@ -455,7 +557,8 @@ noctalia.state.set("entity_count", 0) noctalia.state.set("entities", {}) if isConfigured() then - status = "connecting" - noctalia.state.set("connection_status", status) - fetchInitialStates() + setStatus("connecting", "startup") + connect() end + +syncUpdateInterval() diff --git a/hassio/shortcut.luau b/hassio/shortcut.luau index 9fcd3f7..e5aba23 100644 --- a/hassio/shortcut.luau +++ b/hassio/shortcut.luau @@ -140,7 +140,13 @@ function onClick() "Content-Type: application/json", }, body = encoded, - }, function(response)end) + }, function(response) + local httpStatus = response and response.status or 0 + if httpStatus >= 200 and httpStatus < 300 then return end + noctalia.log("Home Assistant rejected toggle for " .. eid .. ": status " .. tostring(httpStatus)) + clearPending(eid) + render() + end) render() end diff --git a/hassio/shortcut_2.luau b/hassio/shortcut_2.luau index 65fb0b7..690cd68 100644 --- a/hassio/shortcut_2.luau +++ b/hassio/shortcut_2.luau @@ -140,7 +140,13 @@ function onClick() "Content-Type: application/json", }, body = encoded, - }, function(response)end) + }, function(response) + local httpStatus = response and response.status or 0 + if httpStatus >= 200 and httpStatus < 300 then return end + noctalia.log("Home Assistant rejected toggle for " .. eid .. ": status " .. tostring(httpStatus)) + clearPending(eid) + render() + end) render() end diff --git a/hassio/shortcut_3.luau b/hassio/shortcut_3.luau index 935444b..9961f71 100644 --- a/hassio/shortcut_3.luau +++ b/hassio/shortcut_3.luau @@ -140,7 +140,13 @@ function onClick() "Content-Type: application/json", }, body = encoded, - }, function(response)end) + }, function(response) + local httpStatus = response and response.status or 0 + if httpStatus >= 200 and httpStatus < 300 then return end + noctalia.log("Home Assistant rejected toggle for " .. eid .. ": status " .. tostring(httpStatus)) + clearPending(eid) + render() + end) render() end diff --git a/hassio/shortcut_4.luau b/hassio/shortcut_4.luau index 769536f..33e911d 100644 --- a/hassio/shortcut_4.luau +++ b/hassio/shortcut_4.luau @@ -140,7 +140,13 @@ function onClick() "Content-Type: application/json", }, body = encoded, - }, function(response)end) + }, function(response) + local httpStatus = response and response.status or 0 + if httpStatus >= 200 and httpStatus < 300 then return end + noctalia.log("Home Assistant rejected toggle for " .. eid .. ": status " .. tostring(httpStatus)) + clearPending(eid) + render() + end) render() end diff --git a/hassio/translations/en.json b/hassio/translations/en.json index 3cf6658..b67d0e3 100644 --- a/hassio/translations/en.json +++ b/hassio/translations/en.json @@ -10,6 +10,7 @@ "connecting": "Connecting…", "disconnected_reconnecting": "Disconnected. Reconnecting…", "hue": "Hue", + "load_entities_failed": "Could not load the entity list. Try again.", "loading_entities": "Loading entities…", "manage_entities_title": "Manage Entities", "no_entities_found": "No entities found", diff --git a/hassio/translations/pt-BR.json b/hassio/translations/pt-BR.json index 1165c1f..2f42a10 100644 --- a/hassio/translations/pt-BR.json +++ b/hassio/translations/pt-BR.json @@ -10,6 +10,7 @@ "connecting": "Conectando…", "disconnected_reconnecting": "Desconectado. Reconectando…", "hue": "Matiz", + "load_entities_failed": "Não foi possível carregar a lista de entidades. Tente novamente.", "loading_entities": "Carregando entidades…", "manage_entities_title": "Gerenciar Entidades", "no_entities_found": "Nenhuma entidade encontrada",