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

## 1.2.2 — 2026-08-28 — a circuit keeps its identity across the swap to panelbench

**Circuit ids are now derived from the panel serial as well as the circuit id, matching panelbench byte for byte.** Stopping this add-on and starting
panelbench is how a firmware upgrade is rehearsed on one panel, and Home Assistant keys a circuit's entities on the id the panel publishes — so while the two
derived that id differently, every circuit changed identity at the swap and its entities were stranded.

**A simulated panel already added to Home Assistant has to be removed and re-added.** Its circuits get new ids, so their entities do not carry over; nothing
else about the panel changes, and a panel added after this release needs nothing.

### Fixed

- **A circuit now keeps its identity when a simulated panel is swapped for the panelbench build of the same panel**, where the two derived circuit ids
differently and every circuit arrived as a new device with its history and automations left behind on the old one.

## 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
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ ebus/5/{serial}/{node}/{property}
| `core` | Panel state: door, relay, voltages, grid status, dominant power source |
| `upstream-lugs` | Grid-facing: power, currents, energy |
| `downstream-lugs` | Load-facing: feedthrough power, currents |
| `{circuit-uuid}` | Per-circuit: relay, power, energy, shed-priority |
| `{circuit-uuid}` | Per-circuit: relay, power, energy, shed-priority. UUIDv5 of `{serial}/{circuit-id}` |
| `bess-0` | Battery: SOC, grid-state, capacity |
| `pv-0` | Solar inverter: nameplate capacity |
| `evse-0` | EV charger: status, lock state, advertised current |
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.1"
version = "1.2.2"
description = "Standalone eBus simulator for SPAN panels"
requires-python = ">=3.14"
dependencies = [
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.1" \
io.hass.version="1.2.2" \
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.1"
version: "1.2.2"
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.1"
__version__ = "1.2.2"
42 changes: 37 additions & 5 deletions src/span_panel_simulator/emitter_adapter/instance_ids.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,40 @@
"""Stable ID derivation for emitter manifest entries.

Lifted from publisher.py so the simulator's UUID derivation matches what the legacy
publisher produced. UUID v5 with a fixed namespace ensures the same circuit_id always
produces the same UUID across simulator restarts."""
publisher produced. UUID v5 with a fixed namespace ensures the same circuit always
produces the same UUID across simulator restarts.

A circuit's uuid is derived from ``<panel-serial>/<circuit-id>``, not from the
circuit id alone. A circuit id is a YAML key (``solar_inverter``, ``oven``) that
every panel config reuses, so an unscoped derivation gave two panels the same
circuit uuid.

**This derivation is a contract with PanelBench and must match it byte for byte.**
PanelBench publishes circuits flat, at ``ebus/5/<circuit-uuid>/…`` with no serial
in the path, so an unscoped id collided there on the wire; it scopes by the panel
serial to fix that. Stopping this simulator and starting PanelBench is how a
firmware upgrade is rehearsed on a single panel, and Home Assistant keys a circuit
entity on the uuid the panel publishes — so if the two repositories derived a
circuit's id differently, every circuit would change identity at the swap and
strand the entities that carry its history and automations. That is why this
module changed, and why ``tests/test_instance_ids_contract.py`` pins a literal
PanelBench pins too.

Scoping is also correct here in its own right: a circuit id shared across panels
is one identity to any consumer keyed on the node id alone. It was not, however,
a collision on this simulator's own wire. ``wire/mapping/circuit.yaml`` places a
circuit ``node-on-parent`` with ``device_id_source: parent`` and
``$description_owner: parent``, so a circuit is a node on the panel device rather
than a device of its own, every topic is rooted at the panel's serial, and two
simulated panels never overwrote each other's retained values.

The panel serial threaded in here must be the one the manifest publishes —
``panel_config.serial_number`` — and must reach every call site from that single
key. Deriving it a second way (a config file name, a container hostname, a CLI
override read before the ``sim-`` prefix is applied) yields a different uuid for
the same circuit, which breaks the PanelBench contract without colliding with
anything.
"""

from __future__ import annotations

Expand All @@ -11,6 +43,6 @@
_CIRCUIT_NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")


def stable_circuit_uuid(circuit_id: str) -> str:
"""Return a deterministic dashless UUID for a circuit identifier."""
return str(uuid.uuid5(_CIRCUIT_NAMESPACE, circuit_id)).replace("-", "")
def stable_circuit_uuid(panel_id: str, circuit_id: str) -> str:
"""Deterministic dashless UUID for a circuit, scoped to the panel that owns it."""
return str(uuid.uuid5(_CIRCUIT_NAMESPACE, f"{panel_id}/{circuit_id}")).replace("-", "")
34 changes: 27 additions & 7 deletions src/span_panel_simulator/emitter_adapter/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,14 @@ async def start_clone(
3. Default 127.0.0.1:1883 anonymous (no TLS)."""
manifest = build_manifest(engine.config)

uuid_to_circuit_id = {stable_circuit_uuid(c["id"]): c["id"] for c in engine.config["circuits"]}
# ``engine.serial_number`` is the loaded config's ``panel_config.serial_number``
# — the same key ``build_manifest`` scopes circuit uuids with, read after the
# ``sim-`` prefix has been applied. Reading it any other way here would map the
# published uuids back to nothing.
panel_id = engine.serial_number
uuid_to_circuit_id = {
stable_circuit_uuid(panel_id, c["id"]): c["id"] for c in engine.config["circuits"]
}

# The emitter registers internal default /set handlers from the empty
# SetterRegistry — no producer-side wiring required.
Expand Down Expand Up @@ -311,7 +318,11 @@ async def publish_tick(runtime: CloneRuntime) -> EbusPanelSnapshot:
current_time=raw["current_time"],
grid_online=raw["grid_online"],
circuits=raw["circuits"],
evse=_evse_tick_inputs(runtime.engine.config, raw["circuits"]),
evse=_evse_tick_inputs(
runtime.engine.config,
runtime.engine.serial_number,
raw["circuits"],
),
envelope=PanelEnvelopeTick(),
)
return await runtime.emitter.publish_tick(tick)
Expand All @@ -326,6 +337,7 @@ async def stop_clone(runtime: CloneRuntime, *, graceful: bool = True) -> None:

