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

## 2.5.3 — two simulated panels no longer publish over each other

**A simulated panel already added to Home Assistant has to be removed and re-added**, because its circuits get new device ids and so its circuit entities get
new unique ids.

### Fixed

- **Two simulated panels running in one add-on no longer collide on their circuits' topics**, where circuit device ids were derived from the circuit id alone
and the shipped configs reuse ids such as `solar_inverter` across panel sizes, so each panel's readings overwrote the other's every tick.

## 2.5.2 — discovery hands over an address, not an internal alias

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@ ebus/5/{serial}/{node}/{property}
| `evse-0` | EV charger: status, lock state, advertised current |
| `power-flows` | Aggregated: PV, battery, grid, site power |

`{circuit-uuid}` is a UUID v5 over `{panel-serial}/{circuit-id}`. It is scoped to the panel because the shipped configs reuse circuit ids such as
`solar_inverter` across panel sizes, and two panels in one add-on publish to one broker.

### Settable Properties

Control circuits by publishing to `/set` topics:
Expand Down
2 changes: 1 addition & 1 deletion panelbench/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 PanelBench" \
io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \
io.hass.type="addon" \
io.hass.version="2.5.2" \
io.hass.version="2.5.3" \
io.hass.arch="aarch64|amd64"

CMD ["/run.sh"]
2 changes: 1 addition & 1 deletion panelbench/config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: "SPAN PanelBench"
description: "Simulates a SPAN electrical panel for testing and upgrade modeling"
version: "2.5.2"
version: "2.5.3"
slug: "panelbench"
url: "https://github.com/SpanPanel/panelbench"
image: "ghcr.io/spanpanel/panelbench/{arch}"
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 = "panelbench"
version = "2.5.2"
version = "2.5.3"
description = "Standalone eBus simulator for SPAN panels"
requires-python = ">=3.14"
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion src/panelbench/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Standalone eBus simulator for SPAN panels."""

__version__ = "2.5.2"
__version__ = "2.5.3"
34 changes: 29 additions & 5 deletions src/panelbench/emitter_adapter/instance_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

Circuit UUIDs were lifted from publisher.py so the simulator's derivation matches
what the legacy publisher produced: UUID v5 with a fixed namespace, so the same
circuit_id always yields the same UUID across restarts.
circuit always yields the same UUID across restarts. The name hashed is
``<panel-serial>/<circuit-id>`` rather than the circuit id alone — see
`stable_circuit_uuid`.

The rest of the device ids live here for a different reason. They follow the
migration guide's Device ID Stability table, because **a Home Assistant entity's
Expand All @@ -13,7 +15,7 @@

Panel <panel-serial>
Lugs <panel-serial>-lugs-{up,dn}
Circuit <circuit-uuid>
Circuit <circuit-uuid> (uuid5 of <panel-serial>/<circuit-id>)
BESS/PV/EVSE <proxier-id>-<identifier> (proxied; proxier is the panel)
MID <bess-id>-mid

Expand All @@ -40,9 +42,31 @@
_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.

Every other id here is already panel-scoped, because every other device is named
relative to its proxier. A circuit's was not: it hashed the YAML `id` alone, and
those ids are shared vocabulary across the shipped configs — `MAIN_32.yaml` and
`MAIN_40.yaml` both have a `solar_inverter` and an `oven`. One panel per broker
makes that harmless, which is why it survived; two panels in one add-on publish
to one broker, and the two `solar_inverter`s then claim the same device id and
the same `ebus/5/<uuid>/...` topics. Both panels write every tick, so each
circuit's readings alternate between two unrelated loads and a consumer sees
energy counters that fall as often as they rise.

Hashing `<panel-serial>/<circuit-id>` scopes the id to its owner the way the
`<proxier>-<identifier>` forms do. The separator is `/` and not `-` because a
panel serial may itself contain `-`, and any separator the serial can contain
admits two different (panel, circuit) pairs hashing the same name.

Real firmware cannot hit the collision — a panel has its own broker — so this is
an emulator-only defect. The derivation is nonetheless shared byte-for-byte with
the flat simulator, which publishes the same circuit ids for the same config: the
firmware-upgrade rehearsal stops one and starts the other on one panel, and a
circuit whose id changed at the swap strands its Home Assistant history.
"""
return str(uuid.uuid5(_CIRCUIT_NAMESPACE, f"{panel_id}/{circuit_id}")).replace("-", "")


def lugs_device_ids(panel_id: str) -> tuple[str, str]:
Expand Down
11 changes: 9 additions & 2 deletions src/panelbench/emitter_adapter/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,10 @@ async def start_clone(
``start`` or ``stop``."""
manifest = build_manifest(engine.config)

uuid_to_circuit_id = {stable_circuit_uuid(c["id"]): c["id"] for c in engine.config["circuits"]}
panel_id = engine.config["panel_config"]["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 @@ -431,6 +434,10 @@ def _feeds_for_device_type(config: SimulationConfig, device_type: str) -> list[s
circuits = config.get("circuits")
if not isinstance(templates, dict) or not isinstance(circuits, list):
return []
# Read from the config being walked, not from a serial handed in beside it: a
# feed id must be the id the *owner* of these circuits publishes, and taking
# both from one mapping is what makes that true by construction.
panel_id = config["panel_config"]["serial_number"]
feeds: list[str] = []
for item in circuits:
if not isinstance(item, dict):
Expand All @@ -442,5 +449,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
9 changes: 5 additions & 4 deletions src/panelbench/emitter_adapter/spec_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ def _lugs_instances(profile: SimulationConfig) -> list[DeviceInstance]:

def _circuit_instances(profile: SimulationConfig) -> list[DeviceInstance]:
templates = profile.get("circuit_templates") or {}
panel_id = profile["panel_config"]["serial_number"]
instances: list[DeviceInstance] = []
for idx, c in enumerate(profile.get("circuits") or [], start=1):
tabs = c.get("tabs") or [0]
Expand All @@ -165,7 +166,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 @@ -331,18 +332,18 @@ def _pv_instance(profile: SimulationConfig) -> DeviceInstance | None:

def _evse_instances(profile: SimulationConfig) -> list[DeviceInstance]:
evse_cfg = profile.get("evse") or {}
panel_id = profile["panel_config"]["serial_number"]
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")),
"model": str(evse_cfg.get("product", "SPAN Drive")),
Expand Down Expand Up @@ -465,7 +466,7 @@ def _feed_circuit_id(profile: SimulationConfig, 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(profile["panel_config"]["serial_number"], circuit["id"])


def _first_circuit_for_device_type(
Expand Down
3 changes: 2 additions & 1 deletion src/panelbench/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -931,11 +931,12 @@ async def get_tick_inputs(self) -> dict[str, Any]:
# Apply the v0.3.0 sign convention: producers (PV) report negative
# power; consumers report positive. SimulatedCircuit stores magnitudes
# only; the energy_mode tells direction.
panel_id = self.serial_number
circuit_powers: dict[str, float] = {}
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
Loading