diff --git a/CHANGELOG.md b/CHANGELOG.md index 2936443..7f4c68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.2.1 — 2026-08-28 — the same panel on both sides of a firmware upgrade + +**1.2.0 let Home Assistant add a simulated panel; this release lets it keep one across the swap to panelbench.** Stopping this simulator and starting panelbench +is meant to rehearse a firmware upgrade on a single panel, and a panel does not change address or move its TLS port when its firmware changes. Two things here +did, and each was enough on its own to lose the panel at the swap. + +**If you noted this add-on's TLS port from 1.2.0, it has moved.** A panel on HTTP 8081 now serves TLS on 9081 rather than 8443. Nothing needs changing by hand — +the port is published in discovery and Home Assistant picks it up — but a firewall rule or a note written against the old number is now out of date. + +### Fixed + +- **A panel registered through the Supervisor now reports the advertised address rather than the container hostname**, which Home Assistant wrote over the + address a user had configured, leaving the entry pointing at a name the panel's certificate does not carry. +- **A panel now serves TLS on its HTTP port plus 1000, the rule panelbench already follows**, where it used a separate base of 8443 and so appeared to move its + TLS port across an upgrade that should not have changed it. + ## 1.2.0 — 2026-08-28 — the panel serves TLS, so Home Assistant can finish adding it **Each panel now serves its bootstrap API over HTTPS as well as HTTP**, on a second port allocated alongside the HTTP one. The certificate it presents is signed diff --git a/pyproject.toml b/pyproject.toml index b111600..b96fffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "span-panel-simulator" -version = "1.2.0" +version = "1.2.1" description = "Standalone eBus simulator for SPAN panels" requires-python = ">=3.14" dependencies = [ diff --git a/scripts/run-local.sh b/scripts/run-local.sh index 0b4f8c9..b314d9c 100755 --- a/scripts/run-local.sh +++ b/scripts/run-local.sh @@ -210,6 +210,9 @@ run_simulator() { echo "==> Starting simulator..." echo " Config: ${config_dir}" echo " HTTP: ${advertise_addr}:${HTTP_PORT}" + # Derived, not configured -- see https_port_for() in const.py. Shown because + # adding a panel by hand needs the TLS port and it is not the one typed in. + echo " HTTPS: ${advertise_addr}:$((HTTP_PORT + 1000))" echo " Dashboard: http://${advertise_addr:-localhost}:${DASHBOARD_PORT}" echo " MQTTS: ${advertise_addr}:${BROKER_PORT}" if [[ -n "${advertise_addr}" ]]; then diff --git a/span_panel_simulator/Dockerfile b/span_panel_simulator/Dockerfile index c4ee0ae..17ecff9 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.2.0" \ + io.hass.version="1.2.1" \ io.hass.arch="aarch64|amd64" CMD ["/run.sh"] diff --git a/span_panel_simulator/config.yaml b/span_panel_simulator/config.yaml index e2d638d..acd9484 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.2.0" +version: "1.2.1" 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 626065e..25aeb47 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.2.0" +__version__ = "1.2.1" diff --git a/src/span_panel_simulator/app.py b/src/span_panel_simulator/app.py index 3420168..39a20b9 100644 --- a/src/span_panel_simulator/app.py +++ b/src/span_panel_simulator/app.py @@ -25,12 +25,12 @@ from span_panel_simulator.const import ( DASHBOARD_PORT, DEFAULT_BASE_HTTP_PORT, - DEFAULT_BASE_HTTPS_PORT, DEFAULT_BROKER_PASSWORD, DEFAULT_BROKER_USERNAME, DEFAULT_FIRMWARE_VERSION, DEFAULT_TICK_INTERVAL_S, MQTTS_PORT, + https_port_for, ) from span_panel_simulator.dashboard import DashboardContext, create_dashboard_app from span_panel_simulator.discovery import PanelAdvertiser, PanelBrowser @@ -112,7 +112,6 @@ def __init__( broker_host: str = "localhost", broker_port: int = MQTTS_PORT, base_http_port: int = DEFAULT_BASE_HTTP_PORT, - base_https_port: int = DEFAULT_BASE_HTTPS_PORT, cert_dir: Path | None = None, homie_schema_path: Path | None = None, dashboard_port: int = DASHBOARD_PORT, @@ -128,7 +127,6 @@ def __init__( self._broker_host = broker_host self._broker_port = broker_port self._base_http_port = base_http_port - self._base_https_port = base_https_port self._cert_dir = cert_dir or Path("/tmp/span-sim-certs") self._homie_schema_path = homie_schema_path self._dashboard_port = dashboard_port @@ -142,9 +140,6 @@ def __init__( self._panel_start_errors: dict[str, str] = {} # filename -> last error message self._panel_servers: dict[str, BootstrapHttpServer] = {} self._panel_ports: dict[str, int] = {} - self._panel_https_ports: dict[str, int] = {} - # One pool across both bases so an HTTP port can never be handed out - # as an HTTPS one, however the two ranges are configured to overlap. self._used_ports: set[int] = set() self._dashboard_runner: web.AppRunner | None = None self._advertiser: PanelAdvertiser | None = None @@ -161,9 +156,14 @@ def __init__( # Port allocation # ------------------------------------------------------------------ - def _allocate_port(self, base: int | None = None) -> int: - """Return the lowest available port at or above ``base``.""" - port = self._base_http_port if base is None else base + def _allocate_port(self) -> int: + """Return the lowest available HTTP port from the base. + + Only HTTP ports are pooled. The TLS port is derived from the one handed + out here, so there is no second pool that could fall out of step with + this one. + """ + port = self._base_http_port while port in self._used_ports: port += 1 self._used_ports.add(port) @@ -185,10 +185,6 @@ def _get_panel_ports(self) -> dict[str, int]: """Return a mapping of serial number to HTTP port for running panels.""" return dict(self._panel_ports) - def _get_panel_https_ports(self) -> dict[str, int]: - """Return a mapping of serial number to HTTPS port for running panels.""" - return dict(self._panel_https_ports) - def _get_panel_start_errors(self) -> dict[str, str]: """Return the most recent per-filename start/reload errors.""" return dict(self._panel_start_errors) @@ -362,7 +358,7 @@ async def _start_panel(self, config_path: Path) -> PanelInstance: # a nested function. certs = self._certs - def _build(http_port: int, https_port: int) -> BootstrapHttpServer: + def _build(http_port: int) -> BootstrapHttpServer: return BootstrapHttpServer( serial, self._firmware, @@ -372,58 +368,54 @@ def _build(http_port: int, https_port: int) -> BootstrapHttpServer: broker_password=self._broker_password, broker_host=self._broker_host, port=http_port, - https_port=https_port, + https_port=https_port_for(http_port), ) max_port_retries = 20 port = self._allocate_port() - https_port = self._allocate_port(self._base_https_port) for _attempt in range(max_port_retries): - server = _build(port, https_port) + server = _build(port) try: await server.start() break except OSError as exc: if exc.errno != errno.EADDRINUSE: raise - _LOGGER.warning( - "Ports %d/%d in use for panel %s, trying the next pair", - port, - https_port, - serial, - ) + # Either half of the pair may be the one in use; both move + # together, so the next allocation is a clean pair. + _LOGGER.warning("Port %d in use for panel %s, trying next port", port, serial) self._release_port(port) - self._release_port(https_port) port = self._allocate_port() - https_port = self._allocate_port(self._base_https_port) else: self._release_port(port) - self._release_port(https_port) raise OSError( - f"Could not find an available port pair for panel {serial} " + f"Could not find an available port for panel {serial} " f"after {max_port_retries} attempts" ) self._panel_servers[serial] = server self._panel_ports[serial] = port - self._panel_https_ports[serial] = https_port # Register with mDNS advertiser if self._advertiser is not None: await self._advertiser.register_panel( - serial, self._firmware, model=panel_model, port=port, https_port=https_port + serial, + self._firmware, + model=panel_model, + port=port, + https_port=https_port_for(port), ) # Register with Supervisor Discovery if self._supervisor_discovery is not None and self._supervisor_discovery.is_available: - await self._supervisor_discovery.register_panel(serial, port, https_port) + await self._supervisor_discovery.register_panel(serial, port, https_port_for(port)) _LOGGER.info( "Registered panel %s from %s on ports %d/%d (http/https)", serial, config_path.name, port, - https_port, + https_port_for(port), ) return panel @@ -572,13 +564,11 @@ async def _stop_panel(self, config_path: Path) -> None: if server is not None: await server.stop() - # Release the allocated port pair + # Release the allocated port. The TLS port is derived from it, so + # releasing this one releases the pair. port = self._panel_ports.pop(serial, None) if port is not None: self._release_port(port) - https_port = self._panel_https_ports.pop(serial, None) - if https_port is not None: - self._release_port(https_port) # Unregister from mDNS if self._advertiser is not None: @@ -821,7 +811,7 @@ async def run(self) -> None: # 3b. Initialise Supervisor Discovery (add-on mode). from span_panel_simulator.supervisor_discovery import SupervisorDiscovery - self._supervisor_discovery = SupervisorDiscovery() + self._supervisor_discovery = SupervisorDiscovery(self._advertise_address) if self._supervisor_discovery.is_available: await self._supervisor_discovery.cleanup_stale() _LOGGER.info("Supervisor Discovery: available (add-on mode)") diff --git a/src/span_panel_simulator/const.py b/src/span_panel_simulator/const.py index a827897..11578b1 100644 --- a/src/span_panel_simulator/const.py +++ b/src/span_panel_simulator/const.py @@ -9,10 +9,32 @@ WS_PORT = 19001 WSS_PORT = 19002 DEFAULT_BASE_HTTP_PORT = 8081 -# Real panels serve the bootstrap API over plain HTTP on 80 and over TLS on 443. -# The simulator keeps that split -- consumers pin the published authority and then -# talk to the panel over it -- but on offset ports, one pair per panel. -DEFAULT_BASE_HTTPS_PORT = 8443 + +# Real panels serve the bootstrap API over plain HTTP on 80 and over TLS on 443, +# and keep both across a firmware upgrade. Simulated panels sit on non-standard +# HTTP ports so they can share a host, and their TLS port is derived from it by a +# fixed offset rather than allocated separately: a client that has been told one +# port can then be told the other by the same discovery record, with no second +# pool to keep in step. +# +# The offset matches panelbench. Stopping this simulator and starting panelbench +# rehearses a firmware upgrade on one panel, so the two have to agree on where a +# given panel serves TLS -- a panel that moved its TLS port across an upgrade is +# not a panel that was upgraded. +DEFAULT_HTTPS_PORT = 443 +HTTPS_PORT_OFFSET = 1000 + + +def https_port_for(http_port: int) -> int: + """Return the TLS port a panel serves on given its bootstrap HTTP port. + + One definition, because three parties need the same answer: the panel that + binds the listener, the discovery records that publish it, and anyone + reading a port out of the log to add a panel by hand. + """ + return http_port + HTTPS_PORT_OFFSET + + DASHBOARD_PORT = 18080 # Default simulation parameters diff --git a/src/span_panel_simulator/supervisor_discovery.py b/src/span_panel_simulator/supervisor_discovery.py index d7332ee..e0b1931 100644 --- a/src/span_panel_simulator/supervisor_discovery.py +++ b/src/span_panel_simulator/supervisor_discovery.py @@ -45,8 +45,9 @@ 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._advertise_address = advertise_address self._entries: dict[str, str] = {} # serial -> discovery UUID @property @@ -102,8 +103,22 @@ async def cleanup_stale(self) -> None: async def register_panel(self, serial: str, port: int, https_port: int = 443) -> 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 registered is the advertised address when one is known. That is + the address the panel's certificate names, and the integration's + Supervisor path rewrites an existing entry's host to whatever is + registered here -- deliberately and without the guard its other + discovery routes apply, because an add-on legitimately reallocates its + own ports. So registering anything the leaf does not name silently moves + a working entry to an address that cannot pass verification. + + The container hostname is the fallback rather than the rule. Under + ``host_network: true`` it is only a per-add-on alias for the host, so it + is absent from the other emulator's certificate and differs between the + two -- which breaks the simulator-to-panelbench upgrade rehearsal, where + the panel is supposed to keep its identity across the swap. The + advertised address is what both emulators share. + + No-ops if not in add-on mode. ``https_port`` is published alongside the HTTP one because a consumer that pins the authority this panel serves then has to reach the leaf @@ -114,7 +129,7 @@ async def register_panel(self, serial: str, port: int, https_port: int = 443) -> if not self._token: return - host = _container_hostname() + host = self._advertise_address or _container_hostname() payload = { "service": _SERVICE_NAME, "config": { diff --git a/tests/test_discovery_advertiser.py b/tests/test_discovery_advertiser.py index 6c19669..1abc121 100644 --- a/tests/test_discovery_advertiser.py +++ b/tests/test_discovery_advertiser.py @@ -12,7 +12,11 @@ import pytest -from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION +from span_panel_simulator.const import ( + DEFAULT_FIRMWARE_VERSION, + HTTPS_PORT_OFFSET, + https_port_for, +) from span_panel_simulator.discovery import SERVICE_TYPE_EBUS, PanelAdvertiser @@ -40,10 +44,10 @@ async def _ebus_properties(advertiser: PanelAdvertiser, **kwargs: int) -> dict[b async def test_non_standard_ports_are_both_published(advertiser: PanelAdvertiser) -> None: """A panel on offset ports advertises both of them.""" - props = await _ebus_properties(advertiser, port=8081, https_port=8443) + props = await _ebus_properties(advertiser, port=8081, https_port=https_port_for(8081)) assert props[b"httpPort"] == b"8081" - assert props[b"httpsPort"] == b"8443" + assert props[b"httpsPort"] == b"9081" async def test_standard_ports_are_left_unsaid(advertiser: PanelAdvertiser) -> None: @@ -57,3 +61,17 @@ async def test_standard_ports_are_left_unsaid(advertiser: PanelAdvertiser) -> No assert b"httpPort" not in props assert b"httpsPort" not in props + + +def test_the_tls_port_offset_matches_panelbench() -> None: + """The offset is a contract with panelbench, not a local preference. + + Stopping this simulator and starting panelbench rehearses a firmware upgrade + on one panel, and a panel that moved its TLS port across an upgrade is not a + panel that was upgraded. Both derive it as http + 1000; changing this number + on one side alone silently breaks that rehearsal, so it is pinned here + rather than left to whatever the constant happens to say. + """ + assert HTTPS_PORT_OFFSET == 1000 + assert https_port_for(8081) == 9081 + assert https_port_for(80) == 1080 diff --git a/tests/test_supervisor_discovery.py b/tests/test_supervisor_discovery.py index 7ca0368..3f37767 100644 --- a/tests/test_supervisor_discovery.py +++ b/tests/test_supervisor_discovery.py @@ -7,13 +7,14 @@ import pytest from aiohttp import ClientSession +from span_panel_simulator.const import https_port_for from span_panel_simulator.supervisor_discovery import SupervisorDiscovery @pytest.fixture def discovery() -> SupervisorDiscovery: """Discovery client with a fake token (simulates add-on mode).""" - d = SupervisorDiscovery() + d = SupervisorDiscovery(advertise_address="192.168.1.50") d._token = "test-token" return d @@ -75,11 +76,56 @@ async def test_register_panel_publishes_both_ports(discovery: SupervisorDiscover return_value="f8c38f2b-span-panel-simulator", ), ): - await discovery.register_panel("sim-001", 8081, 8443) + await discovery.register_panel("sim-001", 8081, https_port_for(8081)) config = mock_session.post.call_args.kwargs["json"]["config"] assert config["port"] == 8081 - assert config["https_port"] == 8443 + assert config["https_port"] == 9081 + + +async def test_register_panel_uses_the_advertised_address_as_host( + discovery: SupervisorDiscovery, +): + """The host registered is the address the panel's certificate names. + + The integration rewrites an existing entry's host to whatever is registered + here, so registering anything the leaf does not name moves a working entry + to an address that cannot pass verification. + """ + mock_session = _mock_session(200, {"result": "ok", "data": {"uuid": "disc-uuid-123"}}) + with ( + patch("aiohttp.ClientSession", return_value=mock_session), + patch( + "span_panel_simulator.supervisor_discovery._container_hostname", + return_value="f8c38f2b-span-panel-simulator", + ), + ): + await discovery.register_panel("sim-001", 8081) + + assert mock_session.post.call_args.kwargs["json"]["config"]["host"] == "192.168.1.50" + + +async def test_the_container_hostname_is_only_a_fallback(): + """With no advertised address there is nothing better than the hostname. + + Worse than an address the certificate names, but it is what the add-on can + still be reached by, and refusing to register at all would be worse again. + """ + discovery = SupervisorDiscovery() + discovery._token = "test-token" + + mock_session = _mock_session(200, {"result": "ok", "data": {"uuid": "disc-uuid-123"}}) + with ( + patch("aiohttp.ClientSession", return_value=mock_session), + patch( + "span_panel_simulator.supervisor_discovery._container_hostname", + return_value="f8c38f2b-span-panel-simulator", + ), + ): + await discovery.register_panel("sim-001", 8081) + + host = mock_session.post.call_args.kwargs["json"]["config"]["host"] + assert host == "f8c38f2b-span-panel-simulator" async def test_unregister_panel_deletes_from_supervisor(discovery: SupervisorDiscovery):