fix(hassio): keep the entity browser inside the host's CPU budget. (#295)
This commit is contained in:
+165
-47
@@ -21,6 +21,7 @@ local browserLoadingSince = nil
|
|||||||
local browserFailed = false
|
local browserFailed = false
|
||||||
|
|
||||||
local BROWSER_TIMEOUT_SECONDS = 20
|
local BROWSER_TIMEOUT_SECONDS = 20
|
||||||
|
local MAX_BROWSER_ROWS = 200
|
||||||
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
|
||||||
@@ -152,6 +153,52 @@ end
|
|||||||
local ENTITY_LIST_TEMPLATE =
|
local ENTITY_LIST_TEMPLATE =
|
||||||
"{% for s in states %}{{ s.entity_id }}\t{{ s.name | replace('\\t',' ') | replace('\\n',' ') }}\n{% endfor %}"
|
"{% 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.
|
-- HA iterates `states` in entity_id order, so this is normally just the linear check.
|
||||||
local function sortEntitiesIfNeeded(list)
|
local function sortEntitiesIfNeeded(list)
|
||||||
for i = 2, #list do
|
for i = 2, #list do
|
||||||
@@ -170,6 +217,56 @@ local function finishBrowserLoad(list, failed)
|
|||||||
render()
|
render()
|
||||||
end
|
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.
|
-- Continues an already-started load, so it deliberately doesn't re-check browserLoading.
|
||||||
local function fetchAllEntitiesFromStates()
|
local function fetchAllEntitiesFromStates()
|
||||||
local cleanToken = getCleanToken()
|
local cleanToken = getCleanToken()
|
||||||
@@ -190,21 +287,50 @@ local function fetchAllEntitiesFromStates()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local states = noctalia.json.decode(body)
|
local states = noctalia.json.decode(body)
|
||||||
if type(states) ~= "table" then
|
-- Nothing but plain assignments may sit between the decode and here. The decode
|
||||||
noctalia.log("Could not list entities: /api/states was not decodable")
|
-- alone can outlast the budget, and the host only aborts at the next call or
|
||||||
finishBrowserLoad({}, true)
|
-- 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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local list = {}
|
local list = parseEntityList(body)
|
||||||
for _, s in ipairs(states) do
|
|
||||||
local attrs = s.attributes or {}
|
if #list == 0 then
|
||||||
table.insert(list, {
|
-- The preview is what tells us how a given instance actually answers.
|
||||||
entity_id = s.entity_id,
|
local preview = string.gsub(string.sub(body, 1, 120), "%c", ".")
|
||||||
friendly_name = attrs.friendly_name or s.entity_id,
|
noctalia.log(label .. " template returned nothing usable (" .. #body
|
||||||
domain = s.entity_id:match("^([^.]+)"),
|
.. " bytes, starts with: " .. preview .. ")")
|
||||||
})
|
onUnusable()
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
sortEntitiesIfNeeded(list)
|
sortEntitiesIfNeeded(list)
|
||||||
finishBrowserLoad(list)
|
finishBrowserLoad(list)
|
||||||
end)
|
end)
|
||||||
@@ -212,6 +338,8 @@ end
|
|||||||
|
|
||||||
local function fetchAllEntities()
|
local function fetchAllEntities()
|
||||||
if browserLoading then return end
|
if browserLoading then return end
|
||||||
|
pendingStates = nil
|
||||||
|
pendingList = nil
|
||||||
local cleanToken = getCleanToken()
|
local cleanToken = getCleanToken()
|
||||||
local url = getCleanUrl()
|
local url = getCleanUrl()
|
||||||
if not url or url == "" or cleanToken == "" then
|
if not url or url == "" or cleanToken == "" then
|
||||||
@@ -227,42 +355,11 @@ local function fetchAllEntities()
|
|||||||
browserFailed = false
|
browserFailed = false
|
||||||
render()
|
render()
|
||||||
|
|
||||||
noctalia.http({
|
-- Cheapest source first. The id-only template needs no filters and no `name`
|
||||||
url = url .. "/api/template",
|
-- attribute, so it still answers on instances where the full one doesn't, and
|
||||||
method = "POST",
|
-- /api/states is only reached when neither template works.
|
||||||
headers = {
|
fetchAllEntitiesFromTemplate(ENTITY_LIST_TEMPLATE, "Entity list", function()
|
||||||
"Authorization: Bearer " .. cleanToken,
|
fetchAllEntitiesFromTemplate(ENTITY_ID_TEMPLATE, "Entity id", fetchAllEntitiesFromStates)
|
||||||
"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
|
||||||
|
|
||||||
@@ -599,11 +696,18 @@ local function buildBrowserBody()
|
|||||||
|
|
||||||
local q = searchText:lower()
|
local q = searchText:lower()
|
||||||
local filtered = {}
|
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
|
for _, e in ipairs(allEntities) do
|
||||||
if q == ""
|
if q == ""
|
||||||
or e.entity_id:lower():find(q, 1, true)
|
or e.entity_id:lower():find(q, 1, true)
|
||||||
or e.friendly_name:lower():find(q, 1, true)
|
or e.friendly_name:lower():find(q, 1, true)
|
||||||
then
|
then
|
||||||
|
if #filtered >= MAX_BROWSER_ROWS then
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
end
|
||||||
table.insert(filtered, e)
|
table.insert(filtered, e)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -625,6 +729,14 @@ local function buildBrowserBody()
|
|||||||
}))
|
}))
|
||||||
end
|
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)
|
return ui.scroll({ flexGrow = 1, gap = 4 }, rows)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -673,6 +785,12 @@ end
|
|||||||
function update()
|
function update()
|
||||||
local now = os.clock()
|
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
|
if browserLoading and browserLoadingSince and (now - browserLoadingSince) > BROWSER_TIMEOUT_SECONDS then
|
||||||
browserLoading = false
|
browserLoading = false
|
||||||
browserLoadingSince = nil
|
browserLoadingSince = nil
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
id = "pozzoo/hassio"
|
id = "pozzoo/hassio"
|
||||||
name = "Home Assistant"
|
name = "Home Assistant"
|
||||||
version = "2.0.5"
|
version = "2.0.6"
|
||||||
plugin_api = 9
|
plugin_api = 9
|
||||||
author = "Pozzoo"
|
author = "Pozzoo"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"panel": {
|
"panel": {
|
||||||
"auth_failed": "Authentication failed. Check your access token.",
|
"auth_failed": "Authentication failed. Check your access token.",
|
||||||
"brightness": "Brightness",
|
"brightness": "Brightness",
|
||||||
|
"browser_truncated": "Showing the first {count} matches. Refine your search to narrow it down.",
|
||||||
"color_temp": "Color Temperature",
|
"color_temp": "Color Temperature",
|
||||||
"connecting": "Connecting…",
|
"connecting": "Connecting…",
|
||||||
"disconnected_reconnecting": "Disconnected. Reconnecting…",
|
"disconnected_reconnecting": "Disconnected. Reconnecting…",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"panel": {
|
"panel": {
|
||||||
"auth_failed": "Falha na autenticação. Verifique seu token de acesso.",
|
"auth_failed": "Falha na autenticação. Verifique seu token de acesso.",
|
||||||
"brightness": "Brilho",
|
"brightness": "Brilho",
|
||||||
|
"browser_truncated": "Mostrando as primeiras {count} correspondências. Refine sua busca para reduzir a lista.",
|
||||||
"color_temp": "Temperatura de Cor",
|
"color_temp": "Temperatura de Cor",
|
||||||
"connecting": "Conectando…",
|
"connecting": "Conectando…",
|
||||||
"disconnected_reconnecting": "Desconectado. Reconectando…",
|
"disconnected_reconnecting": "Desconectado. Reconectando…",
|
||||||
|
|||||||
Reference in New Issue
Block a user