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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "span-panel-simulator"
version = "1.2.0"
version = "1.2.1"
description = "Standalone eBus simulator for SPAN panels"
requires-python = ">=3.14"
dependencies = [
Expand Down
3 changes: 3 additions & 0 deletions scripts/run-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion span_panel_simulator/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080
LABEL io.hass.name="SPAN Panel Simulator" \
io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \
io.hass.type="addon" \
io.hass.version="1.2.0" \
io.hass.version="1.2.1" \
io.hass.arch="aarch64|amd64"

CMD ["/run.sh"]
2 changes: 1 addition & 1 deletion span_panel_simulator/config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: "SPAN Panel Simulator"
description: "Simulates a SPAN electrical panel for testing and upgrade modeling"
version: "1.2.0"
version: "1.2.1"
slug: "span_panel_simulator"
url: "https://github.com/SpanPanel/simulator"
image: "ghcr.io/spanpanel/simulator/{arch}"
Expand Down
2 changes: 1 addition & 1 deletion src/span_panel_simulator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Standalone eBus simulator for SPAN panels."""

__version__ = "1.2.0"
__version__ = "1.2.1"
62 changes: 26 additions & 36 deletions src/span_panel_simulator/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)")
Expand Down
30 changes: 26 additions & 4 deletions src/span_panel_simulator/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions src/span_panel_simulator/supervisor_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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": {
Expand Down
24 changes: 21 additions & 3 deletions tests/test_discovery_advertiser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand All @@ -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
Loading