565 lines
15 KiB
Luau
565 lines
15 KiB
Luau
--!nonstrict
|
|
|
|
local haUrlRaw = noctalia.getConfig("ha_url")
|
|
local haToken = noctalia.getConfig("ha_token")
|
|
|
|
local MANAGED_ENTITIES_FILE = "managed_entities.json"
|
|
|
|
local function getCleanUrl()
|
|
if type(haUrlRaw) ~= "string" then return "" end
|
|
return (haUrlRaw:gsub("/$", ""))
|
|
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 dataDir, dirErr = noctalia.pluginDataDir()
|
|
if not dataDir then
|
|
noctalia.log("Cannot resolve plugin data dir: " .. (dirErr or "unknown"))
|
|
return {}
|
|
end
|
|
|
|
local data = noctalia.readFile(dataDir .. "/" .. MANAGED_ENTITIES_FILE)
|
|
if data and data ~= "" then
|
|
local parsed = noctalia.json.decode(data)
|
|
if type(parsed) == "table" then return parsed end
|
|
end
|
|
return {}
|
|
end
|
|
|
|
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 entityStates = {}
|
|
local sseActive = false
|
|
local streamHandle = nil
|
|
|
|
local MAX_RECONNECT_RETRIES = 10
|
|
local CONNECTED_POLL_INTERVAL_MS = 1000
|
|
local RECONNECT_POLL_INTERVAL_MS = 1000
|
|
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 maxRetriesNotified = false
|
|
local authFailureNotified = false
|
|
local backoff = false
|
|
local connectionRequestInFlight = false
|
|
local requestStartedAt = nil
|
|
|
|
local entitiesDirty = false
|
|
local lastEntityFlush = 0
|
|
|
|
local snapshotToken = 0
|
|
local snapshotActive = false
|
|
local snapshotStartedAt = 0
|
|
local snapshotQueue = {}
|
|
local snapshotPending = {}
|
|
local snapshotInFlight = 0
|
|
|
|
local function syncUpdateInterval()
|
|
if status == "connected" and sseActive then
|
|
noctalia.setUpdateInterval(CONNECTED_POLL_INTERVAL_MS)
|
|
elseif backoff then
|
|
noctalia.setUpdateInterval(BACKOFF_POLL_INTERVAL_MS)
|
|
else
|
|
noctalia.setUpdateInterval(RECONNECT_POLL_INTERVAL_MS)
|
|
end
|
|
end
|
|
|
|
local function setStatus(next, reason)
|
|
if status == next then return end
|
|
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
|
|
|
|
local function haRequest(endpoint, method, body, callback)
|
|
if not isConfigured() then return false end
|
|
|
|
local url = getCleanUrl() .. endpoint
|
|
local cleanToken = getCleanToken()
|
|
|
|
local jsonBody
|
|
if body then
|
|
jsonBody = noctalia.json.encode(body)
|
|
end
|
|
|
|
local headers = {
|
|
"Authorization: Bearer " .. cleanToken,
|
|
}
|
|
if jsonBody then
|
|
table.insert(headers, "Content-Type: application/json")
|
|
end
|
|
|
|
return noctalia.http({
|
|
url = url,
|
|
method = method,
|
|
body = jsonBody,
|
|
headers = headers,
|
|
}, function(response: HttpResponse)
|
|
if not response then
|
|
callback({ ok = false, status = 0, body = "No response" })
|
|
return
|
|
end
|
|
|
|
callback({
|
|
ok = response.status >= 200 and response.status < 300,
|
|
status = response.status,
|
|
body = response.body
|
|
})
|
|
end)
|
|
end
|
|
|
|
local function supportsColorMode(modes, targets)
|
|
if type(modes) ~= "table" then return false end
|
|
for _, mode in ipairs(modes) do
|
|
for _, target in ipairs(targets) do
|
|
if mode == target then return true end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function processEntity(state)
|
|
if not state then return nil end
|
|
|
|
local attrs = state.attributes or {}
|
|
local modes = attrs.supported_color_modes or {}
|
|
|
|
-- stored as mireds for the panel slider
|
|
local colorTemp = -1
|
|
if attrs.color_temp_kelvin and attrs.color_temp_kelvin > 0 then
|
|
colorTemp = math.floor(1000000 / attrs.color_temp_kelvin + 0.5)
|
|
elseif attrs.color_temp and attrs.color_temp > 0 then
|
|
colorTemp = attrs.color_temp
|
|
end
|
|
|
|
local currentColor = ""
|
|
if type(attrs.rgb_color) == "table" and #attrs.rgb_color == 3 then
|
|
currentColor = attrs.rgb_color[1] .. "," .. attrs.rgb_color[2] .. "," .. attrs.rgb_color[3]
|
|
end
|
|
|
|
return {
|
|
entity_id = state.entity_id,
|
|
state = state.state,
|
|
friendly_name = attrs.friendly_name or state.entity_id,
|
|
domain = state.entity_id:match("^([^.]+)"),
|
|
unit = attrs.unit_of_measurement or "",
|
|
brightness = attrs.brightness or -1,
|
|
color_temp = colorTemp,
|
|
hue = (type(attrs.hs_color) == "table") and (attrs.hs_color[1] or 0) or 0,
|
|
current_color = currentColor,
|
|
supports_brightness = supportsColorMode(modes, {"brightness","color_temp","hs","xy","rgb","rgbw","rgbww"}),
|
|
supports_color_temp = supportsColorMode(modes, {"color_temp"}),
|
|
supports_rgb = supportsColorMode(modes, {"hs","xy","rgb","rgbw","rgbww"}),
|
|
}
|
|
end
|
|
|
|
local function publishEntities()
|
|
local count = 0
|
|
for _ in pairs(entityStates) do
|
|
count = count + 1
|
|
end
|
|
noctalia.state.set("entities", entityStates)
|
|
noctalia.state.set("entity_count", count)
|
|
entitiesDirty = false
|
|
lastEntityFlush = os.clock()
|
|
end
|
|
|
|
-- Every publish is re-decoded by every watcher, so bursts get coalesced.
|
|
local function markEntitiesDirty()
|
|
entitiesDirty = true
|
|
if (os.clock() - lastEntityFlush) >= ENTITY_FLUSH_INTERVAL_SECONDS then
|
|
publishEntities()
|
|
end
|
|
end
|
|
|
|
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 = {
|
|
data = "",
|
|
event = "",
|
|
}
|
|
|
|
local function eventTypeOf(parsed)
|
|
if currentEvent.event and currentEvent.event ~= "" then
|
|
return currentEvent.event
|
|
end
|
|
return parsed.event_type
|
|
end
|
|
|
|
local function tryDispatch()
|
|
if not currentEvent.data then return end
|
|
local parsed = noctalia.json.decode(currentEvent.data)
|
|
if not parsed then return end
|
|
if eventTypeOf(parsed) ~= "state_changed" then return end
|
|
local inner = parsed.data or {}
|
|
if inner.entity_id and inner.new_state then
|
|
updateEntity(inner.entity_id, inner.new_state)
|
|
end
|
|
end
|
|
|
|
local function processSSELine(line)
|
|
if line == "" then
|
|
tryDispatch()
|
|
currentEvent = {}
|
|
elseif line:match("^event:") then
|
|
if currentEvent.data then tryDispatch() end
|
|
currentEvent = { event = line:match("^event:%s*(.+)") or "" }
|
|
elseif line:match("^data:") then
|
|
local data = line:match("^data:%s*(.+)") or ""
|
|
local parsed = noctalia.json.decode(data)
|
|
if parsed then
|
|
if eventTypeOf(parsed) == "state_changed" then
|
|
local inner = parsed.data or {}
|
|
if inner.entity_id and inner.new_state then
|
|
updateEntity(inner.entity_id, inner.new_state)
|
|
currentEvent = {}
|
|
return
|
|
end
|
|
end
|
|
end
|
|
if currentEvent.data and currentEvent.data ~= "" then
|
|
currentEvent.data = currentEvent.data .. "\n" .. data
|
|
else
|
|
currentEvent.data = data
|
|
end
|
|
end
|
|
end
|
|
|
|
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
|
|
|
|
requestStartedAt = os.clock()
|
|
connectionRequestInFlight = true
|
|
|
|
local launched = haRequest("/api/", "GET", nil, function(res)
|
|
connectionRequestInFlight = false
|
|
requestStartedAt = nil
|
|
|
|
if res.status == 0 then
|
|
noctalia.log("Cannot reach Home Assistant (connection failed)")
|
|
setStatus("unreachable", "transport failure")
|
|
return
|
|
end
|
|
|
|
if res.status == 401 or res.status == 403 then
|
|
setStatus("auth_failed", "http " .. tostring(res.status))
|
|
return
|
|
end
|
|
|
|
if not res.ok then
|
|
noctalia.log("Home Assistant API probe failed: status " .. tostring(res.status))
|
|
setStatus("disconnected", "http " .. tostring(res.status))
|
|
return
|
|
end
|
|
|
|
retryCount = 0
|
|
maxRetriesNotified = false
|
|
backoff = false
|
|
setStatus("connected", "api reachable")
|
|
|
|
if not startSSEStream() then
|
|
setStatus("disconnected", "stream failed to start")
|
|
return
|
|
end
|
|
|
|
fetchSnapshot()
|
|
syncUpdateInterval()
|
|
end)
|
|
|
|
if not launched then
|
|
connectionRequestInFlight = false
|
|
requestStartedAt = nil
|
|
setStatus("unreachable", "request not launched")
|
|
end
|
|
end
|
|
|
|
function startSSEStream()
|
|
if sseActive then return true end
|
|
|
|
local cleanToken = getCleanToken()
|
|
if cleanToken == "" then
|
|
return false
|
|
end
|
|
|
|
local url = getCleanUrl() .. "/api/stream?restrict=state_changed"
|
|
|
|
noctalia.log("Starting SSE stream")
|
|
currentEvent = { data = "", event = "" }
|
|
|
|
local handle
|
|
handle = noctalia.httpStream({
|
|
url = url,
|
|
headers = {
|
|
"Accept: text/event-stream",
|
|
"Authorization: Bearer " .. cleanToken,
|
|
},
|
|
}, function(line)
|
|
processSSELine(line)
|
|
end, function(result)
|
|
if streamHandle ~= handle then return end
|
|
|
|
streamHandle = nil
|
|
sseActive = false
|
|
currentEvent = {}
|
|
|
|
if result and (result.status == 401 or result.status == 403) then
|
|
setStatus("auth_failed", "stream http " .. tostring(result.status))
|
|
return
|
|
end
|
|
|
|
-- 404 here means this HA serves no /api/stream, which otherwise looks like a flaky link.
|
|
noctalia.log("SSE stream closed (status " .. tostring(result and result.status or 0) .. ")")
|
|
setStatus("disconnected", "stream closed")
|
|
syncUpdateInterval()
|
|
end)
|
|
|
|
if not handle then
|
|
noctalia.log("Failed to start SSE stream")
|
|
return false
|
|
end
|
|
|
|
streamHandle = handle
|
|
sseActive = true
|
|
syncUpdateInterval()
|
|
return true
|
|
end
|
|
|
|
noctalia.state.watch("command", function(cmd)
|
|
if cmd == "refresh" then
|
|
-- Do NOT clear sseActive: that bypasses startSSEStream()'s duplicate guard.
|
|
if status == "connected" then
|
|
if not sseActive then startSSEStream() end
|
|
fetchSnapshot()
|
|
else
|
|
connect()
|
|
end
|
|
elseif cmd == "reload_entities" then
|
|
local override = noctalia.state.get("entities_override")
|
|
if type(override) == "table" then
|
|
setEntities(override)
|
|
else
|
|
setEntities(loadManagedEntities())
|
|
end
|
|
-- Filtering is client-side, so only the snapshot needs refetching.
|
|
if status == "connected" then
|
|
fetchSnapshot()
|
|
else
|
|
connect()
|
|
end
|
|
end
|
|
end)
|
|
|
|
noctalia.state.watch("panel_open_signal", function(value)
|
|
if value then
|
|
retryCount = 0
|
|
maxRetriesNotified = false
|
|
backoff = false
|
|
syncUpdateInterval()
|
|
end
|
|
end)
|
|
|
|
function update()
|
|
if not isConfigured() then
|
|
setStatus("unconfigured", "no url or token")
|
|
if streamHandle then
|
|
streamHandle.stop()
|
|
streamHandle = nil
|
|
end
|
|
sseActive = false
|
|
syncUpdateInterval()
|
|
return
|
|
end
|
|
|
|
if entitiesDirty and (os.clock() - lastEntityFlush) >= ENTITY_FLUSH_INTERVAL_SECONDS then
|
|
publishEntities()
|
|
end
|
|
|
|
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
|
|
if retryCount >= MAX_RECONNECT_RETRIES and not backoff then
|
|
backoff = true
|
|
if not maxRetriesNotified then
|
|
maxRetriesNotified = true
|
|
if not authFailureNotified then
|
|
noctalia.notifyError(noctalia.tr("notifications.reconnect_failed", { count = MAX_RECONNECT_RETRIES }))
|
|
end
|
|
end
|
|
end
|
|
|
|
setStatus("connecting", "attempt " .. tostring(retryCount))
|
|
connect()
|
|
end
|
|
|
|
syncUpdateInterval()
|
|
end
|
|
|
|
function onExit()
|
|
if streamHandle then
|
|
streamHandle.stop()
|
|
streamHandle = nil
|
|
end
|
|
sseActive = false
|
|
snapshotActive = false
|
|
end
|
|
|
|
noctalia.state.set("connection_status", status)
|
|
noctalia.state.set("entity_count", 0)
|
|
noctalia.state.set("entities", {})
|
|
|
|
if isConfigured() then
|
|
setStatus("connecting", "startup")
|
|
connect()
|
|
end
|
|
|
|
syncUpdateInterval()
|