fix(hassio): recover from CPU budget kills that froze the panel on (#205)
"Connecting"
This commit is contained in:
+143
-34
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user