fix(hassio): recover from CPU budget kills that froze the panel on (#205)

"Connecting"
This commit is contained in:
Artur
2026-08-01 21:31:36 -04:00
committed by GitHub
parent f8767575e4
commit 7440208c19
9 changed files with 431 additions and 193 deletions
+143 -34
View File
@@ -17,6 +17,10 @@ local PENDING_TIMEOUT_SECONDS = 8
local view = "list" -- "list" | "browser" local view = "list" -- "list" | "browser"
local allEntities = {} local allEntities = {}
local browserLoading = false local browserLoading = false
local browserLoadingSince = nil
local browserFailed = false
local BROWSER_TIMEOUT_SECONDS = 20
local searchText = "" local searchText = ""
local monitoredEntities = {} -- current monitored list (owned here, saved to file) local monitoredEntities = {} -- current monitored list (owned here, saved to file)
local panelOpenCount = 0 local panelOpenCount = 0
@@ -51,11 +55,13 @@ local function getCleanToken()
return haToken:gsub("^%s+", ""):gsub("%s+$", "") return haToken:gsub("^%s+", ""):gsub("%s+$", "")
end end
local function haPost(path, body) local function haPost(path, body, onFailure)
local url = getCleanUrl() local url = getCleanUrl()
if not url or url == "" then return end
local cleanToken = getCleanToken() 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) local encodedBody = noctalia.json.encode(body)
noctalia.http({ noctalia.http({
@@ -66,11 +72,16 @@ local function haPost(path, body)
"Content-Type: application/json", "Content-Type: application/json",
}, },
body = encodedBody, 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 end
local function callService(domain, service, entityId) local function callService(domain, service, entityId, onFailure)
haPost("/api/services/" .. domain .. "/" .. service, { entity_id = entityId }) haPost("/api/services/" .. domain .. "/" .. service, { entity_id = entityId }, onFailure)
end end
local MANAGED_ENTITIES_FILE = "managed_entities.json" local MANAGED_ENTITIES_FILE = "managed_entities.json"
@@ -135,19 +146,34 @@ local function toggleBrowserPin(entityId)
render() render()
end end
local function fetchAllEntities() -- /api/states is megabytes on a large instance and decoding it overruns the host's
if browserLoading then return end -- 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 cleanToken = getCleanToken()
local url = getCleanUrl() local url = getCleanUrl()
if not url or url == "" or cleanToken == "" then
allEntities = {}
browserLoading = false
render()
return
end
browserLoading = true
render()
noctalia.http({ noctalia.http({
url = url .. "/api/states", url = url .. "/api/states",
@@ -155,23 +181,88 @@ local function fetchAllEntities()
"Authorization: Bearer " .. cleanToken, "Authorization: Bearer " .. cleanToken,
} }
}, function(response: HttpResponse) }, function(response: HttpResponse)
browserLoading = false local httpStatus = response and response.status or 0
if response and response.body and response.body ~= "" then local body = response and response.body or ""
local states = noctalia.json.decode(response.body) if httpStatus < 200 or httpStatus >= 300 or body == "" then
if type(states) == "table" then noctalia.log("Could not list entities: /api/states returned " .. tostring(httpStatus))
allEntities = {} finishBrowserLoad({}, true)
for _, s in ipairs(states) do return
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
end 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() 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)
end end
@@ -229,7 +320,10 @@ local function actEntity(eid)
local newState = entity.state == "on" and "off" or "on" local newState = entity.state == "on" and "off" or "on"
pendingToggle[eid] = newState pendingToggle[eid] = newState
pendingSince[eid] = os.clock() pendingSince[eid] = os.clock()
callService(d, "toggle", eid) callService(d, "toggle", eid, function()
clearPending(eid)
render()
end)
render() render()
end end
end end
@@ -309,6 +403,7 @@ function onBackToList()
view = "list" view = "list"
allEntities = {} allEntities = {}
searchText = "" searchText = ""
browserFailed = false
render() render()
end end
@@ -495,6 +590,9 @@ local function buildBrowserBody()
if browserLoading then if browserLoading then
return ui.label({ text = tr("loading_entities"), color = "on_surface_variant" }) return ui.label({ text = tr("loading_entities"), color = "on_surface_variant" })
end end
if browserFailed then
return ui.label({ text = tr("load_entities_failed"), color = "error" })
end
if #allEntities == 0 then if #allEntities == 0 then
return ui.label({ text = tr("no_entities_found"), color = "on_surface_variant" }) return ui.label({ text = tr("no_entities_found"), color = "on_surface_variant" })
end end
@@ -573,7 +671,18 @@ function onClose()
end end
function update() 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() render()
end end
end end
+1 -1
View File
@@ -1,6 +1,6 @@
id = "pozzoo/hassio" id = "pozzoo/hassio"
name = "Home Assistant" name = "Home Assistant"
version = "2.0.4" version = "2.0.5"
plugin_api = 9 plugin_api = 9
author = "Pozzoo" author = "Pozzoo"
license = "MIT" license = "MIT"
+257 -154
View File
@@ -10,6 +10,15 @@ local function getCleanUrl()
return (haUrlRaw:gsub("/$", "")) return (haUrlRaw:gsub("/$", ""))
end 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 function loadManagedEntities()
local dataDir, dirErr = noctalia.pluginDataDir() local dataDir, dirErr = noctalia.pluginDataDir()
if not dataDir then if not dataDir then
@@ -25,7 +34,18 @@ local function loadManagedEntities()
return {} return {}
end 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 status = "unconfigured"
local entityStates = {} local entityStates = {}
@@ -33,19 +53,31 @@ local sseActive = false
local streamHandle = nil local streamHandle = nil
local MAX_RECONNECT_RETRIES = 10 local MAX_RECONNECT_RETRIES = 10
local CONNECTED_POLL_INTERVAL_MS = 30000 local CONNECTED_POLL_INTERVAL_MS = 1000
local RECONNECT_POLL_INTERVAL_MS = 1000 local RECONNECT_POLL_INTERVAL_MS = 1000
local BACKOFF_POLL_INTERVAL_MS = 60000 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 retryCount = 0
local maxRetriesNotified = false local maxRetriesNotified = false
local authFailureNotified = false
local backoff = false local backoff = false
local connectionRequestInFlight = false local connectionRequestInFlight = false
local connectingSince = nil local requestStartedAt = nil
local function getCleanToken() local entitiesDirty = false
if type(haToken) ~= "string" then return "" end local lastEntityFlush = 0
return noctalia.string.trim(haToken)
end local snapshotToken = 0
local snapshotActive = false
local snapshotStartedAt = 0
local snapshotQueue = {}
local snapshotPending = {}
local snapshotInFlight = 0
local function syncUpdateInterval() local function syncUpdateInterval()
if status == "connected" and sseActive then if status == "connected" and sseActive then
@@ -57,9 +89,20 @@ local function syncUpdateInterval()
end end
end end
local function isConfigured() local function setStatus(next, reason)
local url = getCleanUrl() if status == next then return end
return url and url ~= "" and getCleanToken() ~= "" 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 end
local function haRequest(endpoint, method, body, callback) local function haRequest(endpoint, method, body, callback)
@@ -76,12 +119,11 @@ local function haRequest(endpoint, method, body, callback)
local headers = { local headers = {
"Authorization: Bearer " .. cleanToken, "Authorization: Bearer " .. cleanToken,
} }
-- Only advertise a JSON body when we actually send one.
if jsonBody then if jsonBody then
table.insert(headers, "Content-Type: application/json") table.insert(headers, "Content-Type: application/json")
end end
local launched = noctalia.http({ return noctalia.http({
url = url, url = url,
method = method, method = method,
body = jsonBody, body = jsonBody,
@@ -98,9 +140,6 @@ local function haRequest(endpoint, method, body, callback)
body = response.body body = response.body
}) })
end) end)
return launched
end end
local function supportsColorMode(modes, targets) local function supportsColorMode(modes, targets)
@@ -148,43 +187,60 @@ local function processEntity(state)
} }
end end
local function updateEntity(entityId, newState) local function publishEntities()
if #entities > 0 then local count = 0
local found = false for _ in pairs(entityStates) do
for _, id in ipairs(entities) do count = count + 1
if id == entityId then
found = true
break
end
end
if not found then return end
end end
noctalia.state.set("entities", entityStates)
noctalia.state.set("entity_count", count)
entitiesDirty = false
lastEntityFlush = os.clock()
end
local processed = processEntity(newState) -- Every publish is re-decoded by every watcher, so bursts get coalesced.
if processed then local function markEntitiesDirty()
entityStates[entityId] = processed entitiesDirty = true
noctalia.state.set("entities", entityStates) if (os.clock() - lastEntityFlush) >= ENTITY_FLUSH_INTERVAL_SECONDS then
publishEntities()
local count = 0
for _ in pairs(entityStates) do
count = count + 1
end
noctalia.state.set("entity_count", count)
end end
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 = { local currentEvent = {
data = "", data = "",
event = "", event = "",
} }
local function eventTypeOf(parsed)
if currentEvent.event and currentEvent.event ~= "" then
return currentEvent.event
end
return parsed.event_type
end
local function tryDispatch() local function tryDispatch()
if not currentEvent.data then return end if not currentEvent.data then return end
local parsed = noctalia.json.decode(currentEvent.data) local parsed = noctalia.json.decode(currentEvent.data)
if not parsed then return end if not parsed then return end
local evType = currentEvent.event or parsed.event_type if eventTypeOf(parsed) ~= "state_changed" then return end
if evType ~= "state_changed" then return end
local inner = parsed.data or {} local inner = parsed.data or {}
if inner.entity_id and inner.new_state then if inner.entity_id and inner.new_state then
updateEntity(inner.entity_id, inner.new_state) updateEntity(inner.entity_id, inner.new_state)
@@ -202,8 +258,7 @@ local function processSSELine(line)
local data = line:match("^data:%s*(.+)") or "" local data = line:match("^data:%s*(.+)") or ""
local parsed = noctalia.json.decode(data) local parsed = noctalia.json.decode(data)
if parsed then if parsed then
local evType = currentEvent.event or parsed.event_type if eventTypeOf(parsed) == "state_changed" then
if evType == "state_changed" then
local inner = parsed.data or {} local inner = parsed.data or {}
if inner.entity_id and inner.new_state then if inner.entity_id and inner.new_state then
updateEntity(inner.entity_id, inner.new_state) updateEntity(inner.entity_id, inner.new_state)
@@ -220,98 +275,127 @@ local function processSSELine(line)
end end
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 if not isConfigured() or connectionRequestInFlight then return end
requestStartedAt = os.clock()
connectionRequestInFlight = true connectionRequestInFlight = true
connectingSince = os.clock()
local launched = haRequest("/api/states", "GET", nil, function(res) local launched = haRequest("/api/", "GET", nil, function(res)
connectionRequestInFlight = false 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 if res.status == 0 then
status = "unreachable"
noctalia.log("Cannot reach Home Assistant (connection failed)") noctalia.log("Cannot reach Home Assistant (connection failed)")
noctalia.state.set("connection_status", status) setStatus("unreachable", "transport failure")
syncUpdateInterval()
return return
end end
if res.status == 401 or (res.body and (res.body:match("^401") or res.body:match("Unauthorized"))) then if res.status == 401 or res.status == 403 then
status = "auth_failed" setStatus("auth_failed", "http " .. tostring(res.status))
noctalia.log("Authentication failed - check your token")
noctalia.notifyError(noctalia.tr("notifications.auth_failed"))
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return return
end end
if not res.body or res.body == "" then if not res.ok then
noctalia.log("Empty response body from /api/states") noctalia.log("Home Assistant API probe failed: status " .. tostring(res.status))
status = "disconnected" setStatus("disconnected", "http " .. tostring(res.status))
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return return
end 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 retryCount = 0
maxRetriesNotified = false maxRetriesNotified = false
backoff = false backoff = false
setStatus("connected", "api reachable")
entityStates = {} if not startSSEStream() then
local count = 0 setStatus("disconnected", "stream failed to start")
return
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
end end
noctalia.state.set("entities", entityStates) fetchSnapshot()
noctalia.state.set("entity_count", count) syncUpdateInterval()
if status == "connected" then
if not startSSEStream() then
status = "disconnected"
noctalia.state.set("connection_status", status)
syncUpdateInterval()
else
syncUpdateInterval()
end
end
end) end)
if not launched then if not launched then
connectionRequestInFlight = false connectionRequestInFlight = false
connectingSince = nil requestStartedAt = nil
status = "unreachable" setStatus("unreachable", "request not launched")
noctalia.state.set("connection_status", status)
syncUpdateInterval()
end end
end end
@@ -344,18 +428,14 @@ function startSSEStream()
sseActive = false sseActive = false
currentEvent = {} currentEvent = {}
if result and result.ok and result.status == 401 then if result and (result.status == 401 or result.status == 403) then
status = "auth_failed" setStatus("auth_failed", "stream http " .. tostring(result.status))
noctalia.log("Authentication failed - check your token")
noctalia.notifyError(noctalia.tr("notifications.auth_failed"))
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return return
end end
status = "disconnected" -- 404 here means this HA serves no /api/stream, which otherwise looks like a flaky link.
noctalia.log("SSE stream closed") noctalia.log("SSE stream closed (status " .. tostring(result and result.status or 0) .. ")")
noctalia.state.set("connection_status", status) setStatus("disconnected", "stream closed")
syncUpdateInterval() syncUpdateInterval()
end) end)
@@ -372,24 +452,26 @@ end
noctalia.state.watch("command", function(cmd) noctalia.state.watch("command", function(cmd)
if cmd == "refresh" then if cmd == "refresh" then
-- Just refetch a fresh snapshot over HTTP. Do NOT touch sseActive here: -- Do NOT clear sseActive: that bypasses startSSEStream()'s duplicate guard.
-- the running stream (if any) is untouched by this, and forcing the if status == "connected" then
-- flag false would bypass startSSEStream()'s duplicate guard and spawn if not sseActive then startSSEStream() end
-- a second stream on top of one that's still alive. fetchSnapshot()
fetchInitialStates() else
connect()
end
elseif cmd == "reload_entities" then elseif cmd == "reload_entities" then
local override = noctalia.state.get("entities_override") local override = noctalia.state.get("entities_override")
if type(override) == "table" then if type(override) == "table" then
entities = override setEntities(override)
else else
entities = loadManagedEntities() setEntities(loadManagedEntities())
end
-- Filtering is client-side, so only the snapshot needs refetching.
if status == "connected" then
fetchSnapshot()
else
connect()
end 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
end) end)
@@ -398,48 +480,67 @@ noctalia.state.watch("panel_open_signal", function(value)
retryCount = 0 retryCount = 0
maxRetriesNotified = false maxRetriesNotified = false
backoff = false backoff = false
syncUpdateInterval()
end end
end) end)
function update() function update()
syncUpdateInterval()
if not isConfigured() then if not isConfigured() then
status = "unconfigured" setStatus("unconfigured", "no url or token")
noctalia.state.set("connection_status", status) if streamHandle then
streamHandle.stop()
streamHandle = nil
end
sseActive = false sseActive = false
syncUpdateInterval()
return return
end end
-- noctalia.http exposes no transport timeout, so a hung request would otherwise if entitiesDirty and (os.clock() - lastEntityFlush) >= ENTITY_FLUSH_INTERVAL_SECONDS then
-- pin status on "connecting" indefinitely. Treat 15s without a callback as a publishEntities()
-- 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()
end 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 retryCount = retryCount + 1
if retryCount >= MAX_RECONNECT_RETRIES and not backoff then 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 backoff = true
if not maxRetriesNotified then if not maxRetriesNotified then
maxRetriesNotified = true 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 end
syncUpdateInterval()
end end
status = "connecting" setStatus("connecting", "attempt " .. tostring(retryCount))
noctalia.state.set("connection_status", status) connect()
fetchInitialStates()
end end
syncUpdateInterval()
end end
function onExit() function onExit()
@@ -448,6 +549,7 @@ function onExit()
streamHandle = nil streamHandle = nil
end end
sseActive = false sseActive = false
snapshotActive = false
end end
noctalia.state.set("connection_status", status) noctalia.state.set("connection_status", status)
@@ -455,7 +557,8 @@ noctalia.state.set("entity_count", 0)
noctalia.state.set("entities", {}) noctalia.state.set("entities", {})
if isConfigured() then if isConfigured() then
status = "connecting" setStatus("connecting", "startup")
noctalia.state.set("connection_status", status) connect()
fetchInitialStates()
end end
syncUpdateInterval()
+7 -1
View File
@@ -140,7 +140,13 @@ function onClick()
"Content-Type: application/json", "Content-Type: application/json",
}, },
body = encoded, 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() render()
end end
+7 -1
View File
@@ -140,7 +140,13 @@ function onClick()
"Content-Type: application/json", "Content-Type: application/json",
}, },
body = encoded, 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() render()
end end
+7 -1
View File
@@ -140,7 +140,13 @@ function onClick()
"Content-Type: application/json", "Content-Type: application/json",
}, },
body = encoded, 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() render()
end end
+7 -1
View File
@@ -140,7 +140,13 @@ function onClick()
"Content-Type: application/json", "Content-Type: application/json",
}, },
body = encoded, 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() render()
end end
+1
View File
@@ -10,6 +10,7 @@
"connecting": "Connecting…", "connecting": "Connecting…",
"disconnected_reconnecting": "Disconnected. Reconnecting…", "disconnected_reconnecting": "Disconnected. Reconnecting…",
"hue": "Hue", "hue": "Hue",
"load_entities_failed": "Could not load the entity list. Try again.",
"loading_entities": "Loading entities…", "loading_entities": "Loading entities…",
"manage_entities_title": "Manage Entities", "manage_entities_title": "Manage Entities",
"no_entities_found": "No entities found", "no_entities_found": "No entities found",
+1
View File
@@ -10,6 +10,7 @@
"connecting": "Conectando…", "connecting": "Conectando…",
"disconnected_reconnecting": "Desconectado. Reconectando…", "disconnected_reconnecting": "Desconectado. Reconectando…",
"hue": "Matiz", "hue": "Matiz",
"load_entities_failed": "Não foi possível carregar a lista de entidades. Tente novamente.",
"loading_entities": "Carregando entidades…", "loading_entities": "Carregando entidades…",
"manage_entities_title": "Gerenciar Entidades", "manage_entities_title": "Gerenciar Entidades",
"no_entities_found": "Nenhuma entidade encontrada", "no_entities_found": "Nenhuma entidade encontrada",