From ef512945ce288546baff14688884d8ec3a2aa62b Mon Sep 17 00:00:00 2001 From: Artur Date: Wed, 22 Jul 2026 00:31:05 -0300 Subject: [PATCH] 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 --- hassio/panel.luau | 5 +++ hassio/plugin.toml | 2 +- hassio/service_sse.luau | 81 ++++++++++++++++++++-------------- hassio/translations/en.json | 2 + hassio/translations/pt-BR.json | 2 + hassio/widget.luau | 4 +- 6 files changed, 61 insertions(+), 35 deletions(-) diff --git a/hassio/panel.luau b/hassio/panel.luau index 4002834..a7bff44 100644 --- a/hassio/panel.luau +++ b/hassio/panel.luau @@ -39,6 +39,8 @@ local function getStatusText() return noctalia.tr("widget.status_connecting") elseif status == "disconnected" then return noctalia.tr("widget.status_disconnected") + elseif status == "unreachable" then + return noctalia.tr("widget.status_unreachable") elseif status == "auth_failed" then return noctalia.tr("widget.status_auth_failed") else @@ -519,6 +521,9 @@ local function buildListBody(ids) if status == "disconnected" then return ui.label({ text = tr("disconnected_reconnecting"), color = "error" }) end + if status == "unreachable" then + return ui.label({ text = tr("unreachable"), color = "error" }) + end if #ids == 0 then return ui.label({ text = status == "connected" diff --git a/hassio/plugin.toml b/hassio/plugin.toml index 4287a90..57b8a5a 100644 --- a/hassio/plugin.toml +++ b/hassio/plugin.toml @@ -1,6 +1,6 @@ id = "pozzoo/hassio" name = "Home Assistant" -version = "2.0.2" +version = "2.0.3" plugin_api = 4 author = "Pozzoo" license = "MIT" diff --git a/hassio/service_sse.luau b/hassio/service_sse.luau index 992b3ba..e06da54 100644 --- a/hassio/service_sse.luau +++ b/hassio/service_sse.luau @@ -35,9 +35,10 @@ local streamHandle = nil local MAX_RECONNECT_RETRIES = 10 local CONNECTED_POLL_INTERVAL_MS = 30000 local RECONNECT_POLL_INTERVAL_MS = 1000 +local BACKOFF_POLL_INTERVAL_MS = 60000 local retryCount = 0 local maxRetriesNotified = false -local suspended = false +local backoff = false local connectionRequestInFlight = false local connectingSince = nil @@ -49,6 +50,8 @@ end 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 @@ -70,14 +73,19 @@ local function haRequest(endpoint, method, body, callback) jsonBody = noctalia.json.encode(body) 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({ url = url, method = method, body = jsonBody, - headers = { - "Authorization: Bearer " .. cleanToken, - "Content-Type: application/json", - } + headers = headers, }, function(response: HttpResponse) if not response then callback({ ok = false, status = 0, body = "No response" }) @@ -213,13 +221,32 @@ local function processSSELine(line) end local function fetchInitialStates() - if not isConfigured() or suspended or connectionRequestInFlight then return end + if not isConfigured() or connectionRequestInFlight then return end connectionRequestInFlight = true connectingSince = os.clock() local launched = haRequest("/api/states", "GET", nil, function(res) connectionRequestInFlight = false 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 noctalia.log("Empty response body from /api/states") status = "disconnected" @@ -228,16 +255,7 @@ local function fetchInitialStates() return end - if not res.ok or res.status == 401 or 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.ok or (res.status and res.status >= 400) then + 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) @@ -258,7 +276,7 @@ local function fetchInitialStates() noctalia.state.set("connection_status", status) retryCount = 0 maxRetriesNotified = false - suspended = false + backoff = false entityStates = {} local count = 0 @@ -291,14 +309,13 @@ local function fetchInitialStates() if not launched then connectionRequestInFlight = false connectingSince = nil - status = "disconnected" + status = "unreachable" noctalia.state.set("connection_status", status) syncUpdateInterval() end end function startSSEStream() - if suspended then return false end if sseActive then return true end local cleanToken = getCleanToken() @@ -327,8 +344,6 @@ function startSSEStream() sseActive = false currentEvent = {} - if suspended then return end - if result and result.ok and result.status == 401 then status = "auth_failed" noctalia.log("Authentication failed - check your token") @@ -382,7 +397,7 @@ noctalia.state.watch("panel_open_signal", function(value) if value then retryCount = 0 maxRetriesNotified = false - suspended = false + backoff = false end end) @@ -396,10 +411,9 @@ function update() return end - if suspended then - return - end - + -- noctalia.http exposes no transport timeout, so a hung request would otherwise + -- pin status on "connecting" indefinitely. Treat 15s without a callback as a + -- 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 @@ -408,18 +422,19 @@ function update() syncUpdateInterval() end - if status == "disconnected" then + if status == "disconnected" or status == "unreachable" then 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 maxRetriesNotified = true noctalia.notifyError(noctalia.tr("notifications.reconnect_failed", { count = MAX_RECONNECT_RETRIES })) end - suspended = true - status = "disconnected" - noctalia.state.set("connection_status", status) - return - end + syncUpdateInterval() + end status = "connecting" noctalia.state.set("connection_status", status) diff --git a/hassio/translations/en.json b/hassio/translations/en.json index e7b6e7d..957c482 100644 --- a/hassio/translations/en.json +++ b/hassio/translations/en.json @@ -9,6 +9,7 @@ "color_temp": "Color Temperature", "connecting": "Connecting…", "disconnected_reconnecting": "Disconnected. Reconnecting…", + "unreachable": "Cannot reach Home Assistant. Check the URL and that the server is running.", "hue": "Hue", "loading_entities": "Loading entities…", "manage_entities_title": "Manage Entities", @@ -64,6 +65,7 @@ "status_connected": "Connected", "status_connecting": "Connecting", "status_disconnected": "Disconnected", + "status_unreachable": "Unreachable", "status_unconfigured": "Not Configured", "tooltip": "Home Assistant: {status}", "tooltip_entities": "Home Assistant: {status} ({count} entities)" diff --git a/hassio/translations/pt-BR.json b/hassio/translations/pt-BR.json index d5ef560..b16a402 100644 --- a/hassio/translations/pt-BR.json +++ b/hassio/translations/pt-BR.json @@ -9,6 +9,7 @@ "color_temp": "Temperatura de Cor", "connecting": "Conectando…", "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", "loading_entities": "Carregando entidades…", "manage_entities_title": "Gerenciar Entidades", @@ -64,6 +65,7 @@ "status_connected": "Conectado", "status_connecting": "Conectando", "status_disconnected": "Desconectado", + "status_unreachable": "Inacessível", "status_unconfigured": "Não Configurado", "tooltip": "Home Assistant: {status}", "tooltip_entities": "Home Assistant: {status} ({count} entidades)" diff --git a/hassio/widget.luau b/hassio/widget.luau index b26c862..d616fab 100644 --- a/hassio/widget.luau +++ b/hassio/widget.luau @@ -34,6 +34,8 @@ local function getStatusText() return tr("status_connecting") elseif status == "disconnected" then return tr("status_disconnected") + elseif status == "unreachable" then + return tr("status_unreachable") elseif status == "auth_failed" then return tr("status_auth_failed") else @@ -46,7 +48,7 @@ local function getStatusColor() return "primary" elseif status == "connecting" then return "on_error" - elseif status == "disconnected" or status == "auth_failed" then + elseif status == "disconnected" or status == "unreachable" or status == "auth_failed" then return "error" else return "on_surface_variant"