From 2fc98270ff0e725c5d7ac10ff8a473d52727cbe8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:56:06 -0700 Subject: [PATCH 1/4] fix(supervisor): read the discovery reply from the response envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Supervisor endpoint answers `{"result": ..., "data": {...}}`, but both calls here read the fields they wanted from the top level of the body. Registering a panel therefore never found the uuid, and logged the whole successful reply as an invalid one. The entry was created — the simulator just did not record what it was called, so `unregister_panel` had nothing to delete and every entry leaked. `cleanup_stale` read `discovery` off the same wrong level, always saw an empty list, and could not reap them on the next start either, so they accumulated in the Supervisor across restarts. Unwrapped once in a helper rather than at each call site, so the shape is stated in one place. The tests mocked a flat body the Supervisor never returns, which is why this shipped; they now mock the real envelope. --- .../supervisor_discovery.py | 24 +++++++++++++++---- tests/test_supervisor_discovery.py | 6 +++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/span_panel_simulator/supervisor_discovery.py b/src/span_panel_simulator/supervisor_discovery.py index 0743e9a..ad4299e 100644 --- a/src/span_panel_simulator/supervisor_discovery.py +++ b/src/span_panel_simulator/supervisor_discovery.py @@ -30,6 +30,18 @@ def _container_hostname() -> str: _SERVICE_NAME = "span_panel" +def _payload(body: object) -> dict[str, object]: + """Unwrap a Supervisor API response envelope. + + Every Supervisor endpoint answers ``{"result": ..., "data": {...}}``; + the fields we want live under ``data``, never at the top level. + """ + if not isinstance(body, dict): + return {} + data = body.get("data") + return data if isinstance(data, dict) else {} + + class SupervisorDiscovery: """Manages Supervisor Discovery entries for simulated panels.""" @@ -59,9 +71,11 @@ async def cleanup_stale(self) -> None: async with session.get(_SUPERVISOR_DISCOVERY_URL, headers=self._headers()) as resp: if resp.status != 200: return - data = await resp.json() + body = await resp.json() - entries = data.get("discovery", []) + entries = _payload(body).get("discovery", []) + if not isinstance(entries, list): + return for entry in entries: if entry.get("service") != _SERVICE_NAME: continue @@ -112,8 +126,8 @@ async def register_panel(self, serial: str, port: int) -> None: headers=self._headers(), ) as resp: if resp.status == 200: - data = await resp.json() - uuid = data.get("uuid") + body = await resp.json() + uuid = _payload(body).get("uuid") if isinstance(uuid, str) and uuid: self._entries[serial] = uuid _LOGGER.info( @@ -125,7 +139,7 @@ async def register_panel(self, serial: str, port: int) -> None: _LOGGER.warning( "Supervisor discovery: register %s returned invalid uuid: %s", serial, - data, + body, ) else: text = await resp.text() diff --git a/tests/test_supervisor_discovery.py b/tests/test_supervisor_discovery.py index 69d1fe4..28a8e53 100644 --- a/tests/test_supervisor_discovery.py +++ b/tests/test_supervisor_discovery.py @@ -45,7 +45,7 @@ def _mock_session(response_status: int, response_json: dict | None = None) -> Ma async def test_register_panel_posts_to_supervisor(discovery: SupervisorDiscovery): """register_panel POSTs to /discovery and tracks the UUID.""" - mock_session = _mock_session(200, {"uuid": "disc-uuid-123"}) + mock_session = _mock_session(200, {"result": "ok", "data": {"uuid": "disc-uuid-123"}}) with ( patch("aiohttp.ClientSession", return_value=mock_session), patch( @@ -100,7 +100,9 @@ async def test_cleanup_stale_on_startup(discovery: SupervisorDiscovery): ] get_resp = AsyncMock() get_resp.status = 200 - get_resp.json = AsyncMock(return_value={"discovery": existing_entries}) + get_resp.json = AsyncMock( + return_value={"result": "ok", "data": {"discovery": existing_entries}} + ) del_resp = AsyncMock() del_resp.status = 200 From a54a8da3931bf47ce54e0a6eb0254b1821f9f307 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:56:53 -0700 Subject: [PATCH 2/4] fix(bootstrap): serve the panel API over TLS and publish the port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel minted a server certificate and published the authority that signed it, but nothing ever served that certificate: the per-panel bootstrap server bound plain HTTP only. A consumer that pins the published authority — as the Home Assistant integration does before it will send the panel passphrase — checks that the anchor validates what the panel actually serves, and that check could not pass at any port. The config flow failed at the certificate step no matter what was entered, reporting a leaf mismatch when the truth was that nothing answered. Each panel now binds HTTPS alongside HTTP, off one runner, using the leaf that was already being generated. Both listeners serve the whole route table rather than splitting it: deciding a panel is a SPAN panel at all, and fetching the authority, both happen before a consumer holds an anchor, so neither can sit behind one — and which endpoints a consumer reaches over which listener is its decision, not the panel's to hard-code. The TLS port is allocated per panel from a pool shared with the HTTP one, so one can never be handed out as the other, and it is published by both discovery paths — `https_port` through the Supervisor, `httpsPort` in the mDNS TXT — so a consumer is not left to assume 443, which is never right here. The new tests bind real sockets with real certificates, because what is under test is exactly what an in-process test client bypasses. They verify against a context built the way the consumer builds it, with VERIFY_X509_STRICT cleared: real panels ship a minimal authority with no key identifier, and verifying more strictly here would test a client that does not exist and push this simulator away from the hardware it stands in for. --- src/span_panel_simulator/app.py | 94 +++++++---- src/span_panel_simulator/bootstrap.py | 66 +++++++- src/span_panel_simulator/const.py | 4 + src/span_panel_simulator/discovery.py | 19 ++- .../supervisor_discovery.py | 9 +- tests/test_bootstrap_tls.py | 156 ++++++++++++++++++ tests/test_discovery_advertiser.py | 59 +++++++ tests/test_supervisor_discovery.py | 23 +++ 8 files changed, 388 insertions(+), 42 deletions(-) create mode 100644 tests/test_bootstrap_tls.py create mode 100644 tests/test_discovery_advertiser.py diff --git a/src/span_panel_simulator/app.py b/src/span_panel_simulator/app.py index f0cdd24..3420168 100644 --- a/src/span_panel_simulator/app.py +++ b/src/span_panel_simulator/app.py @@ -25,6 +25,7 @@ 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, @@ -111,6 +112,7 @@ 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, @@ -126,6 +128,7 @@ 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 @@ -139,6 +142,9 @@ 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 @@ -155,9 +161,9 @@ def __init__( # Port allocation # ------------------------------------------------------------------ - def _allocate_port(self) -> int: - """Return the lowest available port from the base.""" - port = self._base_http_port + 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 while port in self._used_ports: port += 1 self._used_ports.add(port) @@ -179,6 +185,10 @@ 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) @@ -343,59 +353,78 @@ async def _start_panel(self, config_path: Path) -> PanelInstance: self._panels[config_path] = panel self._serial_to_panel[serial] = panel - # Create per-panel bootstrap HTTP server with port allocation - port = self._allocate_port() - server = BootstrapHttpServer( - serial, - self._firmware, - self._certs, - panel_schema, - broker_username=self._broker_username, - broker_password=self._broker_password, - broker_host=self._broker_host, - port=port, - ) + # Create the per-panel bootstrap server on a freshly allocated port + # pair. Both are retried together: the server binds HTTP and HTTPS as + # one unit, so a collision on either means this pair is unusable and + # the panel needs another. + # Bound outside the closure: `self._certs` is optional and mutable, so + # the narrowing asserted at the top of this method does not reach into + # a nested function. + certs = self._certs + + def _build(http_port: int, https_port: int) -> BootstrapHttpServer: + return BootstrapHttpServer( + serial, + self._firmware, + certs, + panel_schema, + broker_username=self._broker_username, + broker_password=self._broker_password, + broker_host=self._broker_host, + port=http_port, + https_port=https_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) try: await server.start() break except OSError as exc: if exc.errno != errno.EADDRINUSE: raise - _LOGGER.warning("Port %d in use for panel %s, trying next port", port, serial) - self._release_port(port) - port = self._allocate_port() - server = BootstrapHttpServer( + _LOGGER.warning( + "Ports %d/%d in use for panel %s, trying the next pair", + port, + https_port, serial, - self._firmware, - self._certs, - panel_schema, - broker_username=self._broker_username, - broker_password=self._broker_password, - broker_host=self._broker_host, - port=port, ) + 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 for panel {serial} " + f"Could not find an available port pair 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 + serial, self._firmware, model=panel_model, port=port, https_port=https_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) + await self._supervisor_discovery.register_panel(serial, port, https_port) - _LOGGER.info("Registered panel %s from %s on port %d", serial, config_path.name, port) + _LOGGER.info( + "Registered panel %s from %s on ports %d/%d (http/https)", + serial, + config_path.name, + port, + https_port, + ) return panel async def _load_recorder_data(self, config_path: Path) -> RecorderDataSource | None: @@ -543,10 +572,13 @@ async def _stop_panel(self, config_path: Path) -> None: if server is not None: await server.stop() - # Release allocated port + # Release the allocated port 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: diff --git a/src/span_panel_simulator/bootstrap.py b/src/span_panel_simulator/bootstrap.py index 90e1cd2..b73c195 100644 --- a/src/span_panel_simulator/bootstrap.py +++ b/src/span_panel_simulator/bootstrap.py @@ -1,8 +1,24 @@ """Bootstrap HTTP server — single-panel per instance. Each simulated panel gets its own ``BootstrapHttpServer`` bound to a -unique port, matching real SPAN hardware where each panel is a separate -device on a different IP. +unique port pair, matching real SPAN hardware where each panel is a +separate device on a different IP. + +Two listeners serve the same routes, mirroring a real panel's 80/443: + + * HTTP -- how a consumer that holds no anchor yet reaches the panel. It + probes ``/api/v2/status`` to decide whether this is a SPAN panel at all + and fetches ``/api/v2/certificate/ca`` to obtain one. Both necessarily + predate the anchor, so neither can be behind it. + * HTTPS -- the same routes under the leaf this panel's published authority + signed. A consumer pins that authority and then does everything else + here, so registration -- the exchange carrying the passphrase and + returning the broker password -- never crosses the wire in the clear. + +The route table is deliberately not split between them. Which endpoints a +consumer chooses to reach over which listener is the consumer's decision, +and hard-coding one client's current split into the panel would make the +simulator lie about hardware the moment that client changed its mind. Endpoints: GET /api/v2/status -> panel identity (serialNumber, firmwareVersion) @@ -16,6 +32,7 @@ import contextlib import logging import secrets +import ssl import time from typing import TYPE_CHECKING @@ -54,7 +71,8 @@ def __init__( broker_password: str = DEFAULT_BROKER_PASSWORD, broker_host: str = "localhost", host: str = "0.0.0.0", - port: int = 443, + port: int = 80, + https_port: int = 443, ) -> None: self._serial = serial self._firmware = firmware @@ -64,6 +82,7 @@ def __init__( self._broker_host = broker_host self._host = host self._port = port + self._https_port = https_port self._homie_schema = schema.raw_json self._app = web.Application() @@ -148,21 +167,52 @@ async def _handle_schema(self, _request: web.Request) -> web.Response: # Lifecycle # ------------------------------------------------------------------ + def _ssl_context(self) -> ssl.SSLContext: + """Build the TLS context from this panel's leaf. + + The leaf is signed by the same authority ``/api/v2/certificate/ca`` + publishes, which is the whole point: a consumer that pins what the + panel hands out must find the pin validates what the panel serves. + """ + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain( + certfile=str(self._certs.server_cert_path), + keyfile=str(self._certs.server_key_path), + ) + return context + async def start(self) -> None: - """Start the HTTP server.""" + """Start the HTTP and HTTPS listeners. + + Both are bound before either is reported started, so a caller that + retries on ``EADDRINUSE`` never inherits a half-bound server: the + runner cleanup in the failure path takes down whichever site did come + up. Started HTTPS-first so the noisier failure surfaces first. + """ self._runner = web.AppRunner(self._app) await self._runner.setup() - site = web.TCPSite(self._runner, self._host, self._port) - await site.start() + try: + https_site = web.TCPSite( + self._runner, self._host, self._https_port, ssl_context=self._ssl_context() + ) + await https_site.start() + http_site = web.TCPSite(self._runner, self._host, self._port) + await http_site.start() + except BaseException: + await self._runner.cleanup() + self._runner = None + raise _LOGGER.info( - "Bootstrap HTTP server for %s listening on %s:%d", + "Bootstrap server for %s listening on http://%s:%d and https://%s:%d", self._serial, self._host, self._port, + self._host, + self._https_port, ) async def stop(self) -> None: - """Stop the HTTP server.""" + """Stop both listeners.""" if self._runner is not None: await self._runner.cleanup() self._runner = None diff --git a/src/span_panel_simulator/const.py b/src/span_panel_simulator/const.py index 751b872..a827897 100644 --- a/src/span_panel_simulator/const.py +++ b/src/span_panel_simulator/const.py @@ -9,6 +9,10 @@ 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 DASHBOARD_PORT = 18080 # Default simulation parameters diff --git a/src/span_panel_simulator/discovery.py b/src/span_panel_simulator/discovery.py index 4d9c7fd..4c5c88d 100644 --- a/src/span_panel_simulator/discovery.py +++ b/src/span_panel_simulator/discovery.py @@ -83,7 +83,13 @@ async def stop(self) -> None: _LOGGER.info("mDNS advertiser stopped") async def register_panel( - self, serial: str, firmware: str, *, model: str = "MAIN_32", port: int = 80 + self, + serial: str, + firmware: str, + *, + model: str = "MAIN_32", + port: int = 80, + https_port: int = 443, ) -> None: """Advertise a panel on the local network. @@ -114,6 +120,14 @@ async def register_panel( if port != 80: ebus_properties["httpPort"] = str(port) + # And httpsPort likewise. A consumer that pins the published authority + # has to know where the leaf it validates is served, and only this + # process knows: the port is allocated per panel and reallocated across + # restarts, so a consumer left to guess would guess 443 and find + # nothing. Publishing it is what keeps that question off the user. + if https_port != 443: + ebus_properties["httpsPort"] = str(https_port) + # _span._tcp properties span_properties: dict[str, str] = { "serialNumber": serial, @@ -151,10 +165,11 @@ async def register_panel( self._services[serial] = services _LOGGER.info( - "Advertised panel %s on %s (ebus SRV port 0, HTTP port %d)", + "Advertised panel %s on %s (ebus SRV port 0, HTTP port %d, HTTPS port %d)", serial, ", ".join(addresses), port, + https_port, ) async def unregister_panel(self, serial: str) -> None: diff --git a/src/span_panel_simulator/supervisor_discovery.py b/src/span_panel_simulator/supervisor_discovery.py index ad4299e..d7332ee 100644 --- a/src/span_panel_simulator/supervisor_discovery.py +++ b/src/span_panel_simulator/supervisor_discovery.py @@ -99,11 +99,17 @@ async def cleanup_stale(self) -> None: finally: await session.close() - async def register_panel(self, serial: str, port: int) -> 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. + + ``https_port`` is published alongside the HTTP one because a consumer + that pins the authority this panel serves then has to reach the leaf + that authority signed, and this process is the only party that knows + where: the port is allocated per panel and reallocated across restarts. + Omitting it leaves the consumer to assume 443 and find nothing there. """ if not self._token: return @@ -114,6 +120,7 @@ async def register_panel(self, serial: str, port: int) -> None: "config": { "host": host, "port": port, + "https_port": https_port, "serial": serial, }, } diff --git a/tests/test_bootstrap_tls.py b/tests/test_bootstrap_tls.py new file mode 100644 index 0000000..67d42c7 --- /dev/null +++ b/tests/test_bootstrap_tls.py @@ -0,0 +1,156 @@ +"""Tests for the bootstrap server's TLS listener. + +These bind real sockets with real certificates rather than driving the app +through ``TestServer``, because what is under test is precisely the part +``TestServer`` bypasses: that the panel actually serves TLS, and that the leaf +it serves is signed by the authority it publishes over plain HTTP. + +That pairing is the whole contract a pinning consumer depends on. It fetches +the authority unpinned, checks it validates what the panel serves, and only +then sends the passphrase -- so a panel that publishes an authority it cannot +back with a live leaf fails that consumer at the last step before +registration, which is exactly where the failure is most expensive. +""" + +from __future__ import annotations + +import socket +import ssl +from pathlib import Path +from unittest.mock import MagicMock + +import aiohttp +import pytest + +from span_panel_simulator.bootstrap import BootstrapHttpServer +from span_panel_simulator.certs import generate_certificates +from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION + +_SERIAL = "sim-tls-001" + + +def _free_port() -> int: + """Return a port that was free a moment ago.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +@pytest.fixture +async def running_server(tmp_path: Path): + """A started panel server on real ports with real certificates.""" + certs = generate_certificates(tmp_path, advertise_address="127.0.0.1") + + schema = MagicMock() + schema.raw_json = '{"test": true}' + + server = BootstrapHttpServer( + serial=_SERIAL, + firmware=DEFAULT_FIRMWARE_VERSION, + certs=certs, + schema=schema, + host="127.0.0.1", + port=_free_port(), + https_port=_free_port(), + ) + await server.start() + try: + yield server + finally: + await server.stop() + + +async def _published_ca(server: BootstrapHttpServer) -> str: + """Fetch the authority the way an unpinned consumer does — over plain HTTP.""" + url = f"http://127.0.0.1:{server._port}/api/v2/certificate/ca" + async with aiohttp.ClientSession() as session, session.get(url) as resp: + assert resp.status == 200 + return (await resp.read()).decode() + + +def _consumer_context(ca_pem: str) -> ssl.SSLContext: + """Build the context a pinning consumer actually uses. + + Mirrors ``span_panel_api.build_panel_ssl_context`` rather than calling it — + the library is not a dependency of the simulator and should not become one + to run a test. What must be copied exactly is the cleared + ``VERIFY_X509_STRICT``: real SPAN panels ship a minimal authority with no + Authority Key Identifier, Python 3.13 turned that flag on by default, and + the library clears it so those panels keep working. + + Verifying here with the flag left on would test a client that does not + exist, and would push this simulator towards a strictly RFC-correct + authority that real hardware does not have — retiring the coverage that + keeps the library's workaround honest. Everything that does matter stays + on: the panel's own CA is the only anchor, and the hostname is checked. + """ + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.verify_mode = ssl.CERT_REQUIRED + context.check_hostname = True + context.verify_flags &= ~ssl.VERIFY_X509_STRICT + context.load_verify_locations(cadata=ca_pem) + return context + + +async def test_tls_leaf_is_signed_by_the_published_authority( + running_server: BootstrapHttpServer, +) -> None: + """The HTTPS listener serves a leaf that the published CA validates. + + Verification is left fully on — hostname included — because a consumer + that relaxed it would not be testing the pin it relies on. + """ + context = _consumer_context(await _published_ca(running_server)) + + url = f"https://127.0.0.1:{running_server._https_port}/api/v2/status" + async with aiohttp.ClientSession() as session, session.get(url, ssl=context) as resp: + assert resp.status == 200 + assert (await resp.json())["serialNumber"] == _SERIAL + + +async def test_tls_listener_is_rejected_without_the_published_authority( + running_server: BootstrapHttpServer, +) -> None: + """A client that does not hold the panel's CA cannot verify the panel. + + Guards the inverse of the test above: it would still pass if the listener + served something publicly trusted, or if verification were quietly off. + """ + url = f"https://127.0.0.1:{running_server._https_port}/api/v2/status" + with pytest.raises(aiohttp.ClientConnectorCertificateError): + async with aiohttp.ClientSession() as session, session.get(url): + pass + + +async def test_http_listener_still_serves_the_pre_anchor_probes( + running_server: BootstrapHttpServer, +) -> None: + """Status and the CA stay reachable over plain HTTP. + + Both necessarily happen before a consumer holds an anchor — it probes + status to decide this is a SPAN panel at all, and fetches the CA to get + the anchor — so moving either behind TLS would strand it. + """ + base = f"http://127.0.0.1:{running_server._port}" + async with aiohttp.ClientSession() as session: + async with session.get(f"{base}/api/v2/status") as resp: + assert resp.status == 200 + assert (await resp.json())["serialNumber"] == _SERIAL + async with session.get(f"{base}/api/v2/certificate/ca") as resp: + assert resp.status == 200 + assert "BEGIN CERTIFICATE" in (await resp.text()) + + +async def test_register_is_available_over_tls(running_server: BootstrapHttpServer) -> None: + """Registration — the exchange carrying the passphrase — is served over TLS.""" + context = _consumer_context(await _published_ca(running_server)) + + url = f"https://127.0.0.1:{running_server._https_port}/api/v2/auth/register" + async with ( + aiohttp.ClientSession() as session, + session.post(url, json={"hopPassphrase": "sim-passphrase"}, ssl=context) as resp, + ): + assert resp.status == 200 + body = await resp.json() + assert body["serialNumber"] == _SERIAL + assert body["ebusBrokerPassword"] == "sim-password" diff --git a/tests/test_discovery_advertiser.py b/tests/test_discovery_advertiser.py new file mode 100644 index 0000000..6c19669 --- /dev/null +++ b/tests/test_discovery_advertiser.py @@ -0,0 +1,59 @@ +"""Tests for the mDNS TXT records a panel advertises. + +The ports are the point. A consumer that finds this panel over mDNS has to +learn where to reach it from the record alone, and both ports move: they are +allocated per panel and reallocated across restarts, so anything the consumer +assumes will be wrong as soon as a second panel exists. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION +from span_panel_simulator.discovery import SERVICE_TYPE_EBUS, PanelAdvertiser + + +@pytest.fixture +def advertiser() -> PanelAdvertiser: + """An advertiser with a stubbed zeroconf, so nothing touches the network.""" + adv = PanelAdvertiser(advertise_address="127.0.0.1") + adv._zeroconf = AsyncMock() + return adv + + +async def _ebus_properties(advertiser: PanelAdvertiser, **kwargs: int) -> dict[bytes, bytes]: + """Register a panel and return the TXT properties of its _ebus._tcp record.""" + with patch( + "span_panel_simulator.discovery._get_host_addresses", + return_value=["127.0.0.1"], + ): + await advertiser.register_panel("sim-001", DEFAULT_FIRMWARE_VERSION, **kwargs) + + for info in advertiser._services["sim-001"]: + if info.type == SERVICE_TYPE_EBUS: + return info.properties + raise AssertionError("no _ebus._tcp record was registered") + + +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) + + assert props[b"httpPort"] == b"8081" + assert props[b"httpsPort"] == b"8443" + + +async def test_standard_ports_are_left_unsaid(advertiser: PanelAdvertiser) -> None: + """A panel on 80/443 publishes neither port. + + Silence is how a consumer is told the panel is where it would have looked + anyway, and it is what real hardware does. Emitting the defaults would put + values in the record that mean nothing. + """ + props = await _ebus_properties(advertiser, port=80, https_port=443) + + assert b"httpPort" not in props + assert b"httpsPort" not in props diff --git a/tests/test_supervisor_discovery.py b/tests/test_supervisor_discovery.py index 28a8e53..7ca0368 100644 --- a/tests/test_supervisor_discovery.py +++ b/tests/test_supervisor_discovery.py @@ -59,6 +59,29 @@ async def test_register_panel_posts_to_supervisor(discovery: SupervisorDiscovery mock_session.post.assert_called_once() +async def test_register_panel_publishes_both_ports(discovery: SupervisorDiscovery): + """The registration carries the TLS port as well as the HTTP one. + + A consumer that pins the authority this panel publishes then has to reach + the leaf that authority signed, and only the simulator knows where: the + port is allocated per panel and reallocated across restarts. Publishing it + is what stops the consumer assuming 443 and finding nothing there. + """ + 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, 8443) + + config = mock_session.post.call_args.kwargs["json"]["config"] + assert config["port"] == 8081 + assert config["https_port"] == 8443 + + async def test_unregister_panel_deletes_from_supervisor(discovery: SupervisorDiscovery): """unregister_panel DELETEs /discovery/{uuid}.""" discovery._entries["sim-001"] = "disc-uuid-123" From 265d946f2a26e086664ac05f58de4c52aa4717d7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:57:05 -0700 Subject: [PATCH 3/4] fix(addon): advertise this machine's address, not the default gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detection took `$3` of the default route, which is the next hop. The comment above it explained why that was the host — true of a bridge-networked container, and this add-on sets `host_network: true`, so the container shares the host's network namespace and reads the host's own routing table. The value picked up was the upstream router. That address went into the leaf certificate's SAN and into the mDNS advertisement, so a neighbouring device was named as the panel and there was no address a client could reach the panel by that passed verification. It also broke the simulator-to-panelbench upgrade rehearsal, since the only remaining way in was a Supervisor hostname that differs between the two. Now taken from `ip -4 route get`, which is a routing-table lookup rather than a probe — it sends no packets and needs nothing reachable — and answers the question that matters: the source address the kernel would put on a reply. That also stays correct on an interface holding several addresses, where taking the first one listed would be arbitrary. An address supplied by the environment now wins over detection, which the previous unconditional assignment clobbered. Existing installs need no intervention: the leaf-reuse check already rejects a certificate whose SAN omits the advertised address, so the next start re-signs it and leaves the authority alone. The test executes the function out of the shipped run.sh against a stub `ip` rather than restating the pipeline, since a copy would keep passing after the original drifted — which is precisely how this broke. --- span_panel_simulator/run.sh | 38 +++++++-- tests/test_run_sh_advertise_address.py | 102 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 tests/test_run_sh_advertise_address.py diff --git a/span_panel_simulator/run.sh b/span_panel_simulator/run.sh index 2bf5a3a..9feca5f 100755 --- a/span_panel_simulator/run.sh +++ b/span_panel_simulator/run.sh @@ -19,11 +19,39 @@ LOG_LEVEL=$(jq -r '.log_level' "${OPTIONS_FILE}") DASHBOARD_ENABLED=$(jq -r '.dashboard_enabled' "${OPTIONS_FILE}") BASE_HTTP_PORT=$(jq -r '.base_http_port // 8081' "${OPTIONS_FILE}") -# Auto-detect host IP for TLS cert SAN. -# Inside a bridge-networked container the default gateway is the host. -# Strip control characters — some container ip implementations emit trailing -# non-printables that would break Python string literals or cert generation. -ADVERTISE_ADDRESS=$(ip route | awk '/default/ { print $3 }' | tr -d '[:cntrl:]' || true) +# Auto-detect the address a client on the LAN reaches this add-on at. It goes +# into the leaf certificate's SAN and into the mDNS advertisement, so getting it +# wrong leaves no address a client can verify us by. +# +# This ran `ip route | awk '/default/ { print $3 }'` and took the *gateway*. The +# reasoning held for a bridge-networked container, where the default gateway is +# the host -- but this add-on sets `host_network: true` (config.yaml), so the +# container shares the host's network namespace and reads the host's routing +# table. `$3` of `default via 192.168.65.1 dev eth0` is then the upstream +# router: a neighbouring device, named in our certificate, that is not us. +# +# `ip route get` is a routing-table lookup rather than a probe -- it sends no +# packets and needs nothing at the far address to be reachable. It answers the +# question that actually matters, which source address the kernel would put on a +# reply, and stays right on an interface holding several addresses where taking +# the first one listed would not. +# +# $ ip -4 route get 1.1.1.1 +# 1.1.1.1 via 192.168.65.1 dev eth0 src 192.168.65.19 uid 0 +# ^^^^^^^^^^^^^ what we want +# +# Control characters are stripped because some container `ip` implementations +# emit trailing non-printables, which would break cert generation downstream. +detect_advertise_address() { + ip -4 route get 1.1.1.1 2>/dev/null \ + | awk '{ for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit } }' \ + | tr -d '[:cntrl:]' || true +} + +# An address supplied by the environment wins over detection: scripts/run-local.sh +# sets one, and an operator on a multi-homed host may need to pick which of its +# addresses the panel is known by. +ADVERTISE_ADDRESS="${ADVERTISE_ADDRESS:-$(detect_advertise_address)}" export ADVERTISE_ADDRESS export CERT_DIR="/data/certs" export BROKER_USERNAME="span" diff --git a/tests/test_run_sh_advertise_address.py b/tests/test_run_sh_advertise_address.py new file mode 100644 index 0000000..54fdc61 --- /dev/null +++ b/tests/test_run_sh_advertise_address.py @@ -0,0 +1,102 @@ +"""Tests for the host-address detection in the add-on's run.sh. + +The function under test is extracted from the shipped ``run.sh`` and executed, +rather than restated here. A copy of the pipeline would keep passing after the +original drifted, and drift is precisely the failure this guards: the previous +implementation was correct for a bridge-networked container and silently became +wrong when the add-on moved to ``host_network: true``. + +``ip`` is replaced with a stub on PATH so the sample routing output is fixed and +the test says nothing about the machine it runs on. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +_RUN_SH = Path(__file__).parent.parent / "span_panel_simulator" / "run.sh" + +# `host_network: true`, so this is the *host's* table: .19 is this machine and +# .1 is the upstream router. Taking the gateway is the bug being guarded. +_ROUTE_GET = "1.1.1.1 via 192.168.65.1 dev eth0 src 192.168.65.19 uid 0\n" + + +def _detect( + ip_stdout: str, + tmp_path: Path, + *, + ip_exit: int = 0, + env_value: str | None = None, +) -> str: + """Run run.sh's detection with a stubbed ``ip`` and return what it printed.""" + source = _RUN_SH.read_text() + match = re.search(r"^detect_advertise_address\(\) \{.*?^\}", source, re.MULTILINE | re.DOTALL) + assert match, "run.sh no longer defines detect_advertise_address()" + + stub_dir = tmp_path / "bin" + stub_dir.mkdir() + stub = stub_dir / "ip" + stub.write_text(f"#!/usr/bin/env bash\ncat <<'OUT'\n{ip_stdout}OUT\nexit {ip_exit}\n") + stub.chmod(0o755) + + # Mirrors run.sh: the same `set` flags, the function, then the assignment it + # actually feeds -- so the env-override precedence is covered too. + script = ( + "set -euo pipefail\n" + f"{match.group(0)}\n" + 'ADVERTISE_ADDRESS="${ADVERTISE_ADDRESS:-$(detect_advertise_address)}"\n' + 'printf "%s" "$ADVERTISE_ADDRESS"\n' + ) + env = {"PATH": f"{stub_dir}:/usr/bin:/bin"} + if env_value is not None: + env["ADVERTISE_ADDRESS"] = env_value + + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, env=env, check=True + ) + return result.stdout + + +def test_the_hosts_own_address_is_detected(tmp_path: Path) -> None: + """The source address is taken, not the gateway it routes via.""" + assert _detect(_ROUTE_GET, tmp_path) == "192.168.65.19" + + +def test_the_gateway_is_not_mistaken_for_the_host(tmp_path: Path) -> None: + """The original defect, stated directly. + + Named separately from the test above because this is the assertion that + would have failed before the fix, and the one worth reading in a report. + """ + assert _detect(_ROUTE_GET, tmp_path) != "192.168.65.1" + + +def test_an_on_link_route_with_no_gateway_still_yields_the_source(tmp_path: Path) -> None: + """A destination on the local segment has no `via`, so `src` shifts position. + + Guards against a parser that counted fields instead of finding the keyword. + """ + on_link = "10.0.0.7 dev eth0 src 10.0.0.3 uid 0\n" + assert _detect(on_link, tmp_path) == "10.0.0.3" + + +def test_control_characters_are_stripped(tmp_path: Path) -> None: + """Some container `ip` builds emit trailing non-printables.""" + noisy = "1.1.1.1 via 192.168.65.1 dev eth0 src 192.168.65.19\r uid 0\n" + assert _detect(noisy, tmp_path) == "192.168.65.19" + + +def test_no_route_leaves_the_address_empty(tmp_path: Path) -> None: + """A failing `ip` must not abort the script under `set -euo pipefail`. + + An empty address costs the leaf its IP SAN. Failing to boot costs the panel + entirely, so the empty answer is the right one. + """ + assert _detect("", tmp_path, ip_exit=2) == "" + + +def test_an_explicit_address_is_not_overridden(tmp_path: Path) -> None: + """run-local.sh sets this, and a multi-homed host may need to choose.""" + assert _detect(_ROUTE_GET, tmp_path, env_value="10.9.9.9") == "10.9.9.9" From 56174a2b06b66f830c72bf905f107c73260821c6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:57:13 -0700 Subject: [PATCH 4/4] chore(release): 1.2.0 Minor rather than patch: the panel gains a TLS listener and a new discovery field, which is added capability, not only repair. --- CHANGELOG.md | 226 +++++++++++++-------------- pyproject.toml | 2 +- span_panel_simulator/Dockerfile | 2 +- span_panel_simulator/config.yaml | 2 +- src/span_panel_simulator/__init__.py | 2 +- 5 files changed, 112 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2384a..2936443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 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 +by the same authority `/api/v2/certificate/ca` publishes, which is what a consumer that pins that authority is entitled to assume — and until now could not, +because nothing ever served the certificate this add-on has always minted. + +**This is what unblocks adding a simulated panel to Home Assistant.** The integration fetches the authority, checks it validates what the panel actually serves, +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 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. + +### Fixed + +- **A panel registered through the Supervisor now records the discovery entry it was given**, where the reply was read from the wrong level of the response and + logged as an invalid uuid despite the registration having succeeded. +- **Discovery entries are now removed when the add-on stops**, instead of accumulating in the Supervisor across every restart because the entry to delete was + never stored. +- **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. + ## 1.1.0 — 2026-08-28 — a fixed certificate authority, so a firmware upgrade looks like one **The certificate authority is now shipped with the package rather than generated at startup**, and is identical to the one panelbench ships. Stopping this @@ -84,151 +111,110 @@ firmware does. `clone` now carries the flag over from the source panel, so a clo ### Also fixed -- **The dashboard shows a locked circuit's priority as the panel publishes it.** Its entity view read the shed priority - off the circuit's template, so a never-backup circuit would have displayed the template's value while the wire carried - `OFF_GRID`. +- **The dashboard shows a locked circuit's priority as the panel publishes it.** Its entity view read the shed priority off the circuit's template, so a + never-backup circuit would have displayed the template's value while the wire carried `OFF_GRID`. - **`circuit/sheddable` no longer claims a relay that cannot open is sheddable.** The consumer rule the guide gives for the retired property is `priority != NEVER && relay-controllable`; the second conjunct was missing, so an always-on circuit at `OFF_GRID` or `SOC_THRESHOLD` priority published `sheddable: true` while the emitter correctly refused to shed it. ## 1.0.16 — 2026-08-11 — EVSE nodes carry the drive serial -**An EVSE's Homie node id is now its drive serial**, where it used to be a positional -slot — `evse`, `evse-2`. No panel publishes those. Firmware names the node after the -drive, the flat parser keys its snapshot on the node id verbatim, and the integration -builds the Home Assistant device identifier from that key — so against real firmware an -EVSE is already serial-identified. v1.0 names the same device `-` and -strips it back to the serial, reproducing the flat key exactly. - -That equality is what carries a drive's identity, history and automations across a -firmware upgrade. The positional slot broke it: upgrading re-keyed every EVSE, so Home -Assistant built new devices and stranded the old ones with their entities Unavailable — -two devices for one drive, same serial, one live and one dead. The defect was only ever -in this simulator, but this simulator is what the upgrade path is rehearsed against, so -it made a passing rehearsal out of a migration that does not survive. - -**The default serial is lower-case** (`sim-evse-`), because it is a topic level -now and not merely a property value: Homie 5 allows only `a`-`z`, `0`-`9` and `-` in a -topic-level id. A configured `evse.serial_number` outside that set is refused at build -time rather than sanitised — rewriting it would publish a node id that no longer matches -the `info/serial-number` beside it, quietly reintroducing the mismatch this release -removes. - -**Upgrade note.** Existing simulator users will see their EVSE devices re-key once, on -first run after this version: the old positional-slot devices are stranded and can be -deleted from the device registry. Panels running real firmware are unaffected, having -been serial-keyed all along. +**An EVSE's Homie node id is now its drive serial**, where it used to be a positional slot — `evse`, `evse-2`. No panel publishes those. Firmware names the node +after the drive, the flat parser keys its snapshot on the node id verbatim, and the integration builds the Home Assistant device identifier from that key — so +against real firmware an EVSE is already serial-identified. v1.0 names the same device `-` and strips it back to the serial, reproducing the flat +key exactly. + +That equality is what carries a drive's identity, history and automations across a firmware upgrade. The positional slot broke it: upgrading re-keyed every +EVSE, so Home Assistant built new devices and stranded the old ones with their entities Unavailable — two devices for one drive, same serial, one live and one +dead. The defect was only ever in this simulator, but this simulator is what the upgrade path is rehearsed against, so it made a passing rehearsal out of a +migration that does not survive. + +**The default serial is lower-case** (`sim-evse-`), because it is a topic level now and not merely a property value: Homie 5 allows only `a`-`z`, `0`-`9` +and `-` in a topic-level id. A configured `evse.serial_number` outside that set is refused at build time rather than sanitised — rewriting it would publish a +node id that no longer matches the `info/serial-number` beside it, quietly reintroducing the mismatch this release removes. + +**Upgrade note.** Existing simulator users will see their EVSE devices re-key once, on first run after this version: the old positional-slot devices are +stranded and can be deleted from the device registry. Panels running real firmware are unaffected, having been serial-keyed all along. ## 1.0.15 — 2026-08-06 — the flat schema release -The flat (`node-on-parent`) Homie data model, which SPAN firmware speaks today. Development -of the parent/child (v1.0) model continues separately and will take over `main`; the flat -lineage stays reachable at this tag and on the `flat` branch, which is where any further -flat-only fix belongs. +The flat (`node-on-parent`) Homie data model, which SPAN firmware speaks today. Development of the parent/child (v1.0) model continues separately and will take +over `main`; the flat lineage stays reachable at this tag and on the `flat` branch, which is where any further flat-only fix belongs. -Nothing in the published surface changed since 1.0.14 — no new entities, no topic moves, no -configuration changes. Both fixes below landed after 1.0.14 was cut, which is the reason to -re-cut rather than leave that tag as the flat reference. +Nothing in the published surface changed since 1.0.14 — no new entities, no topic moves, no configuration changes. Both fixes below landed after 1.0.14 was cut, +which is the reason to re-cut rather than leave that tag as the flat reference. ### Fixed -- **The Home Assistant long-lived token no longer appears in the process command line.** It - was passed to the simulator as `--ha-token`, which put it in `argv`, readable by any - process listing on the machine. It is now exported to the child process instead, and - `.env` is the documented place to set it. If you ran a previous version with a token on - the command line, rotate it. -- **`--stop` waits for the process to actually exit before returning.** It sent SIGTERM and - returned immediately, which is not the same as being stopped: graceful shutdown clears - retained topics and closes the broker connection before releasing the dashboard and HTTP - ports. Anything stopping and immediately restarting on the same ports raced the old - process and failed with "address already in use", which made `--restart` intermittently - fail. Waits up to 10s per process, then forces. +- **The Home Assistant long-lived token no longer appears in the process command line.** It was passed to the simulator as `--ha-token`, which put it in `argv`, + readable by any process listing on the machine. It is now exported to the child process instead, and `.env` is the documented place to set it. If you ran a + previous version with a token on the command line, rotate it. +- **`--stop` waits for the process to actually exit before returning.** It sent SIGTERM and returned immediately, which is not the same as being stopped: + graceful shutdown clears retained topics and closes the broker connection before releasing the dashboard and HTTP ports. Anything stopping and immediately + restarting on the same ports raced the old process and failed with "address already in use", which made `--restart` intermittently fail. Waits up to 10s per + process, then forces. ## 1.0.14 — 2026-07-31 — vendor the flat emitter ### Fixed -- **The HA add-on image could never start.** `ebus_emitter` is a hard, unconditional import - (`app.py` → `emitter_adapter/runtime.py`), but the Dockerfile installs only - `pip install --no-cache-dir .`, and the package was not a declared dependency — it is not - on PyPI and was installed editable from `EBUS_EMITTER_PATH` by `scripts/dev-setup.sh`. - The image therefore built successfully and failed at container start with - `ModuleNotFoundError: No module named 'ebus_emitter'`. The same applied to anyone who - cloned this repo and ran `uv sync` without `dev-setup.sh`. Vendoring removes the external - dependency entirely, so both paths now work. +- **The HA add-on image could never start.** `ebus_emitter` is a hard, unconditional import (`app.py` → `emitter_adapter/runtime.py`), but the Dockerfile + installs only `pip install --no-cache-dir .`, and the package was not a declared dependency — it is not on PyPI and was installed editable from + `EBUS_EMITTER_PATH` by `scripts/dev-setup.sh`. The image therefore built successfully and failed at container start with + `ModuleNotFoundError: No module named 'ebus_emitter'`. The same applied to anyone who cloned this repo and ran `uv sync` without `dev-setup.sh`. Vendoring + removes the external dependency entirely, so both paths now work. ### Changed -- **The flat emitter is vendored at `src/span_panel_simulator/flat_emitter`**, copied from - `ebus-emitter` 0.2.1 (commit `5b84de8`) — MIT, same copyright holders. The upstream repo - has permanently diverged onto the parent/child (v1.0) Homie data model while this - simulator continues to publish the flat schema, so the dependency delivered no upstream - changes while costing path configuration, stale editable metadata, and an unsolvable - distribution problem for the add-on. See the package docstring for full provenance. - - It also closes a correctness hazard: `clone.py` seeds energy accumulators against what - this code publishes, and while the two lived in separate repos each side could look - locally correct while jointly inverting circuit energy — which is exactly what happened. - Both ends now sit in one repo under one test run. - -- **The emitter's test suite came with it** (`tests/flat_emitter/`, 154 tests), including - the circuit energy reference-frame regression tests. Total suite is now 395 tests. - -- **`scripts/dev-setup.sh` is now a thin `uv sync` wrapper** and `.env.example` no longer - defines `EBUS_EMITTER_PATH`; every dependency resolves from PyPI. - -- **`ebus-sdk` is pinned to `==0.1.5`** rather than the range upstream declared, so that - vendoring is behaviour-neutral: 0.1.5 is what the emitter's lockfile resolved and what - this code was tested against. Letting it float within `<0.2` resolves 0.1.10, which drops - the module-level `setLevel(INFO)` on the `homie` logger that `tests/test_main_logging.py` - guards. Raising it is a deliberate follow-up, not a side effect of moving code. - -- **`[tool.ruff.lint]` now declares `ignore = ["TC001", "TC002", "TC003"]`.** The existing - comment already described this ignore, but the key was never present — none of the - simulator's own modules happened to trigger the rules, so the omission was invisible. - The vendored code was authored under an identical select list plus this ignore. - -- **`ChargeMode` is exported from the vendored package** and used to annotate the - `charge_mode` derivation in `engine.py` and `emitter_adapter/runtime.py`. Both sites - already produced only valid values; mypy could not see it while `ebus_emitter` was an - `ignore_missing_imports` module and `BESSConfig` was therefore `Any`. - -> **Version note.** This work merged as 1.0.13, the same version the circuit-energy fix -> below had already published. The add-on image tag is derived from `config.yaml`, so the -> second merge overwrote the first's image without changing the version — leaving anyone -> who had already pulled 1.0.13 on the earlier build with no update signal. Re-cut as -> 1.0.14 so Supervisor sees a change. +- **The flat emitter is vendored at `src/span_panel_simulator/flat_emitter`**, copied from `ebus-emitter` 0.2.1 (commit `5b84de8`) — MIT, same copyright + holders. The upstream repo has permanently diverged onto the parent/child (v1.0) Homie data model while this simulator continues to publish the flat schema, + so the dependency delivered no upstream changes while costing path configuration, stale editable metadata, and an unsolvable distribution problem for the + add-on. See the package docstring for full provenance. + + It also closes a correctness hazard: `clone.py` seeds energy accumulators against what this code publishes, and while the two lived in separate repos each + side could look locally correct while jointly inverting circuit energy — which is exactly what happened. Both ends now sit in one repo under one test run. + +- **The emitter's test suite came with it** (`tests/flat_emitter/`, 154 tests), including the circuit energy reference-frame regression tests. Total suite is + now 395 tests. + +- **`scripts/dev-setup.sh` is now a thin `uv sync` wrapper** and `.env.example` no longer defines `EBUS_EMITTER_PATH`; every dependency resolves from PyPI. + +- **`ebus-sdk` is pinned to `==0.1.5`** rather than the range upstream declared, so that vendoring is behaviour-neutral: 0.1.5 is what the emitter's lockfile + resolved and what this code was tested against. Letting it float within `<0.2` resolves 0.1.10, which drops the module-level `setLevel(INFO)` on the `homie` + logger that `tests/test_main_logging.py` guards. Raising it is a deliberate follow-up, not a side effect of moving code. + +- **`[tool.ruff.lint]` now declares `ignore = ["TC001", "TC002", "TC003"]`.** The existing comment already described this ignore, but the key was never present + — none of the simulator's own modules happened to trigger the rules, so the omission was invisible. The vendored code was authored under an identical select + list plus this ignore. + +- **`ChargeMode` is exported from the vendored package** and used to annotate the `charge_mode` derivation in `engine.py` and `emitter_adapter/runtime.py`. Both + sites already produced only valid values; mypy could not see it while `ebus_emitter` was an `ignore_missing_imports` module and `BESSConfig` was therefore + `Any`. + +> **Version note.** This work merged as 1.0.13, the same version the circuit-energy fix below had already published. The add-on image tag is derived from +> `config.yaml`, so the second merge overwrote the first's image without changing the version — leaving anyone who had already pulled 1.0.13 on the earlier +> build with no update signal. Re-cut as 1.0.14 so Supervisor sees a change. ## 1.0.13 — 2026-07-31 — circuit energy reference frame ### Fixed -- **Clone energy seeds were read in the wrong reference frame.** `clone.py` seeded - `initial_consumed_energy_wh` from a scraped panel's `imported-energy` and - `initial_produced_energy_wh` from its `exported-energy`. The wire is enclosure-framed: - `exported-energy` is energy the enclosure exported *to* a circuit (normal load - consumption) and `imported-energy` is energy it imported *from* a circuit (backfeed). - The two are now read the correct way round, in both the initial-translation path - (`_translate_circuit`) and the refresh path (`update_config_from_scrape`). - - This mirrors the fix in `ebus-emitter` 0.2.1, which corrected the same inversion on the - publish side. The two were previously wrong in a mutually cancelling way — clone read - `imported-energy` into "consumed" and the emitter published "consumed" back out as - `imported-energy` — so a cloned panel round-tripped its wire values faithfully while - every value carried the wrong meaning. Correcting only one side would have broken the - round-trip, so they move together. - -- **Test fixtures encoded the same inversion.** `test_clone.py` gave a load circuit a - rising `imported-energy` and a backfeeding solar circuit a rising `exported-energy`, - which is the reverse of what a real panel publishes, and one fixture comment described - positive `active-power` as "export" when on the wire it means the enclosure is importing - from the circuit. Fixtures and the two energy-seeding test names now describe the - enclosure frame. +- **Clone energy seeds were read in the wrong reference frame.** `clone.py` seeded `initial_consumed_energy_wh` from a scraped panel's `imported-energy` and + `initial_produced_energy_wh` from its `exported-energy`. The wire is enclosure-framed: `exported-energy` is energy the enclosure exported _to_ a circuit + (normal load consumption) and `imported-energy` is energy it imported _from_ a circuit (backfeed). The two are now read the correct way round, in both the + initial-translation path (`_translate_circuit`) and the refresh path (`update_config_from_scrape`). + + This mirrors the fix in `ebus-emitter` 0.2.1, which corrected the same inversion on the publish side. The two were previously wrong in a mutually cancelling + way — clone read `imported-energy` into "consumed" and the emitter published "consumed" back out as `imported-energy` — so a cloned panel round-tripped its + wire values faithfully while every value carried the wrong meaning. Correcting only one side would have broken the round-trip, so they move together. + +- **Test fixtures encoded the same inversion.** `test_clone.py` gave a load circuit a rising `imported-energy` and a backfeeding solar circuit a rising + `exported-energy`, which is the reverse of what a real panel publishes, and one fixture comment described positive `active-power` as "export" when on the wire + it means the enclosure is importing from the circuit. Fixtures and the two energy-seeding test names now describe the enclosure frame. ### Requires -- **ebus-emitter >= 0.2.1**, which carries the matching publish-side fix. Pairing this - release with an older emitter reinstates the inversion. +- **ebus-emitter >= 0.2.1**, which carries the matching publish-side fix. Pairing this release with an older emitter reinstates the inversion. ## 1.0.12 — 2026-07-30 — emitter live-schema alignment and abstraction @@ -237,9 +223,13 @@ re-cut rather than leave that tag as the flat reference. - **Emitter schema alignment**: Adapter updated to work with emitter's live SPAN panel Homie 5 schema (flat node layout, accurate topology and properties). - **Lugs IDs**: Updated to match emitter convention (`lugs-upstream`, `lugs-downstream`). - **BESS/PV feeds**: Updated spec_generator to derive device feeds and metadata from circuit templates (stable circuit UUID linkage). -- **Simulator adapter**: Updated `spec_generator.py` and `runtime.py` to pass EVSE powers to emitter, set `clear_retained=True` on clone stop for graceful shutdown. +- **Simulator adapter**: Updated `spec_generator.py` and `runtime.py` to pass EVSE powers to emitter, set `clear_retained=True` on clone stop for graceful + shutdown. ### Fixed -- **Dev bootstrap dependency drift**: `scripts/dev-setup.sh` installed `ebus-emitter` with a bare `uv pip install --editable`, which re-resolves the emitter's dependency constraints against PyPI and ignores its `uv.lock`. A fresh bootstrap pulled `ebus-sdk` 0.12.0 — whose `Device` constructor is incompatible with the 0.1.x API the emitter targets — and panel startup died with `AttributeError: 'NoneType' object has no attribute 'get'` in `connect_broker()`. The script now installs the emitter's locked runtime dependencies first, then the emitter itself with `--no-deps`, so the venv matches what the emitter pins. +- **Dev bootstrap dependency drift**: `scripts/dev-setup.sh` installed `ebus-emitter` with a bare `uv pip install --editable`, which re-resolves the emitter's + dependency constraints against PyPI and ignores its `uv.lock`. A fresh bootstrap pulled `ebus-sdk` 0.12.0 — whose `Device` constructor is incompatible with + the 0.1.x API the emitter targets — and panel startup died with `AttributeError: 'NoneType' object has no attribute 'get'` in `connect_broker()`. The script + now installs the emitter's locked runtime dependencies first, then the emitter itself with `--no-deps`, so the venv matches what the emitter pins. - **Type safety**: Fixed mypy error in simulator runtime (`_first_feed_for_device_type`) where template_name could be None. diff --git a/pyproject.toml b/pyproject.toml index 36a4725..b111600 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "span-panel-simulator" -version = "1.1.0" +version = "1.2.0" 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 4dc3553..c4ee0ae 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.1.0" \ + io.hass.version="1.2.0" \ io.hass.arch="aarch64|amd64" CMD ["/run.sh"] diff --git a/span_panel_simulator/config.yaml b/span_panel_simulator/config.yaml index 546c0d1..e2d638d 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.1.0" +version: "1.2.0" 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 f4bcd18..626065e 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.1.0" +__version__ = "1.2.0"