fix(hassio): keep the entity browser inside the host's CPU budget. (#295)

This commit is contained in:
Artur
2026-08-07 22:35:16 -04:00
committed by GitHub
parent 79f0b02087
commit 79a69d0511
4 changed files with 168 additions and 48 deletions
+165 -47
View File
@@ -21,6 +21,7 @@ local browserLoadingSince = nil
local browserFailed = false
local BROWSER_TIMEOUT_SECONDS = 20
local MAX_BROWSER_ROWS = 200
local searchText = ""
local monitoredEntities = {} -- current monitored list (owned here, saved to file)
local panelOpenCount = 0
@@ -152,6 +153,52 @@ end
local ENTITY_LIST_TEMPLATE =
"{% for s in states %}{{ s.entity_id }}\t{{ s.name | replace('\\t',' ') | replace('\\n',' ') }}\n{% endfor %}"
-- Same shape minus the friendly names, for instances that reject the template above.
local ENTITY_ID_TEMPLATE =
"{% for s in states %}{{ s.entity_id }}\n{% endfor %}"
local MAX_ENTITY_ID_LENGTH = 255
local MAX_FRIENDLY_NAME_LENGTH = 255
-- Comfortably past id + name, so a record is never cut short by it.
local MAX_RECORD_SCAN = 1024
-- Scans with plain string.find only. A Lua pattern such as "([^\t\n]+)\t" backtracks
-- through every separator-free byte and gmatch then restarts one byte later, so a
-- response that lost its tabs costs O(bytes^2) inside a single uninterruptible C call:
-- seconds of CPU, which the host kills for overrunning the callback budget.
local function parseEntityList(body)
local list = {}
local len = #body
-- Without any newline the whole body is one record, so a space is not a separator.
local splitOnSpace = string.find(body, "\n", 1, true) ~= nil
local pos = 1
while pos <= len do
local eol = string.find(body, "\n", pos, true) or (len + 1)
local lineEnd = eol - 1
if string.sub(body, lineEnd, lineEnd) == "\r" then lineEnd -= 1 end
-- Searching the body itself would re-scan to the far end on every separator-less
-- line, which is the quadratic behaviour this parser exists to avoid.
local record = string.sub(body, pos, math.min(lineEnd, pos + MAX_RECORD_SCAN - 1))
local sep = string.find(record, "\t", 1, true)
if not sep and splitOnSpace then sep = string.find(record, " ", 1, true) end
local entityId = sep and string.sub(record, 1, sep - 1) or record
local dot = string.find(entityId, ".", 1, true)
if dot and dot > 1 and dot < #entityId and #entityId <= MAX_ENTITY_ID_LENGTH then
local name = sep and string.sub(record, sep + 1) or ""
if #name > MAX_FRIENDLY_NAME_LENGTH then name = string.sub(name, 1, MAX_FRIENDLY_NAME_LENGTH) end
table.insert(list, {
entity_id = entityId,
friendly_name = name ~= "" and name or entityId,
domain = string.sub(entityId, 1, dot - 1),
})
end
pos = eol + 1
end
return list
end
-- HA iterates `states` in entity_id order, so this is normally just the linear check.
local function sortEntitiesIfNeeded(list)
for i = 2, #list do
@@ -170,6 +217,56 @@ local function finishBrowserLoad(list, failed)
render()
end
-- /api/states on a large instance decodes for longer than one callback is allowed to
-- run, so the conversion is resumable: whatever is left over is finished by update(),
-- which gets a fresh budget on every tick.
local pendingStates = nil
local pendingList = nil
local pendingCursor = 1
local STATES_PER_TICK = 1000
local function consumePendingStates()
local states = pendingStates
if states == nil then return end
if type(states) ~= "table" then
pendingStates = nil
pendingList = nil
noctalia.log("Could not list entities: /api/states was not decodable")
finishBrowserLoad({}, true)
return
end
local list = pendingList
local total = #states
local i = pendingCursor
local last = math.min(i + STATES_PER_TICK - 1, total)
while i <= last do
local s = states[i]
local entityId = type(s) == "table" and s.entity_id or nil
if type(entityId) == "string" then
local attrs = s.attributes
local dot = string.find(entityId, ".", 1, true)
table.insert(list, {
entity_id = entityId,
friendly_name = (type(attrs) == "table" and attrs.friendly_name) or entityId,
domain = dot and string.sub(entityId, 1, dot - 1) or entityId,
})
end
i += 1
-- Kept in step with `list` so an aborted chunk resumes without duplicating.
pendingCursor = i
end
if i > total then
-- Cleared only once the sort is through, so an abort here retries next tick.
sortEntitiesIfNeeded(list)
pendingStates = nil
pendingList = nil
finishBrowserLoad(list)
end
end
-- Continues an already-started load, so it deliberately doesn't re-check browserLoading.
local function fetchAllEntitiesFromStates()
local cleanToken = getCleanToken()
@@ -190,21 +287,50 @@ local function fetchAllEntitiesFromStates()
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)
-- Nothing but plain assignments may sit between the decode and here. The decode
-- alone can outlast the budget, and the host only aborts at the next call or
-- loop instruction, so these stores still commit and update() can take over.
pendingStates = states
pendingList = {}
pendingCursor = 1
consumePendingStates()
end)
end
-- Continues an already-started load, so it deliberately doesn't re-check browserLoading.
local function fetchAllEntitiesFromTemplate(template, label, onUnusable)
local cleanToken = getCleanToken()
local url = getCleanUrl()
noctalia.http({
url = url .. "/api/template",
method = "POST",
headers = {
"Authorization: Bearer " .. cleanToken,
"Content-Type: application/json",
},
body = noctalia.json.encode({ template = 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(label .. " template unavailable (status " .. tostring(httpStatus) .. ")")
onUnusable()
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("^([^.]+)"),
})
local list = parseEntityList(body)
if #list == 0 then
-- The preview is what tells us how a given instance actually answers.
local preview = string.gsub(string.sub(body, 1, 120), "%c", ".")
noctalia.log(label .. " template returned nothing usable (" .. #body
.. " bytes, starts with: " .. preview .. ")")
onUnusable()
return
end
sortEntitiesIfNeeded(list)
finishBrowserLoad(list)
end)
@@ -212,6 +338,8 @@ end
local function fetchAllEntities()
if browserLoading then return end
pendingStates = nil
pendingList = nil
local cleanToken = getCleanToken()
local url = getCleanUrl()
if not url or url == "" or cleanToken == "" then
@@ -227,42 +355,11 @@ local function fetchAllEntities()
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)
-- Cheapest source first. The id-only template needs no filters and no `name`
-- attribute, so it still answers on instances where the full one doesn't, and
-- /api/states is only reached when neither template works.
fetchAllEntitiesFromTemplate(ENTITY_LIST_TEMPLATE, "Entity list", function()
fetchAllEntitiesFromTemplate(ENTITY_ID_TEMPLATE, "Entity id", fetchAllEntitiesFromStates)
end)
end
@@ -599,11 +696,18 @@ local function buildBrowserBody()
local q = searchText:lower()
local filtered = {}
-- A row is six UI nodes and a closure, so rendering every match on a large instance
-- overruns the callback budget on each keystroke. Nobody scrolls past a few hundred.
local truncated = false
for _, e in ipairs(allEntities) do
if q == ""
or e.entity_id:lower():find(q, 1, true)
or e.friendly_name:lower():find(q, 1, true)
then
if #filtered >= MAX_BROWSER_ROWS then
truncated = true
break
end
table.insert(filtered, e)
end
end
@@ -625,6 +729,14 @@ local function buildBrowserBody()
}))
end
if truncated then
table.insert(rows, ui.label({
text = tr("browser_truncated", { count = MAX_BROWSER_ROWS }),
color = "on_surface_variant",
fontSize = 12,
}))
end
return ui.scroll({ flexGrow = 1, gap = 4 }, rows)
end
@@ -673,6 +785,12 @@ end
function update()
local now = os.clock()
-- A load that is still converting is making progress, so it outranks the timeout.
if pendingStates ~= nil then
consumePendingStates()
return
end
if browserLoading and browserLoadingSince and (now - browserLoadingSince) > BROWSER_TIMEOUT_SECONDS then
browserLoading = false
browserLoadingSince = nil
+1 -1
View File
@@ -1,6 +1,6 @@
id = "pozzoo/hassio"
name = "Home Assistant"
version = "2.0.5"
version = "2.0.6"
plugin_api = 9
author = "Pozzoo"
license = "MIT"
+1
View File
@@ -6,6 +6,7 @@
"panel": {
"auth_failed": "Authentication failed. Check your access token.",
"brightness": "Brightness",
"browser_truncated": "Showing the first {count} matches. Refine your search to narrow it down.",
"color_temp": "Color Temperature",
"connecting": "Connecting…",
"disconnected_reconnecting": "Disconnected. Reconnecting…",
+1
View File
@@ -6,6 +6,7 @@
"panel": {
"auth_failed": "Falha na autenticação. Verifique seu token de acesso.",
"brightness": "Brilho",
"browser_truncated": "Mostrando as primeiras {count} correspondências. Refine sua busca para reduzir a lista.",
"color_temp": "Temperatura de Cor",
"connecting": "Conectando…",
"disconnected_reconnecting": "Desconectado. Reconectando…",