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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 2.5.2 — discovery hands over an address, not an internal alias

### Fixed

- **Discovery now gives Home Assistant the panel's address rather than an internal add-on hostname**, which Home Assistant writes over the address on a panel
you already added and which no certificate names, so a panel added by IP stopped verifying.

## 2.5.1 — the address a client can actually verify us by

An existing install corrects itself on the next start: a stored certificate that names the wrong address is already treated as unfit and re-signed, so nothing
Expand Down
2 changes: 1 addition & 1 deletion panelbench/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 PanelBench" \
io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \
io.hass.type="addon" \
io.hass.version="2.5.1" \
io.hass.version="2.5.2" \
io.hass.arch="aarch64|amd64"

CMD ["/run.sh"]
2 changes: 1 addition & 1 deletion panelbench/config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: "SPAN PanelBench"
description: "Simulates a SPAN electrical panel for testing and upgrade modeling"
version: "2.5.1"
version: "2.5.2"
slug: "panelbench"
url: "https://github.com/SpanPanel/panelbench"
image: "ghcr.io/spanpanel/panelbench/{arch}"
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 = "panelbench"
version = "2.5.1"
version = "2.5.2"
description = "Standalone eBus simulator for SPAN panels"
requires-python = ">=3.14"
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion src/panelbench/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Standalone eBus simulator for SPAN panels."""

__version__ = "2.5.1"
__version__ = "2.5.2"
4 changes: 3 additions & 1 deletion src/panelbench/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,9 @@ async def run(self) -> None:
# 3b. Initialise Supervisor Discovery (add-on mode).
from panelbench.supervisor_discovery import SupervisorDiscovery

self._supervisor_discovery = SupervisorDiscovery()
self._supervisor_discovery = SupervisorDiscovery(
advertise_address=self._advertise_address,
)
if self._supervisor_discovery.is_available:
await self._supervisor_discovery.cleanup_stale()
_LOGGER.info("Supervisor Discovery: available (add-on mode)")
Expand Down
21 changes: 17 additions & 4 deletions src/panelbench/supervisor_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ def _payload(body: object) -> dict[str, object]:
class SupervisorDiscovery:
"""Manages Supervisor Discovery entries for simulated panels."""

def __init__(self) -> None:
def __init__(self, advertise_address: str | None = None) -> None:
self._token = os.environ.get("SUPERVISOR_TOKEN")
self._entries: dict[str, str] = {} # serial -> discovery UUID
self._advertise_address = advertise_address

@property
def is_available(self) -> bool:
Expand Down Expand Up @@ -106,8 +107,20 @@ async def register_panel(
) -> None:
"""Register a panel with the Supervisor Discovery API.

The host is always the container hostname — HA Core resolves it
via Docker DNS. No-ops if not in add-on mode.
The host is the advertised address when one is configured, and the
container hostname only when none is. Both are reachable from HA
Core, but only the address is in the certificate this panel serves,
and the hostname is what a consumer cannot verify us by: under
``host_network: true`` it is a per-add-on alias for the host rather
than a name the leaf was issued for.

It also has to be the address because ``async_step_hassio`` rewrites
an existing entry's host to whatever is registered here. A panel
someone added by IP is silently re-pointed at this alias, which then
fails hostname verification -- and the alias changes when the panel
moves between add-ons, where the address does not.

No-ops if not in add-on mode.

``https_port`` is published beside the bootstrap port because a
simulated panel serves TLS on a port only this process knows. The
Expand All @@ -118,7 +131,7 @@ async def register_panel(
if not self._token:
return

host = _container_hostname()
host = self._advertise_address or _container_hostname()
payload = {
"service": _SERVICE_NAME,
"config": {
Expand Down
52 changes: 52 additions & 0 deletions tests/test_supervisor_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ def discovery() -> SupervisorDiscovery:
return d


@pytest.fixture
def discovery_with_address() -> SupervisorDiscovery:
"""Add-on mode with an advertised address configured."""
d = SupervisorDiscovery(advertise_address="192.168.65.19")
d._token = "test-token"
return d


@pytest.fixture
def discovery_no_token() -> SupervisorDiscovery:
"""Discovery client without token (standalone mode)."""
Expand Down Expand Up @@ -81,6 +89,50 @@ async def test_register_panel_publishes_both_ports(discovery: SupervisorDiscover
assert config["https_port"] == 9081


async def test_the_advertised_address_is_registered_as_the_host(
discovery_with_address: SupervisorDiscovery,
):
"""The host is the address the leaf names, not the container alias.

`async_step_hassio` rewrites an existing entry's host to whatever is
registered here, so registering the alias silently re-points a panel that
someone added by IP at a name the certificate does not cover.
"""
mock_session = _mock_session(200, {"result": "ok", "data": {"uuid": "disc-uuid-123"}})
with (
patch("aiohttp.ClientSession", return_value=mock_session),
patch(
"panelbench.supervisor_discovery._container_hostname",
return_value="f8c38f2b-panelbench",
),
):
await discovery_with_address.register_panel("sim-001", 8081)

config = mock_session.post.call_args.kwargs["json"]["config"]
assert config["host"] == "192.168.65.19"
assert config["host"] != "f8c38f2b-panelbench"


async def test_the_container_hostname_is_the_fallback(discovery: SupervisorDiscovery):
"""With no address configured there is nothing else to publish.

Docker DNS still resolves it, so a panel with no advertised address stays
discoverable rather than registering a host of ``None``.
"""
mock_session = _mock_session(200, {"result": "ok", "data": {"uuid": "disc-uuid-123"}})
with (
patch("aiohttp.ClientSession", return_value=mock_session),
patch(
"panelbench.supervisor_discovery._container_hostname",
return_value="f8c38f2b-panelbench",
),
):
await discovery.register_panel("sim-001", 8081)

config = mock_session.post.call_args.kwargs["json"]["config"]
assert config["host"] == "f8c38f2b-panelbench"


async def test_unregister_panel_deletes_from_supervisor(discovery: SupervisorDiscovery):
"""unregister_panel DELETEs /discovery/{uuid}."""
discovery._entries["sim-001"] = "disc-uuid-123"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.