fix(hassio): Resolve perpetual "connecting" and add unreachable state (#78)

* fix(service_sse): Fixed perpetual connecting from hung states request.

* fix(service_sse): Distinguish unreachable server from disconnected/auth
states.

* chore(plugin): Bumped version to 2.0.3
This commit is contained in:
Artur
2026-07-21 23:31:05 -04:00
committed by GitHub
parent 5012a7e2f4
commit ef512945ce
6 changed files with 61 additions and 35 deletions
+5
View File
@@ -39,6 +39,8 @@ local function getStatusText()
return noctalia.tr("widget.status_connecting") return noctalia.tr("widget.status_connecting")
elseif status == "disconnected" then elseif status == "disconnected" then
return noctalia.tr("widget.status_disconnected") return noctalia.tr("widget.status_disconnected")
elseif status == "unreachable" then
return noctalia.tr("widget.status_unreachable")
elseif status == "auth_failed" then elseif status == "auth_failed" then
return noctalia.tr("widget.status_auth_failed") return noctalia.tr("widget.status_auth_failed")
else else
@@ -519,6 +521,9 @@ local function buildListBody(ids)
if status == "disconnected" then if status == "disconnected" then
return ui.label({ text = tr("disconnected_reconnecting"), color = "error" }) return ui.label({ text = tr("disconnected_reconnecting"), color = "error" })
end end
if status == "unreachable" then
return ui.label({ text = tr("unreachable"), color = "error" })
end
if #ids == 0 then if #ids == 0 then
return ui.label({ return ui.label({
text = status == "connected" text = status == "connected"
+1 -1
View File
@@ -1,6 +1,6 @@
id = "pozzoo/hassio" id = "pozzoo/hassio"
name = "Home Assistant" name = "Home Assistant"
version = "2.0.2" version = "2.0.3"
plugin_api = 4 plugin_api = 4
author = "Pozzoo" author = "Pozzoo"
license = "MIT" license = "MIT"
+47 -32
View File
@@ -35,9 +35,10 @@ local streamHandle = nil
local MAX_RECONNECT_RETRIES = 10 local MAX_RECONNECT_RETRIES = 10
local CONNECTED_POLL_INTERVAL_MS = 30000 local CONNECTED_POLL_INTERVAL_MS = 30000
local RECONNECT_POLL_INTERVAL_MS = 1000 local RECONNECT_POLL_INTERVAL_MS = 1000
local BACKOFF_POLL_INTERVAL_MS = 60000
local retryCount = 0 local retryCount = 0
local maxRetriesNotified = false local maxRetriesNotified = false
local suspended = false local backoff = false
local connectionRequestInFlight = false local connectionRequestInFlight = false
local connectingSince = nil local connectingSince = nil
@@ -49,6 +50,8 @@ end
local function syncUpdateInterval() local function syncUpdateInterval()
if status == "connected" and sseActive then if status == "connected" and sseActive then
noctalia.setUpdateInterval(CONNECTED_POLL_INTERVAL_MS) noctalia.setUpdateInterval(CONNECTED_POLL_INTERVAL_MS)
elseif backoff then
noctalia.setUpdateInterval(BACKOFF_POLL_INTERVAL_MS)
else else
noctalia.setUpdateInterval(RECONNECT_POLL_INTERVAL_MS) noctalia.setUpdateInterval(RECONNECT_POLL_INTERVAL_MS)
end end
@@ -70,14 +73,19 @@ local function haRequest(endpoint, method, body, callback)
jsonBody = noctalia.json.encode(body) jsonBody = noctalia.json.encode(body)
end end
local headers = {
"Authorization: Bearer " .. cleanToken,
}
-- Only advertise a JSON body when we actually send one.
if jsonBody then
table.insert(headers, "Content-Type: application/json")
end
local launched = noctalia.http({ local launched = noctalia.http({
url = url, url = url,
method = method, method = method,
body = jsonBody, body = jsonBody,
headers = { headers = headers,
"Authorization: Bearer " .. cleanToken,
"Content-Type: application/json",
}
}, function(response: HttpResponse) }, function(response: HttpResponse)
if not response then if not response then
callback({ ok = false, status = 0, body = "No response" }) callback({ ok = false, status = 0, body = "No response" })
@@ -213,13 +221,32 @@ local function processSSELine(line)
end end
local function fetchInitialStates() local function fetchInitialStates()
if not isConfigured() or suspended or connectionRequestInFlight then return end if not isConfigured() or connectionRequestInFlight then return end
connectionRequestInFlight = true connectionRequestInFlight = true
connectingSince = os.clock() connectingSince = os.clock()
local launched = haRequest("/api/states", "GET", nil, function(res) local launched = haRequest("/api/states", "GET", nil, function(res)
connectionRequestInFlight = false connectionRequestInFlight = false
connectingSince = nil connectingSince = 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
status = "unreachable"
noctalia.log("Cannot reach Home Assistant (connection failed)")
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return
end
if res.status == 401 or (res.body and (res.body:match("^401") or res.body:match("Unauthorized"))) then
status = "auth_failed"
noctalia.log("Authentication failed - check your token")
noctalia.notifyError(noctalia.tr("notifications.auth_failed"))
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return
end
if not res.body or res.body == "" then if not res.body or res.body == "" then
noctalia.log("Empty response body from /api/states") noctalia.log("Empty response body from /api/states")
status = "disconnected" status = "disconnected"
@@ -228,16 +255,7 @@ local function fetchInitialStates()
return return
end end
if not res.ok or res.status == 401 or res.body:match("^401") or res.body:match("Unauthorized") then if not res.ok or res.status >= 400 then
status = "auth_failed"
noctalia.log("Authentication failed - check your token")
noctalia.notifyError(noctalia.tr("notifications.auth_failed"))
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return
end
if not res.ok or (res.status and res.status >= 400) then
status = "disconnected" status = "disconnected"
noctalia.log("HTTP request failed: status " .. (res.status or "unknown")) noctalia.log("HTTP request failed: status " .. (res.status or "unknown"))
noctalia.state.set("connection_status", status) noctalia.state.set("connection_status", status)
@@ -258,7 +276,7 @@ local function fetchInitialStates()
noctalia.state.set("connection_status", status) noctalia.state.set("connection_status", status)
retryCount = 0 retryCount = 0
maxRetriesNotified = false maxRetriesNotified = false
suspended = false backoff = false
entityStates = {} entityStates = {}
local count = 0 local count = 0
@@ -291,14 +309,13 @@ local function fetchInitialStates()
if not launched then if not launched then
connectionRequestInFlight = false connectionRequestInFlight = false
connectingSince = nil connectingSince = nil
status = "disconnected" status = "unreachable"
noctalia.state.set("connection_status", status) noctalia.state.set("connection_status", status)
syncUpdateInterval() syncUpdateInterval()
end end
end end
function startSSEStream() function startSSEStream()
if suspended then return false end
if sseActive then return true end if sseActive then return true end
local cleanToken = getCleanToken() local cleanToken = getCleanToken()
@@ -327,8 +344,6 @@ function startSSEStream()
sseActive = false sseActive = false
currentEvent = {} currentEvent = {}
if suspended then return end
if result and result.ok and result.status == 401 then if result and result.ok and result.status == 401 then
status = "auth_failed" status = "auth_failed"
noctalia.log("Authentication failed - check your token") noctalia.log("Authentication failed - check your token")
@@ -382,7 +397,7 @@ noctalia.state.watch("panel_open_signal", function(value)
if value then if value then
retryCount = 0 retryCount = 0
maxRetriesNotified = false maxRetriesNotified = false
suspended = false backoff = false
end end
end) end)
@@ -396,10 +411,9 @@ function update()
return return
end end
if suspended then -- noctalia.http exposes no transport timeout, so a hung request would otherwise
return -- pin status on "connecting" indefinitely. Treat 15s without a callback as a
end -- failed attempt and fall through to the reconnect path below.
if status == "connecting" and connectingSince and (os.clock() - connectingSince) > 15 then if status == "connecting" and connectingSince and (os.clock() - connectingSince) > 15 then
connectionRequestInFlight = false connectionRequestInFlight = false
connectingSince = nil connectingSince = nil
@@ -408,17 +422,18 @@ function update()
syncUpdateInterval() syncUpdateInterval()
end end
if status == "disconnected" then if status == "disconnected" or status == "unreachable" then
retryCount = retryCount + 1 retryCount = retryCount + 1
if retryCount >= MAX_RECONNECT_RETRIES 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
if not maxRetriesNotified then if not maxRetriesNotified then
maxRetriesNotified = true maxRetriesNotified = true
noctalia.notifyError(noctalia.tr("notifications.reconnect_failed", { count = MAX_RECONNECT_RETRIES })) noctalia.notifyError(noctalia.tr("notifications.reconnect_failed", { count = MAX_RECONNECT_RETRIES }))
end end
suspended = true syncUpdateInterval()
status = "disconnected"
noctalia.state.set("connection_status", status)
return
end end
status = "connecting" status = "connecting"
+2
View File
@@ -9,6 +9,7 @@
"color_temp": "Color Temperature", "color_temp": "Color Temperature",
"connecting": "Connecting…", "connecting": "Connecting…",
"disconnected_reconnecting": "Disconnected. Reconnecting…", "disconnected_reconnecting": "Disconnected. Reconnecting…",
"unreachable": "Cannot reach Home Assistant. Check the URL and that the server is running.",
"hue": "Hue", "hue": "Hue",
"loading_entities": "Loading entities…", "loading_entities": "Loading entities…",
"manage_entities_title": "Manage Entities", "manage_entities_title": "Manage Entities",
@@ -64,6 +65,7 @@
"status_connected": "Connected", "status_connected": "Connected",
"status_connecting": "Connecting", "status_connecting": "Connecting",
"status_disconnected": "Disconnected", "status_disconnected": "Disconnected",
"status_unreachable": "Unreachable",
"status_unconfigured": "Not Configured", "status_unconfigured": "Not Configured",
"tooltip": "Home Assistant: {status}", "tooltip": "Home Assistant: {status}",
"tooltip_entities": "Home Assistant: {status} ({count} entities)" "tooltip_entities": "Home Assistant: {status} ({count} entities)"
+2
View File
@@ -9,6 +9,7 @@
"color_temp": "Temperatura de Cor", "color_temp": "Temperatura de Cor",
"connecting": "Conectando…", "connecting": "Conectando…",
"disconnected_reconnecting": "Desconectado. Reconectando…", "disconnected_reconnecting": "Desconectado. Reconectando…",
"unreachable": "Não foi possível acessar o Home Assistant. Verifique a URL e se o servidor está em execução.",
"hue": "Matiz", "hue": "Matiz",
"loading_entities": "Carregando entidades…", "loading_entities": "Carregando entidades…",
"manage_entities_title": "Gerenciar Entidades", "manage_entities_title": "Gerenciar Entidades",
@@ -64,6 +65,7 @@
"status_connected": "Conectado", "status_connected": "Conectado",
"status_connecting": "Conectando", "status_connecting": "Conectando",
"status_disconnected": "Desconectado", "status_disconnected": "Desconectado",
"status_unreachable": "Inacessível",
"status_unconfigured": "Não Configurado", "status_unconfigured": "Não Configurado",
"tooltip": "Home Assistant: {status}", "tooltip": "Home Assistant: {status}",
"tooltip_entities": "Home Assistant: {status} ({count} entidades)" "tooltip_entities": "Home Assistant: {status} ({count} entidades)"
+3 -1
View File
@@ -34,6 +34,8 @@ local function getStatusText()
return tr("status_connecting") return tr("status_connecting")
elseif status == "disconnected" then elseif status == "disconnected" then
return tr("status_disconnected") return tr("status_disconnected")
elseif status == "unreachable" then
return tr("status_unreachable")
elseif status == "auth_failed" then elseif status == "auth_failed" then
return tr("status_auth_failed") return tr("status_auth_failed")
else else
@@ -46,7 +48,7 @@ local function getStatusColor()
return "primary" return "primary"
elseif status == "connecting" then elseif status == "connecting" then
return "on_error" return "on_error"
elseif status == "disconnected" or status == "auth_failed" then elseif status == "disconnected" or status == "unreachable" or status == "auth_failed" then
return "error" return "error"
else else
return "on_surface_variant" return "on_surface_variant"