From 4299fe7d98ca8432addb2d425b6a3516d55628cd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:37:58 -0700 Subject: [PATCH] fix(emitter): derive circuit ids from panel serial to match panelbench `stable_circuit_uuid` derived a circuit's uuid from the YAML circuit id alone. That id is a key every panel configuration reuses (`solar_inverter`, `oven`), so it was not unique to a panel. PanelBench publishes circuits flat, at `ebus/5//...` with no serial in the path, so an unscoped id collided there on the wire; it now scopes by the panel serial. This repository must derive the same id byte for byte. Stopping this simulator and starting PanelBench is how a firmware upgrade is rehearsed on a single 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 history and automations were stranded on the old device. The derivation now hashes `/`, and `tests/test_instance_ids_contract.py` pins the literal PanelBench pins, so a one-sided change fails both suites. 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 -- but it was not a collision on this simulator's own wire: `wire/mapping/circuit.yaml` places a circuit `node-on-parent` with `device_id_source: parent`, so every topic is rooted at the panel serial and two simulated panels never overwrote each other's retained values. The serial is `panel_config.serial_number`, read once per module and threaded down rather than re-derived: a second derivation would be free to read a different key and would break the contract without colliding with anything. Circuit ids change, so a simulated panel already added to Home Assistant has to be removed and re-added. --- CHANGELOG.md | 14 ++++++ DEVELOPER.md | 2 +- pyproject.toml | 2 +- span_panel_simulator/Dockerfile | 2 +- span_panel_simulator/config.yaml | 2 +- src/span_panel_simulator/__init__.py | 2 +- .../emitter_adapter/instance_ids.py | 42 ++++++++++++++-- .../emitter_adapter/runtime.py | 34 ++++++++++--- .../emitter_adapter/spec_generator.py | 33 ++++++------ src/span_panel_simulator/engine.py | 6 ++- tests/emitter_adapter/test_instance_ids.py | 8 +-- tests/emitter_adapter/test_runtime.py | 21 ++++++-- tests/emitter_adapter/test_spec_generator.py | 50 +++++++++++++++++-- tests/flat_emitter/test_never_backup.py | 3 +- tests/test_instance_ids_contract.py | 27 ++++++++++ 15 files changed, 203 insertions(+), 45 deletions(-) create mode 100644 tests/test_instance_ids_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4c68b..0f00c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DEVELOPER.md b/DEVELOPER.md index 9c17e9f..430ecd7 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -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 | diff --git a/pyproject.toml b/pyproject.toml index b96fffe..5a44b68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/span_panel_simulator/Dockerfile b/span_panel_simulator/Dockerfile index 17ecff9..35327cd 100644 --- a/span_panel_simulator/Dockerfile +++ b/span_panel_simulator/Dockerfile @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080 LABEL io.hass.name="SPAN Panel Simulator" \ io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \ io.hass.type="addon" \ - io.hass.version="1.2.1" \ + io.hass.version="1.2.2" \ io.hass.arch="aarch64|amd64" CMD ["/run.sh"] diff --git a/span_panel_simulator/config.yaml b/span_panel_simulator/config.yaml index acd9484..9b8bed1 100644 --- a/span_panel_simulator/config.yaml +++ b/span_panel_simulator/config.yaml @@ -1,6 +1,6 @@ name: "SPAN Panel Simulator" description: "Simulates a SPAN electrical panel for testing and upgrade modeling" -version: "1.2.1" +version: "1.2.2" 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 25aeb47..2f3712d 100644 --- a/src/span_panel_simulator/__init__.py +++ b/src/span_panel_simulator/__init__.py @@ -1,3 +1,3 @@ """Standalone eBus simulator for SPAN panels.""" -__version__ = "1.2.1" +__version__ = "1.2.2" diff --git a/src/span_panel_simulator/emitter_adapter/instance_ids.py b/src/span_panel_simulator/emitter_adapter/instance_ids.py index 80b44bc..a10bdaf 100644 --- a/src/span_panel_simulator/emitter_adapter/instance_ids.py +++ b/src/span_panel_simulator/emitter_adapter/instance_ids.py @@ -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 ``/``, 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//…`` 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 @@ -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("-", "") diff --git a/src/span_panel_simulator/emitter_adapter/runtime.py b/src/span_panel_simulator/emitter_adapter/runtime.py index 9d285b7..7ccc9d1 100644 --- a/src/span_panel_simulator/emitter_adapter/runtime.py +++ b/src/span_panel_simulator/emitter_adapter/runtime.py @@ -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. @@ -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) @@ -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. @@ -344,7 +356,7 @@ 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) @@ -352,12 +364,20 @@ def _evse_tick_inputs( } -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): @@ -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 diff --git a/src/span_panel_simulator/emitter_adapter/spec_generator.py b/src/span_panel_simulator/emitter_adapter/spec_generator.py index 0536e22..a443158 100644 --- a/src/span_panel_simulator/emitter_adapter/spec_generator.py +++ b/src/span_panel_simulator/emitter_adapter/spec_generator.py @@ -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( @@ -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): @@ -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), @@ -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"))) @@ -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")), @@ -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( diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index ff9898d..e52583a 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -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 @@ -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, diff --git a/tests/emitter_adapter/test_instance_ids.py b/tests/emitter_adapter/test_instance_ids.py index 32d4739..2138f42 100644 --- a/tests/emitter_adapter/test_instance_ids.py +++ b/tests/emitter_adapter/test_instance_ids.py @@ -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") diff --git a/tests/emitter_adapter/test_runtime.py b/tests/emitter_adapter/test_runtime.py index 421b896..bed18aa 100644 --- a/tests/emitter_adapter/test_runtime.py +++ b/tests/emitter_adapter/test_runtime.py @@ -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": {}, @@ -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} diff --git a/tests/emitter_adapter/test_spec_generator.py b/tests/emitter_adapter/test_spec_generator.py index 65150c6..9b2af7c 100644 --- a/tests/emitter_adapter/test_spec_generator.py +++ b/tests/emitter_adapter/test_spec_generator.py @@ -29,11 +29,16 @@ def test_build_manifest_includes_bess_when_enabled() -> None: def test_build_manifest_derives_pv_and_evse_from_device_type_templates() -> None: - manifest = build_manifest(_profile()) + profile = _profile() + # Read from the fixture rather than hardcoded: a feed uuid is scoped to the + # owning panel, so a test that scoped with some other serial would assert a + # value the manifest never emits. + panel_id = profile["panel_config"]["serial_number"] + manifest = build_manifest(profile) pv = manifest.of_class("pv")[0] assert pv.instance_id == "pv" - assert pv.metadata["feed"] == stable_circuit_uuid("solar_inverter") + assert pv.metadata["feed"] == stable_circuit_uuid(panel_id, "solar_inverter") assert pv.metadata["relative-position"] == "IN_PANEL" # The node id is the drive serial, not a positional slot. `evse` / `evse-2` were @@ -43,12 +48,12 @@ def test_build_manifest_derives_pv_and_evse_from_device_type_templates() -> None # still pass if one of them were derived some other way that happened to match. evse = manifest.of_class("evse")[0] assert evse.instance_id == evse.metadata["serial-number"] - assert evse.metadata["feed"] == stable_circuit_uuid("span_drive_garage") + assert evse.metadata["feed"] == stable_circuit_uuid(panel_id, "span_drive_garage") assert len(manifest.of_class("evse")) == 2 second = manifest.of_class("evse")[1] assert second.instance_id == second.metadata["serial-number"] assert second.instance_id != evse.instance_id, "two drives must not share a node id" - assert second.metadata["feed"] == stable_circuit_uuid("span_drive_driveway") + assert second.metadata["feed"] == stable_circuit_uuid(panel_id, "span_drive_driveway") def test_build_manifest_omits_native_devices_when_disabled() -> None: @@ -220,3 +225,40 @@ def test_circuit_relay_behavior_translates_underscore_to_hyphen() -> None: c = build_manifest(profile).of_class("circuit")[0] assert c.metadata["relay-behavior"] == "always-on" assert c.metadata["always-on"] == "true" + + +def test_two_panels_sharing_circuit_ids_emit_distinct_circuit_devices() -> None: + """The same YAML circuit ids appear in every panel config, so an unscoped + uuid gave two panels one circuit identity. PanelBench publishes circuits + flat and collided on the wire for this reason; here the ids must simply + match PanelBench's, so that swapping one for the other does not re-key + every circuit.""" + + def _profile_for(serial: str) -> dict: + return { + "panel_config": {"serial_number": serial, "total_tabs": 40, "main_size": 200}, + "circuit_templates": {"lighting": {"priority": "NICE_TO_HAVE"}}, + "circuits": [ + {"id": "kitchen", "name": "Kitchen", "template": "lighting", "tabs": [1]}, + {"id": "oven", "name": "Oven", "template": "lighting", "tabs": [3]}, + ], + } + + first = {c.instance_id for c in build_manifest(_profile_for("sim-a")).of_class("circuit")} + second = {c.instance_id for c in build_manifest(_profile_for("sim-b")).of_class("circuit")} + assert len(first) == 2 + assert not first & second + + +def test_evse_feed_points_at_the_circuit_device_this_panel_publishes() -> None: + """The feed is a cross-reference to a circuit's device id, so it has to be + scoped with the same serial the circuit instance was — not left unscoped + while the circuit moved, which would point every drive at nothing.""" + profile = { + "panel_config": {"serial_number": "sim-a", "total_tabs": 40, "main_size": 200}, + "circuit_templates": {"span_drive": {"device_type": "evse"}}, + "circuits": [{"id": "garage", "name": "Garage", "template": "span_drive", "tabs": [1]}], + } + manifest = build_manifest(profile) + circuit_ids = {c.instance_id for c in manifest.of_class("circuit")} + assert manifest.of_class("evse")[0].metadata["feed"] in circuit_ids diff --git a/tests/flat_emitter/test_never_backup.py b/tests/flat_emitter/test_never_backup.py index d4df316..483b8a9 100644 --- a/tests/flat_emitter/test_never_backup.py +++ b/tests/flat_emitter/test_never_backup.py @@ -338,11 +338,12 @@ def _first_never_priority_circuit(profile: dict[str, Any]) -> tuple[dict[str, An """The first circuit in a profile whose template is priority NEVER and which no installer locked, with its emitted instance id. ``None`` when the profile has no such circuit — it then has nothing to say about the defect.""" + panel_id = profile["panel_config"]["serial_number"] templates = profile["circuit_templates"] for circuit in profile["circuits"]: template = templates.get(circuit.get("template", ""), {}) if template.get("priority") == "NEVER" and "never_backup" not in circuit: - return circuit, stable_circuit_uuid(circuit["id"]) + return circuit, stable_circuit_uuid(panel_id, circuit["id"]) return None diff --git a/tests/test_instance_ids_contract.py b/tests/test_instance_ids_contract.py new file mode 100644 index 0000000..eaa413b --- /dev/null +++ b/tests/test_instance_ids_contract.py @@ -0,0 +1,27 @@ +"""The circuit-uuid derivation is a contract shared with PanelBench. + +PanelBench emits the same panel over the same broker namespace, and stopping one +to start the other is how a firmware upgrade is rehearsed on a single panel. If +the two derived a circuit's device id differently, every circuit would change +identity at the swap — so both repositories pin the same literal, and a change +made on one side alone fails the other side's suite. +""" + +from span_panel_simulator.emitter_adapter.instance_ids import stable_circuit_uuid + +#: Cross-repo contract with panelbench: the same panel serial and circuit id must +#: derive this exact value there. Do not regenerate it to match a code change — +#: a mismatch means the derivation moved, which is the thing being guarded. +SOLAR_INVERTER_ON_SIM_40T_001 = "be87c32bda4f5cd9abbf6d3995ae28c0" + + +def test_derivation_matches_the_panelbench_contract() -> None: + assert stable_circuit_uuid("sim-40t-001", "solar_inverter") == SOLAR_INVERTER_ON_SIM_40T_001 + + +def test_the_same_circuit_id_on_two_panels_gets_two_ids() -> None: + """The collision this scoping exists to remove: two panels in one add-on + share a broker, and a circuit id is a YAML key both panel configs reuse.""" + first = stable_circuit_uuid("sim-40t-001", "solar_inverter") + second = stable_circuit_uuid("sim-40t-002", "solar_inverter") + assert first != second