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 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