From f0cc45bb616115e04c6acdc7fb633c3ae8fb242d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:26:27 -0700 Subject: [PATCH] fix(ha-api): an unreachable Home Assistant no longer aborts startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HAClient.async_validate` set `aiohttp.ClientTimeout(total=...)` on its requests but caught only `(aiohttp.ClientError, PermissionError)`. An expired total timeout surfaces as the builtin `TimeoutError`, which is an `OSError` and not an `aiohttp.ClientError`, so it escaped and took startup with it — the one failure the validation exists to report was the one that killed the simulator instead of degrading to "continue without HA". `OSError` joins the except tuple, and the four failure shapes a caller can hit are pinned by tests: timeout, refused connection, unauthorized, and a bare transport error. Also records the retirement notice in the README: this simulator emulates firmware prior to r202633 and gives way to panelbench once that ships. Release 1.0.18. --- CHANGELOG.md | 11 +++++ README.md | 4 ++ pyproject.toml | 2 +- span_panel_simulator/Dockerfile | 2 +- span_panel_simulator/config.yaml | 2 +- src/span_panel_simulator/__init__.py | 2 +- src/span_panel_simulator/ha_api/client.py | 6 ++- tests/test_ha_api/test_client_validate.py | 51 +++++++++++++++++++++++ 8 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 tests/test_ha_api/test_client_validate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c36f01..1a33f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.0.18 — 2026-08-28 — an unreachable Home Assistant no longer aborts startup + +**`HAClient.async_validate` now degrades to `False` on a connection timeout** instead of letting the exception escape and take startup with it. The client sets +`aiohttp.ClientTimeout(total=...)` on its requests, and an expired total timeout surfaces as the builtin `TimeoutError` — which is an `OSError`, not an +`aiohttp.ClientError`, so the existing `except (aiohttp.ClientError, PermissionError)` never caught it. A slow or unreachable Home Assistant is exactly the case +validation exists to report, and it was the one case that killed the simulator rather than continuing without HA. `OSError` joins the tuple, and the four +failure shapes a caller can hit — timeout, refused connection, unauthorized, and a bare transport error — are pinned by tests. + +The README now carries a retirement notice: this simulator emulates SPAN firmware prior to r202633 and will be retired once that firmware is published. Panels +on r202633 and later should use [panelbench](https://github.com/SpanPanel/panelbench) with SPAN integration 3.0.1 or newer. + ## 1.0.17 — 2026-08-26 — never-backup is a commissioning flag, not a priority **`circuit/never-backup` is now published from a per-circuit configuration flag** and is no longer derived from the shed priority. It was emitted as diff --git a/README.md b/README.md index d8ec538..268033b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ A standalone simulator that mimics real SPAN panel behavior. +> **Retirement notice** — This simulator emulates SPAN firmware prior to **r202633**. It will be retired once +> firmware r202633 is published. For panels on that firmware and later, use +> [panelbench](https://github.com/SpanPanel/panelbench) with the SPAN integration 3.0.1 or newer. + [![Open your Home Assistant instance and show the App Store.](https://my.home-assistant.io/badges/supervisor_store.svg)](https://my.home-assistant.io/redirect/supervisor_store/) - Provides mDNS discovery to panels not yet using the Home Assistant integration and direct connections to the SpanPanel SPAN integration for Home Assistant. diff --git a/pyproject.toml b/pyproject.toml index 8a61a29..1bb3eb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "span-panel-simulator" -version = "1.0.17" +version = "1.0.18" description = "Standalone eBus simulator for SPAN panels" requires-python = ">=3.14" dependencies = [ diff --git a/span_panel_simulator/Dockerfile b/span_panel_simulator/Dockerfile index d62a95a..482a25a 100644 --- a/span_panel_simulator/Dockerfile +++ b/span_panel_simulator/Dockerfile @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080 LABEL io.hass.name="SPAN Panel Simulator" \ io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \ io.hass.type="addon" \ - io.hass.version="1.0.17" \ + io.hass.version="1.0.18" \ io.hass.arch="aarch64|amd64" CMD ["/run.sh"] diff --git a/span_panel_simulator/config.yaml b/span_panel_simulator/config.yaml index ffb5c5e..114bc56 100644 --- a/span_panel_simulator/config.yaml +++ b/span_panel_simulator/config.yaml @@ -1,6 +1,6 @@ name: "SPAN Panel Simulator" description: "Simulates a SPAN electrical panel for testing and upgrade modeling" -version: "1.0.17" +version: "1.0.18" slug: "span_panel_simulator" url: "https://github.com/SpanPanel/simulator" image: "ghcr.io/spanpanel/simulator/{arch}" diff --git a/src/span_panel_simulator/__init__.py b/src/span_panel_simulator/__init__.py index 70ec1b0..2368c01 100644 --- a/src/span_panel_simulator/__init__.py +++ b/src/span_panel_simulator/__init__.py @@ -1,3 +1,3 @@ """Standalone eBus simulator for SPAN panels.""" -__version__ = "1.0.17" +__version__ = "1.0.18" diff --git a/src/span_panel_simulator/ha_api/client.py b/src/span_panel_simulator/ha_api/client.py index 546b87f..4f3cf4f 100644 --- a/src/span_panel_simulator/ha_api/client.py +++ b/src/span_panel_simulator/ha_api/client.py @@ -230,7 +230,11 @@ async def async_validate(self) -> bool: else: _LOGGER.warning("HA API: unexpected response from /api/: %s", result) return ok - except (aiohttp.ClientError, PermissionError): + except (aiohttp.ClientError, OSError, PermissionError): + # OSError covers the transport-level failures aiohttp raises + # outside its own hierarchy — notably the builtin TimeoutError + # from ClientTimeout, which would otherwise abort startup + # instead of degrading to "continue without HA". _LOGGER.exception("HA API: validation failed") return False diff --git a/tests/test_ha_api/test_client_validate.py b/tests/test_ha_api/test_client_validate.py new file mode 100644 index 0000000..070d09e --- /dev/null +++ b/tests/test_ha_api/test_client_validate.py @@ -0,0 +1,51 @@ +"""Tests for HA API connection validation.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import aiohttp +import pytest + +from span_panel_simulator.ha_api.client import HAClient, HAConnectionConfig + + +def _make_client() -> HAClient: + return HAClient( + HAConnectionConfig( + base_url="http://ha.invalid:8123/api", + token="synthetic-token", + is_supervisor=False, + ) + ) + + +@pytest.mark.parametrize( + "error", + [ + TimeoutError(), + aiohttp.ClientConnectionError("refused"), + PermissionError("401"), + OSError("no route to host"), + ], + ids=["timeout", "connection-refused", "unauthorized", "os-error"], +) +async def test_validate_returns_false_when_ha_unreachable(error: Exception) -> None: + """An unreachable or unauthorized HA degrades to False, never raises. + + A connect/total timeout surfaces as the builtin ``TimeoutError`` (an + ``OSError``), not an ``aiohttp.ClientError`` — letting it escape kills + simulator startup instead of continuing without HA. + """ + client = _make_client() + client._get = AsyncMock(side_effect=error) # type: ignore[method-assign] + + assert await client.async_validate() is False + + +async def test_validate_returns_true_on_api_running() -> None: + """The documented success response validates the connection.""" + client = _make_client() + client._get = AsyncMock(return_value={"message": "API running."}) # type: ignore[method-assign] + + assert await client.async_validate() is True