From fd6569ea7a57f984f31b1c9339428f3e717e1ae2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:45:22 -0700 Subject: [PATCH 1/3] fix(supervisor): register the advertised address, not the container hostname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration's Supervisor path rewrites an existing entry's host to whatever the add-on registers, deliberately and without the guard its other discovery routes apply — an add-on legitimately reallocates its own ports, so freezing the entry against its own add-on would be worse. The consequence is that whatever is registered here had better be an address the panel's certificate names. The container hostname is not. Under `host_network: true` it is only a per-add-on alias for the host, so it is absent from the leaf's SAN and differs between the two emulators. An entry a user had configured by IP was silently moved to it within a minute of creation, and the panelbench swap then failed hostname verification — the panel is meant to keep its identity across that swap, which is the whole point of the shared authority. The advertised address is what the leaf names and what stays constant across the swap, so it is what gets registered. The hostname remains the fallback for an install that configures no advertise address, where it is still better than refusing to register at all. Note that panelbench needs the same change for the rehearsal to work end to end: it still registers its container hostname, so the entry is rewritten again after the swap. --- .../supervisor_discovery.py | 23 ++++++-- tests/test_supervisor_discovery.py | 52 +++++++++++++++++-- 2 files changed, 68 insertions(+), 7 deletions(-) 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_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): From d25710e2e0f4ee63f55e19820f03edb10261fe50 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:45:31 -0700 Subject: [PATCH 2/3] fix(ports): derive the TLS port from the HTTP port, matching panelbench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TLS port was allocated from its own base of 8443 while panelbench derives it as the HTTP port plus 1000. A real panel keeps 443 across a firmware upgrade, so two emulators that stand in for the same panel either side of one have to agree on where it serves TLS — a panel whose TLS port moved across the swap is not a panel that was upgraded. Adopted panelbench's rule, down to the `https_port_for` helper, so the two read the same and neither can drift without the other noticing. Deriving also removes the second pool: there is one allocation per panel now, and releasing it releases the pair, so the two pools can no longer fall out of step. Pair-retry semantics are unchanged — either half may be the one in use, and both move together, so the next allocation is a clean pair. The offset is asserted in a test rather than left implicit in a constant, since it is a contract with another repository and changing it on one side alone breaks the rehearsal silently. --- CHANGELOG.md | 9 +++-- scripts/run-local.sh | 3 ++ src/span_panel_simulator/app.py | 62 +++++++++++++----------------- src/span_panel_simulator/const.py | 30 +++++++++++++-- tests/test_discovery_advertiser.py | 24 ++++++++++-- 5 files changed, 82 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2936443..5aa6128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,10 @@ because nothing ever served the certificate this add-on has always minted. and only then sends the passphrase — so registration never crosses the wire in the clear. With no TLS listener that check could not pass at any port, and the config flow stopped at "The certificate this panel serves is not signed by the authority it published" no matter what was entered. -**The TLS port is published in discovery, so Home Assistant does not ask for it.** It is allocated per panel and reallocated across restarts, so this add-on is -the only party that knows it; a user left to answer would have to guess, and 443 — the sensible guess — is never right here. Panels found over mDNS carry it as -`httpsPort`, and panels registered through the Supervisor carry it as `https_port`. +**The TLS port is published in discovery, so Home Assistant does not ask for it.** It is the panel's HTTP port plus 1000 — a panel on 8081 serves TLS on 9081 — +which is the rule panelbench already follows, so a panel keeps the same TLS port across the simulator-to-panelbench swap the way it would across a firmware +upgrade. Panels found over mDNS carry it as `httpsPort`, and panels registered through the Supervisor carry it as `https_port`, so nobody has to read it out of +a log. The plain HTTP port keeps serving the whole API. Deciding a panel is a SPAN panel at all, and fetching the authority, both necessarily happen before a consumer holds an anchor, so neither can be placed behind one. @@ -26,6 +27,8 @@ holds an anchor, so neither can be placed behind one. - **Stale discovery entries left by an earlier run are cleaned up at startup**, which silently did nothing for the same reason. - **The address advertised to the network, and named in the certificate, is now this machine's own**, where it was the default route's gateway — a neighbouring router — which left no address a consumer could verify the panel by. +- **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. ## 1.1.0 — 2026-08-28 — a fixed certificate authority, so a firmware upgrade looks like one 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/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/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 From e18c8a1bad4b644de9f47ec9c2d4c4099e20d373 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:49:28 -0700 Subject: [PATCH 3/3] chore(release): 1.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.2.0 is already installed from the previous merge, so these two fixes cannot ship under that version — the add-on store would never offer them. The 1.2.0 entry is left describing only what it shipped. Its TLS port paragraph had been edited in place to describe the derived port, which 1.2.0 does not do: that release allocated from a separate base of 8443, and saying otherwise would misdescribe the build people are running. --- CHANGELOG.md | 25 +++++++++++++++++++------ pyproject.toml | 2 +- span_panel_simulator/Dockerfile | 2 +- span_panel_simulator/config.yaml | 2 +- src/span_panel_simulator/__init__.py | 2 +- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aa6128..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 @@ -10,10 +26,9 @@ because nothing ever served the certificate this add-on has always minted. and only then sends the passphrase — so registration never crosses the wire in the clear. With no TLS listener that check could not pass at any port, and the config flow stopped at "The certificate this panel serves is not signed by the authority it published" no matter what was entered. -**The TLS port is published in discovery, so Home Assistant does not ask for it.** It is the panel's HTTP port plus 1000 — a panel on 8081 serves TLS on 9081 — -which is the rule panelbench already follows, so a panel keeps the same TLS port across the simulator-to-panelbench swap the way it would across a firmware -upgrade. Panels found over mDNS carry it as `httpsPort`, and panels registered through the Supervisor carry it as `https_port`, so nobody has to read it out of -a log. +**The TLS port is published in discovery, so Home Assistant does not ask for it.** It is allocated per panel and reallocated across restarts, so this add-on is +the only party that knows it; a user left to answer would have to guess, and 443 — the sensible guess — is never right here. Panels found over mDNS carry it as +`httpsPort`, and panels registered through the Supervisor carry it as `https_port`. The plain HTTP port keeps serving the whole API. Deciding a panel is a SPAN panel at all, and fetching the authority, both necessarily happen before a consumer holds an anchor, so neither can be placed behind one. @@ -27,8 +42,6 @@ holds an anchor, so neither can be placed behind one. - **Stale discovery entries left by an earlier run are cleaned up at startup**, which silently did nothing for the same reason. - **The address advertised to the network, and named in the certificate, is now this machine's own**, where it was the default route's gateway — a neighbouring router — which left no address a consumer could verify the panel by. -- **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. ## 1.1.0 — 2026-08-28 — a fixed certificate authority, so a firmware upgrade looks like one 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/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"