def _evse_tick_inputs(
config: SimulationConfig,
panel_id: str,
circuit_powers: dict[str, float],
) -> dict[str, float]:
"""Mirror EVSE feed-circuit power into TickInputs.evse.
Expand All @@ -344,20 +356,28 @@ def _evse_tick_inputs(
raw_feed = evse_cfg.get("feed")
explicit_feed = str(raw_feed) if raw_feed else None

feeds = [explicit_feed] if explicit_feed else _feeds_for_device_type(config, "evse")
feeds = [explicit_feed] if explicit_feed else _feeds_for_device_type(config, panel_id, "evse")
return {
(base_evse_id if idx == 1 else f"{base_evse_id}-{idx}"): circuit_powers.get(feed, 0.0)
for idx, feed in enumerate(feeds, start=1)
if feed is not None
}


def _first_feed_for_device_type(config: SimulationConfig, device_type: str) -> str | None:
feeds = _feeds_for_device_type(config, device_type)
def _first_feed_for_device_type(
config: SimulationConfig,
panel_id: str,
device_type: str,
) -> str | None:
feeds = _feeds_for_device_type(config, panel_id, device_type)
return feeds[0] if feeds else None


def _feeds_for_device_type(config: SimulationConfig, device_type: str) -> list[str]:
def _feeds_for_device_type(
config: SimulationConfig,
panel_id: str,
device_type: str,
) -> list[str]:
templates = config.get("circuit_templates")
circuits = config.get("circuits")
if not isinstance(templates, dict) or not isinstance(circuits, list):
Expand All @@ -373,5 +393,5 @@ def _feeds_for_device_type(config: SimulationConfig, device_type: str) -> list[s
if isinstance(template, dict) and template.get("device_type") == device_type:
circuit_id = item.get("id")
if circuit_id is not None:
feeds.append(stable_circuit_uuid(str(circuit_id)))
feeds.append(stable_circuit_uuid(panel_id, str(circuit_id)))
return feeds
33 changes: 18 additions & 15 deletions src/span_panel_simulator/emitter_adapter/spec_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,24 +59,28 @@ def _normalise_inverter_type(raw: str) -> str:
def build_manifest(profile: SimulationConfig) -> DeviceManifest:
"""Walk the loaded SimulationConfig dict; emit a DeviceManifest the emitter
consumes. Identity + physics — no behaviour, no schedule, no modelling."""
# Derived once and threaded down: circuit uuids are scoped to the owning
# panel, so every helper must scope with the same serial the panel instance
# publishes. A helper re-reading it would be free to read it from somewhere
# else, and a circuit's identity would split in two the day one did.
panel_id = str(profile["panel_config"]["serial_number"])
instances: list[DeviceInstance] = [
_panel_instance(profile),
_panel_instance(profile, panel_id),
*_lugs_instances(profile),
*_circuit_instances(profile),
*_circuit_instances(profile, panel_id),
]
bess = _bess_instance(profile)
if bess is not None:
instances.append(bess)
pv = _pv_instance(profile)
pv = _pv_instance(profile, panel_id)
if pv is not None:
instances.append(pv)
instances.extend(_evse_instances(profile))
instances.extend(_evse_instances(profile, panel_id))
return DeviceManifest(instances=tuple(instances))


def _panel_instance(profile: SimulationConfig) -> DeviceInstance:
def _panel_instance(profile: SimulationConfig, panel_id: str) -> DeviceInstance:
panel_cfg = profile["panel_config"]
panel_id = panel_cfg["serial_number"]
panel_size = int(panel_cfg.get("total_tabs", 40))
panel_model = PANEL_SIZE_TO_MODEL.get(panel_size, f"MAIN_{panel_size}")
return DeviceInstance(
Expand Down Expand Up @@ -117,7 +121,7 @@ def _lugs_instances(profile: SimulationConfig) -> list[DeviceInstance]:
]


def _circuit_instances(profile: SimulationConfig) -> list[DeviceInstance]:
def _circuit_instances(profile: SimulationConfig, panel_id: str) -> list[DeviceInstance]:
templates = profile.get("circuit_templates") or {}
instances: list[DeviceInstance] = []
for idx, c in enumerate(profile.get("circuits") or [], start=1):
Expand All @@ -141,7 +145,7 @@ def _circuit_instances(profile: SimulationConfig) -> list[DeviceInstance]:
instances.append(
DeviceInstance(
entity_class="circuit",
instance_id=stable_circuit_uuid(c["id"]),
instance_id=stable_circuit_uuid(panel_id, c["id"]),
display_name=c.get("name", c["id"]),
metadata={
"tab-numbers": ",".join(str(int(t)) for t in tabs if t),
Expand Down Expand Up @@ -191,9 +195,9 @@ def _bess_instance(profile: SimulationConfig) -> DeviceInstance | None:
)


def _pv_instance(profile: SimulationConfig) -> DeviceInstance | None:
def _pv_instance(profile: SimulationConfig, panel_id: str) -> DeviceInstance | None:
pv_cfg = profile.get("pv") or {}
pv_feed = _feed_circuit_id(profile, "pv")
pv_feed = _feed_circuit_id(profile, panel_id, "pv")
if not pv_cfg.get("enabled") and pv_feed is None:
return None
inverter_type = _normalise_inverter_type(str(pv_cfg.get("inverter_type", "ac-coupled")))
Expand Down Expand Up @@ -226,20 +230,19 @@ def _pv_instance(profile: SimulationConfig) -> DeviceInstance | None:
)


def _evse_instances(profile: SimulationConfig) -> list[DeviceInstance]:
def _evse_instances(profile: SimulationConfig, panel_id: str) -> list[DeviceInstance]:
evse_cfg = profile.get("evse") or {}
feed_circuits = _circuits_for_device_type(profile, "evse")
explicit_feed = str(evse_cfg["feed"]) if "feed" in evse_cfg else None
if explicit_feed:
feeds = [explicit_feed]
else:
feeds = [stable_circuit_uuid(circuit["id"]) for circuit in feed_circuits]
feeds = [stable_circuit_uuid(panel_id, circuit["id"]) for circuit in feed_circuits]
if not feeds and evse_cfg.get("enabled"):
feeds = [""]
if not feeds:
return []

panel_id = profile["panel_config"]["serial_number"]
base_metadata = {
"vendor-name": str(evse_cfg.get("vendor", "SPAN")),
"product-name": str(evse_cfg.get("product", "SPAN Drive")),
Expand Down Expand Up @@ -294,11 +297,11 @@ def _bess_instance_id(bess: BESSConfigYAML) -> str:
return str(bess.get("instance_id", "bess"))


def _feed_circuit_id(profile: SimulationConfig, device_type: str) -> str | None:
def _feed_circuit_id(profile: SimulationConfig, panel_id: str, device_type: str) -> str | None:
circuit = _first_circuit_for_device_type(profile, device_type)
if circuit is None:
return None
return stable_circuit_uuid(circuit["id"])
return stable_circuit_uuid(panel_id, circuit["id"])


def _first_circuit_for_device_type(
Expand Down
6 changes: 5 additions & 1 deletion src/span_panel_simulator/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,10 @@ async def get_tick_inputs(self) -> dict[str, Any]:

from span_panel_simulator.emitter_adapter.instance_ids import stable_circuit_uuid

# Circuit uuids are scoped to the owning panel, so the keys handed to the
# emitter must be scoped with the serial the manifest was built from —
# ``self.serial_number`` reads the same ``panel_config`` key.
panel_id = self.serial_number
current_time = self._clock.current_time
for _cid, circuit in self._circuits.items():
# Tab-sync grouping currently has no override path — the
Expand All @@ -928,7 +932,7 @@ async def get_tick_inputs(self) -> dict[str, Any]:
for cid, circuit in self._circuits.items():
mag = circuit.instant_power_w
signed = -mag if circuit.energy_mode == "producer" else mag
circuit_powers[stable_circuit_uuid(cid)] = signed
circuit_powers[stable_circuit_uuid(panel_id, cid)] = signed

return {
"current_time": current_time,
Expand Down
8 changes: 4 additions & 4 deletions tests/emitter_adapter/test_instance_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@


def test_stable_circuit_uuid_is_dashless() -> None:
uid = stable_circuit_uuid("kitchen-circuit-1")
uid = stable_circuit_uuid("sim-40t-001", "kitchen-circuit-1")
assert len(uid) == 32
assert "-" not in uid


def test_stable_circuit_uuid_is_deterministic() -> None:
a = stable_circuit_uuid("kitchen-circuit-1")
b = stable_circuit_uuid("kitchen-circuit-1")
a = stable_circuit_uuid("sim-40t-001", "kitchen-circuit-1")
b = stable_circuit_uuid("sim-40t-001", "kitchen-circuit-1")
assert a == b


def test_stable_circuit_uuid_differs_for_different_inputs() -> None:
assert stable_circuit_uuid("a") != stable_circuit_uuid("b")
assert stable_circuit_uuid("sim-40t-001", "a") != stable_circuit_uuid("sim-40t-001", "b")
21 changes: 18 additions & 3 deletions tests/emitter_adapter/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ def test_load_shedding_config_custom_threshold() -> None:


def test_evse_tick_inputs_include_each_evse_feed() -> None:
panel_id = "sim-40t-001"
config = {
"panel_config": {"serial_number": panel_id},
"circuit_templates": {
"span_drive": {"device_type": "evse"},
"lighting": {},
Expand All @@ -97,10 +99,23 @@ def test_evse_tick_inputs_include_each_evse_feed() -> None:
],
}
circuit_powers = {
stable_circuit_uuid("span_drive_garage"): 7200.0,
stable_circuit_uuid("span_drive_driveway"): 3600.0,
stable_circuit_uuid(panel_id, "span_drive_garage"): 7200.0,
stable_circuit_uuid(panel_id, "span_drive_driveway"): 3600.0,
}
assert _evse_tick_inputs(config, circuit_powers) == {
assert _evse_tick_inputs(config, panel_id, circuit_powers) == {
"evse": 7200.0,
"evse-2": 3600.0,
}


def test_evse_tick_inputs_miss_the_feed_when_scoped_to_another_panel() -> None:
"""The feed lookup and the tick keys must be scoped with the same serial.
Scoping them differently does not raise -- it silently reports every drive
at zero -- so the mismatch is pinned rather than left to be noticed."""
config = {
"panel_config": {"serial_number": "sim-40t-001"},
"circuit_templates": {"span_drive": {"device_type": "evse"}},
"circuits": [{"id": "span_drive_garage", "template": "span_drive"}],
}
circuit_powers = {stable_circuit_uuid("sim-40t-001", "span_drive_garage"): 7200.0}
assert _evse_tick_inputs(config, "sim-40t-002", circuit_powers) == {"evse": 0.0}
Loading