Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion span_panel_simulator/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion span_panel_simulator/config.yaml
Original file line number Diff line number Diff line change
@@ -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}"
Expand Down
2 changes: 1 addition & 1 deletion src/span_panel_simulator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Standalone eBus simulator for SPAN panels."""

__version__ = "1.0.17"
__version__ = "1.0.18"
6 changes: 5 additions & 1 deletion src/span_panel_simulator/ha_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions tests/test_ha_api/test_client_validate.py
Original file line number Diff line number Diff line change
@@ -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